第八章 对象、类与面向对象编程
理解对象
ECMA-262 将对象定义为一组属性的无序集合。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值
属性的类型
ECMA-262 使用一些内部特性来描述属性的特征,开发者不能在 JavaScript 中直接访问这些特性。为了将某个特性标识为内部特性,规范会用两个中括号把特性的名称括起来,比如 [[Enumerable]]
属性分两种:数据属性和访问器属性
1、数据属性
数据属性包含一个保存数据值的位置。值从这个位置读取,也会写入到这个位置。数据属性有4个特性描述它们的行为:
- [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认值为false。默认情况下,所有直接定义在对象上的属性的这个特性都是 true
- [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认值为false。默认情况下,所有直接定义在对象上的属性的这个特性都是 true
- [[Writable]]:表示属性的值是否可以修改。默认值为false。默认情况下,所有直接定义在对象上的属性的这个特性都是 true
- [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。默认值为 undefined
要修改属性的默认特性,就必须使用 Object.defineProperty()
方法:
1 | let person = {} |
writable 为 false 即 值不能在修改了,在非严格模式下尝试给这个属性重新赋值会被忽略;在严格模式下,尝试修改只读属性的值会抛出错误
一个属性被定义为不可配置之后,就不能再变回可配置的了。再次调用 Object.defineProperty() 并修改任何非 writable 属性会导致错误
2、访问器属性
访问器属性包含一个 getter 函数和一个 setter 函数,不过这两个函数不是必须的。访问器属性有4个特性描述它们的行为:
- [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,是否可以把它改为数据属性
- [[Enumerable]]:表示属性是否可以通过 for-in 循环返回
- [[Get]]:获取函数,在读取属性时调用。默认值为 undefined
- [[Set]]:设置函数,在写入属性时调用。默认值为 undefined
访问器属性必须使用 Object.defineProperty()
定义
1 | let book = { |
以上例子中,对象 book 有两个默认属性:year_和 edition。year_ 中的下划线常用来表示该属性并不希望在对象方法的外部被访问
。另一个属性 year 被定义为一个访问器属性,设置一个属性值会导致一些其他的变化,这是放问题属性的典型使用场景
获取函数和设置函数不一定都要定义。只定义获取函数意味着属性是只读的,尝试修改属性会被忽略;类似的,只有一个设置函数的属性是不能读取的,非严格模式下会返回 undefined ,严格模式下会抛出错误
定义多个属性
Object.defineProperties()
接收两个参数:要添加或修改属性的对象和另一个描述符对象,其属性与要添加或修改 的属性一一对应
1 | let book = {} |
以上代码在 book 对象上定义了两个数据属性 year_ 和 edition ,还有一个访问器属性 year。最终的对象跟上一节实例中的一样。唯一的区别是所有属性都是同时定义的,并且数据属性的 configurable、enumerable 和 writable 特性值都是 false
读取属性的特性
Object.getOwnPropertyDescriptor()
获取指定属性的属性描述符。接收两个参数:属性所在的对象和属性名。返回值是一个对象,对于访问器属性包含 configurable、enumerable、get 和 set,对于数据属性包含 configurable、enumerable、writable 和 value
1 | let book = {} |
对于数据属性 year_,value 等于原来的值,configurable 是 false,get 是 undefined
对于访问器属性 year,value 是 undefined,enumerable 是 false, get 是一个指向获取函数的指针
ECMAScript 2017 新增了 Object.getOwnPropertyDescriptors()
静态方法,这个方法会在每个自由属性上调用 Object.getOwnPropertyDescriptor() 并在一个新对象中返回它们。类似与 Object.defineProperty() 与 Object.definePropertise()
1 | let book = {} |
对象合并
Object.assign()
接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举
(Object.propertyIsEnmuerable()返回true)和自有属性
(Object.hasOwnProperty()返回true)复制到目标对象。以字符串和符号为键的属性会被复制。对每个符合条件的属性,这个方法会使用源对象上的 [[Get]] 取得属性的值,然后使用目标对象上的 [[Set]] 设置属性的值
语法:
1 | Object.assign(target,...sources) |
1 | let dest = {} |
Object.assign() 对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使用最后一个复制的值。此外,从源对象访问器属性取得的值,比如获取函数,会作为一个静态值赋值给目标对象。换句话说,不能在两个对象间转移获取函数和设置函数
如果赋值期间出错,则操作会中止并退出,同时抛出错误。抛出错误之前,目标对象上已经完成的修改会继续存在
对象标识及相等判定
Object.is()
判断两个值是否为同一个值,如果满足以下条件则两个值相等
- 都是 undefined
- 都是 null
- 都是 true 或 false
- 都是相同长度的字符串且相同字符按相同顺序排列
- 都是相同对象(意味着每个对象有同一个引用)
- 都是数字且
- 都是 +0
- 都是 -0
- 都是 NaN
- 或都是非零且非 NaN 且为同一个值
增强的对象语法
1、可计算属性
可以在对象字面量中完成动态属性赋值。中括号包围的对象属性键告诉运行时将其作为 JavaScript 表达式而不是字符串来求值
1 | const nameKey = 'name' |
因为被当作 JavaScript 表达式求值,所以可计算属性本身可以是复杂的表达式,在实例化时再求值:
1 | const nameKey = 'name' |
可计算属性表达式中抛出任何错误都会中断对象创建
2、简写方法名
如果对象的某个值为函数,则可以省略该值的键
简写方法名对获取函数和设置函数也是适用的
1 | let person = { |
简写方法名与可计算属性键相互兼容
1 | const methodKey = 'sayName' |
对象解构
1、直接使用原对象的属性名
1 | let person = { |
2、声明自己的变量
1 | let person = { |
3、解构赋值不一定与对象的属性匹配
赋值的时候可以忽略某些属性,而如果引用的属性不存在,则该变量的值就是 undefined
1 | let person = { |
也可以在结构赋值的同时定义默认值
1 | let person = { |
4、解构原理
解构在内部使用函数 ToObject()
(不能在运行时环境中直接访问)把源数据解构转换为对象。这意味着在对象解构的上下文中,原始值会被当成对象。这也意味着(根据 ToObject() 的定义),null 和 undefined 不能被解构,否则会抛出错误
1 | let { length } = 'foobar' |
5、解构声明
解构并不要求变量必须在解构表达式中声明。如果给事先声明的变量赋值,则赋值表达式必须包含在一对括号中:
1 | let personName, personAge |
6、嵌套解构
1、解构对于引用嵌套的属性或赋值目标没有限制。为此,可以通过解构来赋值对象属性
1 | let person = { |
2、解构赋值可以使用嵌套解构,以匹配嵌套的属性
1 | let person = { |
3、在外层属性没有定义的情况下,不能使用嵌套解构。无论源对象还是目标对象
1 | let person = { |
7、部分解构
涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分
8、参数上下文匹配
在函数参数列表中也可以进行解构赋值。对参数的解构赋值不会影响 arguments 对象,但可以在函数签名中声明在函数体内使用局部变量
1 | let person = { |
创建对象
工厂模式
1 | function createPerson(name,age,job) { |
这里,函数 createPerson() 接收3个参数,根据这几个参数构建了一个包含 Person 信息的对象。可以用不同的参数多次调用这个函数,每次都会返回包含3个属性和一个方法的对象。这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)
构造函数模式
构造函数是用于创建
特定类型对象
的函数,也叫类
构造函数模式的目的是为了创建一个自定义类,并且创建这个类的实例
创建实例应使用 new 操作符,以这种方式调用构造函数会执行如下操作:
1、在内存中创建一个新对象
2、这个新对象内部的 [[Prototype]] 特性被赋值为构造函数的 prototype 属性
3、构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)
4、执行构造函数内部的代码(给新对象添加属性)
5、如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象
构造函数模式中,浏览器会默认返回实例(对象数据类型的值),如果手动写了return:
- return 一个基本数据类型的值,当前实例不变。如 return 100 , 会正常返回实例
- return 一个引用数据类型的值,则实例会被该返回值给替换
1 | function Fn(name,age) { |
构造函数不一定要写成函数声明的形式。赋值给变量的函数表达式也可以表示构造函数
1 | let Fn = function(name, age){ |
构造函数的问题:
构造函数定义的方法在每个实例上都会创建一遍,对于上面的例子,每生成一个新的实例,都会有一个 sayName() 的方法,但这两个方法不是同一个 Function 实例,这就导致了相同逻辑的函数重复定义的问题。该问题可以通过原型模式来解决
原型模式
每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享
1、理解原型
只要创建一个函数,就会为这个函数创建一个 prototype 属性(指向原型对象)。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数
在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自 Object。每次调用构造函数创建一个新实例,这个实例的内部 [[Prototype]] 指针就会被赋值为构造函数的原型对象。脚本中没有访问这个 [[Prototype]] 特性的标准方式,但 Firefox、Safari 和 Chrome 会在每个对象上暴露 proto 属性,通过这个属性可以访问对象的原型。关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有
可以使用 isPrototypeOf()
方法确定两个对象之间的关系。本质上,isPrototypeOf() 会在传入参数的 [[Prototype]] 指向调用它的对象时返回 true
1 | console.log(Fn.prototype.isPrototypeOf(p1)) // true |
ECMAScript 的 Object 类型有一个方法叫 Object.getPrototypeOf(),返回参数的内部特性 [[Prototype]] 的值。这在通过原型实现继承时显得尤为重要!
1 | function Fn(name,age) { |
Object 类型还有一个 setPrototypeOf()
方法,可以向实例的私有特性 [[Prototype]] 写入一个新值。这样就可以重写一个对象的原型继承关系
1 | let biped = { |
!Object.setPrototypeOf() 可能会严重影响代码性能。MDN文档:“由于现代 JavaScript 引擎优化属性访问所带来的特性的关系,更改对象的 [[Prototype]]在各个浏览器和 JavaScript 引擎上都是一个很慢的操作。其在更改继承的性能上的影响是微妙而又广泛的,这不仅仅限于
obj.__proto__ = ...
语句上的时间花费,而且可能会延伸到任何代码,那些可以访问任何[[Prototype]]已被更改的对象的代码”
为避免使用 Object.setPrototypeOf() 可能造成的性能下降,可以通过 Object.create() 来创建一个新对象,同时为其指定原型
1 | let biped = { |
2、原型层级
在通过对象访问属性时,会按照这个属性的名称开始搜索。搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着指针进入原型对象,在原型对象上找到属性后,返回对应的值
虽然可以通过实例读取原型对象上的值,但不可能通过实例重写这些值。如果在实例上添加了一个与原型对象中同名的属性,那就会在实例上创建这个属性,这个属性会遮蔽(shadow)原型对象上的属性。即使在实例上把这个属性设置为 null,也不会恢复它和原型的联系。不过,使用 delete 操作符可以完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索原型对象
hasOwnProperty()
方法用于确定某个属性是在实例上还是在原型对象上。这个方法继承自 Object,会在属性存在于调用它的对象实例上时返回 true
ECMAScript 的 Object.getOwnPropertyDescriptor() 方法只对实例属性有效,要取得原型属性的描述符,就必须直接在原型对象上调用 Object.getOwnProperty()
3、原型和 in 操作符
in 操作符的使用方式:
- 单独使用。在可以通过对象访问指定属性时返回 true ,无论该属性是在实例上还是在原型上
for-in
循环中使用
1 | function Person() {} |
扩展:如何确定一个属性是否存在原型上?
在 for-in 循环中使用 in 操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性
要获取对象上所有可枚举的实例属性,可以使用 Object.keys() 方法。这个方法接收一个对象作为参数,返回包含该对象所有可枚举属性名称的字符串数组
如果想列出所有实例属性,无论是否可以枚举,可以使用 Object.getOwnPropertyNames()
\
4、属性枚举顺序
for-in 循环和 Object.keys() 的枚举顺序是不确定的,取决于 JavaScript 引擎,可能因浏览器而异
Object.getOwnPropertyNames()、Object.getOwnPropertySymbols() 和 Object.assign() 的枚举顺序是确定的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。在对象字面量中定义的键以它们逗号分割的顺序插入
对象迭代
Object.values()
- 返回值:对象值的数组
Object.entries()
- 返回值:键/值对的数组
1 | const obj ={ |
注意 非字符串属性会被转换为字符串输出。这两个方法执行对象的浅复制。符号属性会被忽略
1、其他原型语法
重写原型后,构造函数原型的 constructor 属性就不指向构造函数,而是指向了 Object构造函数。如果 constructor 的值很重要的话,需要在手动重写原型对象时专门设置一下 constructor 的值。但是,在对象上直接定义属性,该属性的 [[Enumerable]] 为 true,而原生的 constructor 属性默认是不可枚举的。此时,可以使用 Object.defineProperty() 方法来定义 constructor 属性
2、原型的动态性
实例和原型之间的链接就是简单的指针,而不是保存的副本
实例只有指向原型的指针,没有指向构造函数的指针
3、原生对象原型
通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法
! 尽管可以这么做,但并不推荐在产品环境中修改原生对象原型。推荐的做法是创建一个自定义的类,继承原生类型
4、原型的问题
1、原型模式弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值
2、共享特性。原型上的所有属性是在实例间共享的,这对函数来说比较合适;包含原始值的属性也可以通过在实例上添加同名属性来遮蔽原型上的属性;真正的问题来自包含引用值的属性
1 | function Person() {} |
继承
很多面向对象编程语言都支持两种继承:接口继承(只继承方法签名)和实现继承(继承实际的方法)。接口继承在 ECMAScript 中是不可能的,因为函数没有签名。实现继承是 ECMAScript 唯一支持的继承方法,而这主要是通过原型链实现的
原型链
ECMA-262 把 原型链 定义为 ECMAScript 的主要继承方式。
每个构造函数都有一个原型对象,原型有一个属性( constructor )指回构造函数,而实例有一个内部指针([[Prototype]])指向原型。如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。这就是原型链的基本构想
1、默认原型
默认情况下,所有引用类型都继承自 Object,这也是通过原型链实现的。任何函数的默认原型都是一个 Object 的实例,这意味着这个实例有一个内部指针指向 Object.prototype 。这也是自定义类型能够继承包括 toString()、valueOf() 在内的所有默认方法的原因
2、原型与继承关系
原型与实例的关系可以通过两种方式来确定:
- instanceof 操作符
- isPrototypeOf() 方法 原型链中的每个原型都可以调用这个方法,只要原型链中包含这个原型,就返回 true
4、原型链的问题
1、原型中引用值问题。原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型的实例。这意味着原先的实例属性摇身一变称为了原型属性
1 | function SuperType() { |
2、子类型在实例化时不能给夫类型的构造函数传参。事实上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。再加上之前提到的原型中包含引用值的问题,就导致原型链基本不会被单独使用
盗用构造函数
盗用构造函数(constructor stealing)也称为 对象伪装
或 经典继承
。解决了原型包含引用值导致的继承问题
原理:在子类构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用 apply() 和 call() 方法以新创建的对象为上下文执行构造函数
1 | function SuperType() { |
1、传递参数
相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参
1 | function SuperType(name) { |
2、盗用构造函数的问题
盗用构造函数的主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能只用构造函数模式
组合继承
组合继承(也叫伪经典继承)综合了原型链和盗用构造函数,将两者的有点集中了起来
原理:使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例方法。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性
1 | function SuperType(name) { |
组合继承弥补了原型链和盗用构造函数的不足,是 JavaScript 中使用最多的继承模式。而且组合继承也保留了 instanceof 操作符和 isPrototypeOf() 方法识别合成对象的能力
原型式继承
2006年,Douglas Crockford
写了一篇文章:《JavaScript中的原型式继承》。他的出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。文章给出了一个函数:
1 | function object(o){ |
ECMAScript 5 通过增加 Object.create()
方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)
Object.create() 的第二个参数与 Object.defineProperties() 的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性
1 | let person = { |
原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但是,属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的
寄生式继承
与原型式继承比较接近的一种继承方式是寄生式继承,也是 Crockford
首倡的一种模式。
寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。基本的寄生继承模式如下:
1 | function fn(ob){ // 1、创建一个实现继承的函数 |
这个例子基于 person 对象返回了一个新对象。新返回的 p1 对象具有 person 的所有属性和方法,还有一个新方法叫 sayHi()
寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。fn() 函数不是寄生式继承所必需的,任何返回新对象(深克隆或者实现继承的对象)的函数都可以在这里使用
通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类型
寄生式组合继承
寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。寄生式组合继承的基本模式如下:
1 | function inheritPrototype(subType, superType) { |
寄生式组合继承可以算是引用类型继承的最佳模式
类
类定义
定义类有主要两种方式:
- 类声明
- 类表达式
这两种方式都使用 class
关键字加大括号
1 | // 类声明 |
与函数表达式类似,类表达式在他们被求值前也不能引用。不过,与函数定义不同的是,虽然函数声明可以提升,但类定义不能
类的构成
类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必须的。空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行
建议类名首字母大写
1 | // 空定义类 |
类表达式的名称是可选的。在把类表达式赋值给变量后,可以通过 name 属性取得类表达式的名称字符串。但不能在类表达式作用域外部访问这个标识符
1 | let Person = class PersonName { |
类构造函数
constructor 关键字用于在类定义块内部创建类的构造函数。方法名 constructor 会告诉解释器在使用 new 操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必须的,不定义构造函数相当于将构造函数定义为空函数
1、实例化
使用 new 调用类的构造函数会执行如下操作:
1、在内存中创建一个对象
2、这个新对象内部的 [[Prototype]] 指针被赋值为构造函数的 prototype 属性
3、构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)
4、执行构造函数内部的代码(给新对象添加属性)
5、如果构造函数返回非空对象,则返回改对象;否则,返回刚创建的新对象
类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的
1 | class Person { |
默认情况下,类构造函数会在执行之后返回 this 对象。构造函数返回的对象会被用作实例化的对象,如果没有什么引用新创建的 this 对象,那么这个对象会被销毁。不过,如果返回的不是 this 对象,而是其他对象,那么这个对象不会通过 instanceof 操作符检测出跟类有关联,因为这个对象的原型指针并没有被修改
1 | class Person { |
类构造函数与构造函数的主要区别是,调用类构造函数必须使用 new 操作符,而普通构造函数如果不使用 new 调用,那么就会以全局的 this(通常是 window) 作为内部对象。调用类构造函数时如果没有使用 new 则会抛出错误:
1 | function Person() {} |
2、把类当成特殊函数
声明一个类之后,通过 typeof 操作符检测类标识符,表明它是一个函数
1 | class Person {} |
类标识符有 prototype 属性,而这个原型也有一个 constructor 属性指向类自身
1 | class Person {} |
与普通构造函数一样,可以使用 instanceof 操作符检查构造函数原型是否存在于实例的原型链中
1 | class Person {} |
类本身具有与普通构造函数一样的行为。在类的上下文中,类本身在使用 new 调用时就会被当成构造函数。重点在于,类中定义的 constructor 方法 不会 被当成构造函数,在对他使用 instanceof 操作符时会返回 false。但是,如果在创建实例时直接将类构造函数当成普通构造函数来使用,那么 instanceof 操作符的返回值会反转
1 | class Person {} |
类是 JavaScript 的一等公民,因此可以像其他对象或函数引用一样把类作为参数传递
1 | // 类可以像函数一样在任何地方定义,比如在数组中 |
与立即调用函数表达式相似,类也可以立即实例化
1 | let p = new class Foo{ |
实例、原型和类成员
1、实例成员
每次通过 new 调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this)添加 “自有” 属性。在构造函数执行完毕后,仍然可以给实例继续添加新成员
每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享
2、原型方法与访问器
为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法
1 | class Person { |
可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据
1 | class Person { |
类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键:
1 | const symbolKey = Symbol('symbolKey') |
类定义也支持获取和设置访问器。语法与行为跟普通对象一样
1 | class Person { |
3、静态类方法
可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。静态成员每个类上只能有一个
静态类成员在定义类中使用 static 关键字作为前缀。在静态成员中,this 引用类自身。其他所有约定跟原型成员一样
1 | class Person { |
静态类方法非常适合作为实例工厂:
1 | class Person { |
4、非函数原型和类成员
虽然类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加
1 | class Person { |
添加在静态属性上的方法,可以通过原型链找到,但是直接打印类本身是看不到的
类定义中之所以没有显式支持添加数据成员,是因为在共享目标(原型和类)上添加可变(可修改)数据成员是一种反模式。一般来说,对象实例应该独自拥有通过 this 引用的数据
5、迭代器与生成器方法
类定义语法支持在原型和类本身上定义生成器方法
1 | class Person { |
因为支持生成器方法,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象
1 | class Person { |
也可以只返回迭代器实例
1 | class Person { |
补充
yield:用来暂停和恢复一个生成器函数(function* 或 遗留的生成器函数)
继承
ES6 原生支持了类继承机制。虽然类继承机制使用的是新语法,但背后依旧使用的是原型链
1、继承基础
ES6 类支持单继承。使用 extends
关键字,就可以继承任何拥有 [[Construct]] 和原型的对象。这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容)
1 | class Vehicle {} |
派生类都会通过原型链访问到类和原型上定义的方法。this 的值会反应调用相应方法的实例或者类
1 | class Vehicle { |
extends 关键字也可以在类表达式中使用,因此
let Bar = class extends Foo{}
是有效的语法
2、构造函数、HomeObject 和 super()
继承自其他类的类被称作派生类
派生类的方法可以通过 super 关键字引用他们的原型。这个关键字只能在派生类中使用,而且仅限于构造函数、实例方法和静态方法内部。在类构造函数中使用 super 可以调用父类构造函数
ES6 给类构造函数和静态方法添加了内部特性 [[HomeObject]],这个特性是一个指针,指向定义该方法的对象。这个指针是自动赋值的,而且只能在 JavaScript 引擎内部访问。super 始终会定义为 [[HomeObject]]的原型
在使用 super 时要注意的几个问题:
- super 只能在派生类构造函数和静态方法中使用
- 不能单独引用 super 关键字,要么用它调用构造函数,要么用它引用静态方法
- 调用 super() 会调用父类构造函数,并将返回的实例赋值给 this
- super() 的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入
- 如果没有定义类构造函数,在实例化派生类时会调用 super(),而且会传入所有传给派生类的参数
- 在类构造函数中,不能在调用 super() 之前引用 this,否则会报错
- 如果在派生类中显示定义了构造函数,则要么必须在其中调用 super(),要么必须在其中返回一个对象,否则会报错
1 | class Person { |
实例通过 prototype 也是可以直接访问到类上的静态属性的,用 super 的原因,个人理解为 当实例上的属性遮蔽了类上的属性,而又想使用类上的属性,可以使用 super 处理
3、抽象基类
可供其他类继承,但本身不会被实例化
可通过 new.target
实现。new.target
保存通过 new 关键字调用的类或函数。通过在实例化时检测 new.target 是不是抽象基类,可以阻止对抽象基类的实例化
1 | // 抽象基类 |
通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法。因为原型方法在调用类构造函数之前就已经存在了,所以可以通过 this 关键字来检查相应的方法
1 | // 抽象基类 |
4、继承内置类型
利用 ES6 的继承机制,可以方便的扩展内置类型:
1 | class SuperArray extends Array { |
有些内置类型的方法会返回新实例。默认情况下,返回实例的类型与原型式里的类型是一样的。如果想覆盖这个默认行为,则可以覆盖 Symbol.species 访问器,这个访问器决定在创建返回的实例时使用的类
1 | class SuperArray extends Array {} |
5、类混入
把不同类的行为集中到一个类
一个实现方法是定义一组 “可嵌套” 的函数,每个函数分别接收一个超类作为参数,而将混入类定义为这个参数的子类,并返回这个类。这些组合函数可以连缀调用,最终组合成超类表达式
1 | class Vehicle {} |
很多 JavaScript 框架(特别是 React)已经抛弃混入模式,转向了组合模式(把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。这反映了那个众所周知的软件设计原则:“组合胜过继承(composition over inheritance)”