-
在探索编程世界的深渊时,我们常常会遇到一些令人困惑的概念,尤其是当它们与我们现实生活中的事物息息相关时。面向对象编程(OOP)就是这样一个强大的概念,它不仅仅是一种编程范式,更是一种模拟和理解现实世界的有效工具。但是,作为初学者,面向对象的抽象性会让我们难以把握。例如,如何通过编程语言来模拟现实世界的复杂性?JavaScript中的对象是如何封装和继承属性的?更进一步,JavaScript如何通过属性描述符精细控制对象的行为?
-
本文将带大家深入面向对象编程的核心,探讨JavaScript中对象的基础和高级特性,解答上述问题。我们将从对象如何模拟现实世界的实体开始,探索对象的封装和继承机制,并深入理解JavaScript特有的属性描述符。通过对这些概念的讨论,将获得更深层次的理解,能够更自信地应用这些概念来解决实际问题
-
接下来,让我们一起揭开JavaScript面向对象编程的神秘面纱,逐步解锁编程中的抽象艺术
一、面向对象是现实的抽象方式
现实世界的东西大多数都是可以在编程中抽象出来的
编程是对现实世界的抽象,而面向对象是对现实世界抽象的一种方式
-
作为刚开始学习面向对象的我们来说,这两句话不是很好理解,但等我们学完再回头看的时候,一定能够有所领悟
1.1. 什么是面向对象
-
面向对象编程是一种使用包含数据和行为的对象来模拟现实世界实体的编程范式。这种方法便于封装、继承和多态性,代码更易管理、复用和扩展
-
而在JS当中所体现的主要来源于封装和继承,封装在日常处处都会用到,我们使用函数方法就是一种封装
-
而继承则是我们的重点,
原型链
的概念就来自于继承
-
-
对象是JavaScript中一个非常重要的概念,这是因为对象可以将多个相关联的数据封装到一起,更好的描述一个事物:
-
比如我们可以描述一辆车:Car,具有颜色(color)、速度(speed)、品牌(brand)、价格(price),行驶(travel)等等
扫描二维码关注公众号,回复: 17443003 查看本文章 -
比如我们可以描述一个人:Person,具有姓名(name)、年龄(age)、身高(height),吃东西(eat)、跑步(run)等等
-
-
用对象来描述事物,更有利于我们将现实的事物,抽离成代码中某个数据结构:
-
所以有一些编程语言就是纯面向对象的编程语言,比Java
-
我们在实现任何现实抽象时都需要先创建一个类,根据类再去创建对象,在纯面向对象的语言中都是这么做的
-
1.2. JS中的面向对象
-
JavaScript其实支持多种编程范式,包括函数式编程和面向对象编程:
{ key:value }
-
key是一个标识符名称,value可以是任意类型,也可以是其他对象或者函数类型
-
如果值是一个函数,那么我们可以称之为是对象的方法
-
JavaScript中的对象被设计成一组属性的无序集合,像是一个哈希表,由key和value组成
-
-
那我们要如何创建一个对象?
-
早期使用创建对象的方式最多的是使用Object类,并且使用new关键字来创建一个对象:
-
这是因为早期很多JavaScript开发者是从Java过来的,他们也更习惯于Java中通过new的方式创建一个对象
-
在JavaScript的早期发展阶段,Java语言非常受欢迎,被认为是互联网的未来。因此,当时的Netscape公司希望通过将这种新的客户端脚本语言与Java关联起来,来利用Java的流行度为JavaScript增添吸引力,而这也是早期代码中很多使用这种方式来创建对象的原因,因为最早的一批用户中有很多都是Java的开发者
-
这门语言的命名是早期互联网时代一种常见的市场策略,但本身也足够优秀且后劲十足才能越来越壮大
-
-
后来很多开发者为了方便起见,都是直接通过字面量的形式来创建对象:
-
这种形式看起来更加的简洁,并且对象和属性之间的内聚性也更强,所以这种方式后来就流行了起来
-
1.2.1. 创建对象的方式
-
使用
new Object()
的优势:-
动态性:适合在复杂的逻辑流程中动态添加或修改属性和方法,提供了更多的控制和灵活性
-
熟悉性:对于有其他面向对象编程语言背景的开发者来说,这种方式更加直观和熟悉,我们所说的从Java迁移过来的开发者就属于这部分群体
-
-
使用
对象字面量
的优势:-
简洁性:代码更简洁明了,易于编写和理解,减少了书写的复杂度
-
直观性:在定义时即可见到对象的结构,提高代码的可读性和内聚性
-
效率:通常在性能上稍优于使用构造函数,因为省略了函数调用的开销
-
// 示例 1: 使用 new Object() 创建对象
var person1 = new Object();
person1.name = "小余";
person1.age = 18;
person1.greet = function() {
console.log("Hello, my name is " + this.name + " and I am " + this.age + " years old.");
};
//上面的方式是对person1当作一个构造函数,然后通过new关键字来执行函数,这个时候也会创建出来对象
// 调用 person1 的方法
person1.greet(); // 输出: Hello, my name is 小余 and I am 18 years old.
// 示例 2: 使用对象字面量来创建对象
var person2 = {
name: "小余",
age: 18,
greet: function() {
console.log("Hello, my name is " + this.name + " and I am " + this.age + " years old.");
}
};
// 调用 person2 的方法
person2.greet(); // 输出: Hello, my name is 小余 and I am 18 years old.
1.3. 对象的数据属性描述符
在JavaScript中,数据属性描述符是一种用于控制对象属性行为的工具。它提供了对属性的更细致管理,允许开发者定义属性的各种特性。数据属性描述符包含了几个特殊的属性(或称为“特性”),这些特性决定了对象属性的行为方式
1.3.1. 对属性操作的控制
在前面,我们的属性都是直接定义在对象内部,或者直接添加到对象内部的:
-
但是这样来做的时候我们就不能对这个属性进行一些限制:
-
比如这个属性
是否是可以通过delete删除
的? -
这个属性
是否在for-in遍历的时候被遍历
出来呢?是否能够被赋值
?
-
var obj = {
name:"小余",
age:20,
sex:"男"
}
//对属性的控制
//获取属性
console.log(obj.name)//小余
//给属性赋值
obj.name = "xiaoyu"
console.log(obj.name)//xiaoyu
//删除属性
delete obj.name
console.log(obj)//{ age: 20, sex: '男' }
如果我们想要对一个属性进行比较精准的操作控制,那么我们就可以使用属性描述符
-
通过属性描述符可以精准的添加或修改对象的属性,也就是一种"特性"
-
属性描述符需要使用 Object.defineProperty 来对属性进行添加或者修改
二、Object.defineProperty
Object.defineProperty()方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象
-
会修改我们的原对象的,所以这个并不是一个纯函数
Object.defineProperty(obj, prop, descriptor)
-
可接收三个参数,其中前两个参数较为好理解:
-
obj:要定义属性的对象
-
prop:要定义或修改的属性的名称或者Symbol(后面会讲解这个类型)
-
descriptor:要定义或修改的
属性描述符
-
-
而我们本章节的标题其实就是
属性描述符
详解,最重要的内容隐藏在第三参数之中 -
返回值:被传递给函数的对象
var obj = {
name:"xiaoyu",
age:20
}
Object.defineProperty(obj,"height",{
//很多的配置
value:1.75
})
console.log(obj)
//node环境下打印
//{ name: 'xiaoyu', age: 20 }
-
咦,我们不是已经定义了一个属性了吗?为什么Node环境下没有打印出来
-
如果在浏览器控制台中,也会这样吗?
-
图13-1 浏览器下的height属性
-
通过图13-1,我们能够看到,返回值obj对象身上确实生效了height属性,那为什么两种环境下所显示的结果会不同,这又是什么原理?
-
因为height的属性是不可枚举,不可遍历的。所以我们在node环境下整体打印就看不到新增的height,但是我们可以局部打印还是可以出来的,例如下方的案例,说明这个height已经真真实实的添加到我们的obj里面了,只是我们看不到而已
-
浏览器控制台(如Chrome, Firefox等)往往对用户更加友好,它们在打印对象时可能显示所有属性,包括非枚举属性。浏览器开发者工具的目的本身就是调试,通常会提供更多的信息,帮助我们更全面地了解对象的状态,因此会选择显示非枚举属性
Node.js环境通常遵循严格的ECMAScript标准,使用像
console.log()
这样的方法打印对象时,默认只显示那些可枚举的属性。这是因为Node.js的console.log
在底层使用util.inspect()
方法,该方法默认只考虑可枚举属性
-
var obj = {
name:"xiaoyu",
age:20
}
Object.defineProperty(obj,"height",{
//很多的配置,我们在这里写入的就是属性描述符
value:1.75
})
console.log(obj.height);//Node环境下打印 1.75
-
这里涉及到一个概念,那就是什么是
可枚举属性
,我们从代码中来看吧,还是一样的案例,看区别在哪:
var obj = {
name: "xiaoyu",
age: 20
};
Object.defineProperty(obj, "height", {
value: 1.75,
enumerable: true // 设置为true使其可枚举
});
console.log(obj); // 将在所有环境中显示 { name: 'xiaoyu', age: 20, height: 1.75 }
2.1. 属性描述符分类
-
可枚举属性
是一种属性描述符,而属性描述符有很多种,分两种类型:-
数据属性(Data Properties)描述符(Descriptor)
-
存取属性(Accessor访问器 Properties)描述符(Descriptor)
-
-
并且
数据描述符
和存取描述符
是有一点冲突的:
configurable(可配置的) | enumerable(可枚举的) | value(值) | writable(可写的) | get(获取) | set(设置) | |
---|---|---|---|---|---|---|
数据描述符 | 可以 | 可以 | 可以 | 可以 | 不可以 | 不可以 |
存取描述符 | 可以 | 可以 | 不可以 | 不可以 | 可以 | 可以 |
-
我们可以看到,一共有6种
属性描述符
-
可以这样记忆:2共用2可选,同时生效最多四种
-
其中
configurable(可配置的)
和enumerable(可枚举的)
是共用的 -
value
和writable
特性与get
和set
特性存在冲突,只能二者选其一。原因在于这两种描述符定义了属性的不同行为和访问模式,导致它们的用途和实现机制互不兼容
-
2.1.1. 不兼容原因
-
因为它们各自指示了属性值是如何被存储和管理的不同方式:
-
使用
value
和writable
,我们是在说:“这个属性持有一个具体的数据值,这个值可以直接被读取和(如果writable
为true)修改。” -
使用
get
和set
,我们是在说:“这个属性的值通过特定的函数动态确定。它没有‘静态’值,而是每次访问都可能不同,依赖于这些函数的实现。”
-
-
如果同时定义了这两组特性,就会引起混淆,因为JavaScript引擎不会清楚在访问或修改属性时应该直接操作一个静态值,还是应该调用一个函数来决定这些操作。因此,JavaScript规范不允许我们这样做,以确保属性的行为是明确和一致的
-
两种不同的情况导致属性描述符分裂出两种模式:
-
数据属性描述符
-
存取属性描述符
-
2.1.2. 数据属性描述符
在接下来我们描述属性描述符的时候,会用
[[]]
来括起来属性
这是因为双方括号
[[ ]]
在JavaScript的官方文档和规范中是用来描述对象的内部属性和行为,能够帮我们区分普通的JavaScript属性和对象的底层(或元层面)行为这是一种专门为规范文档和JavaScript引擎的实现者设计的标记方式,而不是日常开发中使用的代码语法
所以下次有看到这样括起来的属性,就表示这些特性是对象的内部机制的一部分,它们不是对象上可以直接访问的属性。开发者不能直接通过脚本访问或修改这些内部属性。例如,你不能在代码中直接使用
obj.[[Configurable]]
来检查或设置一个属性的可配置性
-
数据属性描述符有如下四个特征
-
[[Configurable]]
:表示属性是否可以通过delete删除属性,是否可以修改它的特性,或者是否可以将它修改为存取属性描述符-
当我们直接在一个对象上定义某个属性时,这个属性的[[Configurable]]为true
-
当我们通过属性描述符定义一个属性时,这个属性的[[Configurable]]默认为false
-
-
[[Enumerable]]
:表示属性是否可以通过for-in或者Object.key()返回该属性;-
当我们直接在一个对象上定义某个属性时,这个属性的[[Enumerable]]为true
-
当我们通过属性描述符定义一个属性时,这个属性的[[Enumerable]]默认为false
-
-
[[Writable]]
:表示是否可以修改属性的值;-
当我们直接在一个对象上定义某个属性时,这个属性的[[Writable]]为true
-
当我们通过属性描述符定义一个属性的时候,这个属性的[[Writable]]为false
-
-
[[value]]
:属性的value值,读取属性时会返回该值,修改属性时,会对其进行修改-
默认情况下这个值是undefined
-
//name和age虽然没有使用属性描述符来定义,但是它们也是具备对应的特性的,以下是对应的默认值
//value:赋值的value
//configurable:true
//enumerable:true
//writable:true
var obj = {
name:"xiaoyu",
age:18
}
//数据属性描述符
Object.defineProperty(obj,"address",{
//很多配置
value:"福建省",//默认值undefined
//该属性不可删除,不可修改。不可以重新定义属性描述符
configurable:false//默认值false
//该特性是配置对应的属性(address)是否是可以枚举的
enumerable:true,//默认值false
//该特性是否可以赋值
writable:false//默认值false
})
delete obj.name
console.log(obj)//{ age: 18 },name被成功删除
delete obj.address
console.log(obj.address);//福建省 没删除掉,因为我们设置了不可配置configurable:false
//测试enumerable的作用
console.log(obj)
for(var key in obj){
console.log(key,'for遍历');//如果enumerable为false,则只会出来name和age,address只有设置为true的时候才会出来
}
console.log(Object.keys(obj),'keys的作用');
//enumerable前后对比
//[ 'name', 'age' ] keys的作用(enumerable:false)
//[ 'name', 'age', 'address' ] keys的作用(enumerable:true)
//测试writable的作用
obj.address = "上海市"
console.log(obj.address);//福建省,新的内容不可写入。如果我们不设置value为福建省,则在不可写入的情况下显示undefined
2.1.3. 存取属性描述符
-
数据描述符有如下四个特征:
-
[[Configurable]]
:表示属性是否可以通过delete删除属性,是否可以修改它的特性,或者是否可以将它修改为存取属性描述符-
和数据属性描述符是一致的
-
-
[[Enumerable]]
:表示属性是否可以通过for-in或者Object.keys()返回该属性;-
和数据描述符是一致的
-
-
[[get]]
:获取属性时会执行的函数,默认为undefined -
[[set]]
:设置属性时会执行的函数,默认为undefined
var obj = {
name:"xiaoyu",
age:18,
_address:"泉州市"//_开头表示私有的,不希望被人看到。我们就通过get来使用address代替掉_address。别人就通过address调用我们,而不是使用_address调用
}
//当我们使用get、set,不使用value和writable的时候,叫做存取属性描述符
//使用场景:1.隐藏某一个私有属性,不希望直接被外界使用和赋值
//2.如果我们希望截获某一个属性,它访问和设置值的过程时,我们也会使用存储属性描述符
Object.defineProperty(obj,"address",{
//很多配置
enumerable:true,
configurable:true,
//value跟writable与get、set不能共存
// value:"福建省",
// writable:true,
get:function(){
foo()//我们希望获取值的时候提醒我们一下的时候就会这么干
return this._address//将_address这个属性隐藏起来,给他套上了address这个马甲
},
set:function(value){
bar()//这样就截获了它获取值的过程,这是Vue2响应式的原理
//当我们如下对obj.address进行赋值的时候,值就通过形参传递了进来,我们在这里进行赋值的操作
this._address = value
}
})
console.log(obj)//{ name: 'xiaoyu', age: 18, address: [Getter/Setter] }
console.log(obj.address);//泉州市,get拿到了值
obj.address = "coderwhy"//我们使用的是address,而不是_address了哦,注意这里的变化
console.log(obj.address);//coderwhy
function foo(){
console.log("获取了一次address的值")
}
function bar(){
console.log("设置了一次address的值")
}
-
在
存取属性描述符
中,我们可以看到_address
的写法,这在以前是一种约定俗成的规范,一旦看到_
在单词的前面,说明这个属性是一个私有属性,不希望外部进行访问到,但本质上这和其他属性是一样的。在之后的ES6语法中可以通过#
来真正实现私有属性,和其他属性形成不同的效果,而这些我们以后都会学习到 -
此时我们再来回顾这个不兼容原因,两种命名的方式分别是数据、存取
-
数据是一个名词,而且是具体的,表示的是"静态"的
-
而存取则是动词,存进去与取出来,在JS世界中,这动态的操作则是通过函数来实现
-
-
静与动本就是相反的一对,在JS中反应了两种不同的处理方式,为了防止引起混淆,所以是不能够同时进行的,这就是他们的不兼容原因,也可以通过这种方式来进行记忆它们的特性
2.2. 学习属性描述符的意义
-
我们其实很少会去主动操作属性描述符,那为什么要去学习这些内容呢?
-
首先需要说明这个内容是很重要的
-
因为我们所有的原生对象API中都有属性描述符,有些可以遍历,有些不行,有些可以读取,有些又不行了
-
比如
for-in
为什么有时候管用,有时候不管用。我们学习了属性描述符,就能够有能力去了解每一个API的能力边界范围,并且API都是使用哪个属性描述符都是有规律的,这对于我们在恰好的时候使用恰好的API是非常关键的事情
-
-
我们提到,属性描述符是有能够说明一个API的能力边界,那我们是不是可以联想一下,技术文档会这样进行讲解吗?
-
很显然是会的,拿MDN文档举例,如图12-2,我们如果想要能够正常流畅的阅读技术文档,掌握这些内容是有必要
-
图12-2 MDN文档对API能力边界的描述
-
并且在以后阅读Vue、React之类的技术文档的时候,难度往往是没有MDN这个JS技术文档难度高的
-
因为这种类型的文档往往只是应用层面的,而且解释得较为人性化,很好理解
-
高深的词汇不会太多,像
严格模式
、纯函数
等等的,我们都已经讲解过了,在学习JS高级的过程当中,我会把后续进阶可能会遇到的词汇融合进来。等学完JS高级要继续进阶之后,阅读框架的官方文档就不会有很陡峭的门槛,例如React文档,如图12-3
-
图12-3 React文档词汇