第8 章 对象、类与面向对象编程

本章内容
 理解对象
 理解对象创建过程
 理解继承
 理解类
ECMA-262 将对象定义为一组属性的无序集合。严格来说,这意味着对象就是一组没有特定顺序的
值。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值。正因为如此(以及其他还未
讨论的原因),可以把ECMAScript 的对象想象成一张散列表,其中的内容就是一组名/值对,值可以是
数据或者函数。


8.1 理解对象


创建自定义对象的通常方式是创建Object 的一个新实例,然后再给它添加属性和方法,如下例
所示:
这个例子创建了一个名为person 的对象,而且有三个属性(name、age 和job)和一个方法
(sayName())。sayName()方法会显示this.name 的值,这个属性会解析为person.name。早期
JavaScript 开发者频繁使用这种方式创建新对象。几年后,对象字面量变成了更流行的方式。前面的例
子如果使用对象字面量则可以这样写:
这个例子中的person 对象跟前面例子中的person 对象是等价的,它们的属性和方法都一样。这
些属性都有自己的特征,而这些特征决定了它们在JavaScript 中的行为。


8.1.1 属性的类型


ECMA-262 使用一些内部特性来描述属性的特征。这些特性是由为JavaScript 实现引擎的规范定义
的。因此,开发者不能在JavaScript 中直接访问这些特性。为了将某个特性标识为内部特性,规范会用
两个中括号把特性的名称括起来,比如[[Enumerable]]。
属性分两种:数据属性和访问器属性。
1. 数据属性
数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有4
个特性描述它们的行为。
 [[Configurable]]:表示属性是否可以通过delete 删除并重新定义,是否可以修改它的特
性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特
性都是true,如前面的例子所示。
 [[Enumerable]]:表示属性是否可以通过for-in 循环返回。默认情况下,所有直接定义在对
象上的属性的这个特性都是true,如前面的例子所示。
 [[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的
这个特性都是true,如前面的例子所示。
 [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性
的默认值为undefined。
在像前面例子中那样将属性显式添加到对象之后,[[Configurable]]、[[Enumerable]]和
[[Writable]]都会被设置为true,而[[Value]]特性会被设置为指定的值。比如:
这里,我们创建了一个名为name 的属性,并给它赋予了一个值"Nicholas"。这意味着[[Value]]
特性会被设置为"Nicholas",之后对这个值的任何修改都会保存这个位置。
要修改属性的默认特性,就必须使用Object.defineProperty()方法。这个方法接收3 个参数:
要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包
含:configurable、enumerable、writable 和value,跟相关特性的名称一一对应。根据要修改
的特性,可以设置其中一个或多个值。比如:
这个例子创建了一个名为name 的属性并给它赋予了一个只读的值"Nicholas"。这个属性的值就
不能再修改了,在非严格模式下尝试给这个属性重新赋值会被忽略。在严格模式下,尝试修改只读属性
的值会抛出错误。
类似的规则也适用于创建不可配置的属性。比如:
这个例子把configurable 设置为false,意味着这个属性不能从对象上删除。非严格模式下对
这个属性调用delete 没有效果,严格模式下会抛出错误。此外,一个属性被定义为不可配置之后,就
不能再变回可配置的了。再次调用Object.defineProperty()并修改任何非writable 属性会导致
错误:
因此,虽然可以对同一个属性多次调用Object.defineProperty(),但在把configurable 设
置为false 之后就会受限制了。
在调用Object.defineProperty()时,configurable、enumerable 和writable 的值如果不
指定,则都默认为false。多数情况下,可能都不需要Object.defineProperty()提供的这些强大
的设置,但要理解JavaScript 对象,就要理解这些概念。
2. 访问器属性
访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不
过这两个函数不是必需的。在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效
的值。在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。访
问器属性有4 个特性描述它们的行为。
 [[Configurable]]:表示属性是否可以通过delete 删除并重新定义,是否可以修改它的特
性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性
都是true。
 [[Enumerable]]:表示属性是否可以通过for-in 循环返回。默认情况下,所有直接定义在对
象上的属性的这个特性都是true。
 [[Get]]:获取函数,在读取属性时调用。默认值为undefined。
 [[Set]]:设置函数,在写入属性时调用。默认值为undefined。
访问器属性是不能直接定义的,必须使用Object.defineProperty()。下面是一个例子:
在这个例子中,对象book 有两个默认属性:year_和edition。year_中的下划线常用来表示该
属性并不希望在对象方法的外部被访问。另一个属性year 被定义为一个访问器属性,其中获取函数简
单地返回year_的值,而设置函数会做一些计算以决定正确的版本(edition)。因此,把year 属性修改
为2018 会导致year_变成2018,edition 变成2。这是访问器属性的典型使用场景,即设置一个属性
值会导致一些其他变化发生。
获取函数和设置函数不一定都要定义。只定义获取函数意味着属性是只读的,尝试修改属性会被忽
略。在严格模式下,尝试写入只定义了获取函数的属性会抛出错误。类似地,只有一个设置函数的属性
是不能读取的,非严格模式下读取会返回undefined,严格模式下会抛出错误。
在不支持Object.defineProperty()的浏览器中没有办法修改[[Configurable]]或[[Enumerable]]。
注意 在ECMAScript 5 以前,开发者会使用两个非标准的访问创建访问器属性:__define-
Getter__()和__defineSetter__()。这两个方法最早是Firefox 引入的,后来Safari、
Chrome 和Opera 也实现了。


8.1.2 定义多个属性


在一个对象上同时定义多个属性的可能性是非常大的。为此,ECMAScript 提供了Object.define-
Properties()方法。这个方法可以通过多个描述符一次性定义多个属性。它接收两个参数:要为之添
加或修改属性的对象和另一个描述符对象,其属性与要添加或修改的属性一一对应。比如:

这段代码在book 对象上定义了两个数据属性year_和edition,还有一个访问器属性year。
最终的对象跟上一节示例中的一样。唯一的区别是所有属性都是同时定义的,并且数据属性的
configurable、enumerable 和writable 特性值都是false。
8.1.3 读取属性的特性
使用Object.getOwnPropertyDescriptor()方法可以取得指定属性的属性描述符。这个方法接
收两个参数:属性所在的对象和要取得其描述符的属性名。返回值是一个对象,对于访问器属性包含
configurable、enumerable、get 和set 属性,对于数据属性包含configurable、enumerable、
writable 和value 属性。比如:
对于数据属性year_,value 等于原来的值,configurable 是false,get 是undefined。对
于访问器属性year,value 是undefined,enumerable 是false,get 是一个指向获取函数的指针。
ECMAScript 2017 新增了Object.getOwnPropertyDescriptors()静态方法。这个方法实际上
会在每个自有属性上调用Object.getOwnPropertyDescriptor()并在一个新对象中返回它们。对于
前面的例子,使用这个静态方法会返回如下对象:


8.1.4 合并对象


JavaScript 开发者经常觉得“合并”(merge)两个对象很有用。更具体地说,就是把源对象所有的
本地属性一起复制到目标对象上。有时候这种操作也被称为“混入”(mixin),因为目标对象通过混入
源对象的属性得到了增强。
Object.assign()实际上对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使
用最后一个复制的值。此外,从源对象访问器属性取得的值,比如获取函数,会作为一个静态值赋给目
标对象。换句话说,不能在两个对象间转移获取函数和设置函数。
如果赋值期间出错,则操作会中止并退出,同时抛出错误。Object.assign()没有“回滚”之前
赋值的概念,因此它是一个尽力而为、可能只会完成部分复制的方法。
 

8.1.5 对象标识及相等判定


在ECMAScript 6 之前,有些特殊情况即使是===操作符也无能为力:
为改善这类情况,ECMAScript 6 规范新增了Object.is(),这个方法与===很像,但同时也考虑
到了上述边界情形。这个方法必须接收两个参数:
要检查超过两个值,递归地利用相等性传递即可:


8.1.6 增强的对象语法


ECMAScript 6 为定义和操作对象新增了很多极其有用的语法糖特性。这些特性都没有改变现有引擎
的行为,但极大地提升了处理对象的方便程度。
本节介绍的所有对象语法同样适用于ECMAScript 6 的类,本章后面会讨论。
注意 相比于以往的替代方案,本节介绍的增强对象语法可以说是一骑绝尘。因此本章及
本书会默认使用这些新语法特性。
1. 属性值简写
在给对象添加变量的时候,开发者经常会发现属性名和变量名是一样的。例如:
为此,简写属性名语法出现了。简写属性名只要使用变量名(不用再写冒号)就会自动被解释为同
名的属性键。如果没有找到同名变量,则会抛出ReferenceError。
以下代码和之前的代码是等价的:
代码压缩程序会在不同作用域间保留属性名,以防止找不到引用。以下面的代码为例:
在这里,即使参数标识符只限定于函数作用域,编译器也会保留初始的name 标识符。如果使用
Google Closure 编译器压缩,那么函数参数会被缩短,而属性名不变:
2. 可计算属性
在引入可计算属性之前,如果想使用变量的值作为属性,那么必须先声明对象,然后使用中括号语
法来添加属性。换句话说,不能在对象字面量中直接动态命名属性。比如:
有了可计算属性,就可以在对象字面量中完成动态属性赋值。中括号包围的对象属性键告诉运行时
将其作为JavaScript 表达式而不是字符串来求值:
因为被当作JavaScript 表达式求值,所以可计算属性本身可以是复杂的表达式,在实例化时再求值:
注意 可计算属性表达式中抛出任何错误都会中断对象创建。如果计算属性的表达式有副
作用,那就要小心了,因为如果表达式抛出错误,那么之前完成的计算是不能回滚的。
3. 简写方法名
在给对象定义方法时,通常都要写一个方法名、冒号,然后再引用一个匿名函数表达式,如下所示:
新的简写方法的语法遵循同样的模式,但开发者要放弃给函数表达式命名(不过给作为方法的函数
命名通常没什么用)。相应地,这样也可以明显缩短方法声明。
以下代码和之前的代码在行为上是等价的:
简写方法名对获取函数和设置函数也是适用的:
简写方法名与可计算属性键相互兼容:
注意 简写方法名对于本章后面介绍的ECMAScript 6 的类更有用。


8.1.7 对象解构


ECMAScript 6 新增了对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。简
单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值。
下面的例子展示了两段等价的代码,首先是不使用对象解构的:
然后,是使用对象解构的:
使用解构,可以在一个类似对象字面量的结构中,声明多个变量,同时执行多个赋值操作。如果想
让变量直接使用属性的名称,那么可以使用简写语法,比如:
解构赋值不一定与对象的属性匹配。赋值的时候可以忽略某些属性,而如果引用的属性不存在,则
该变量的值就是undefined:
也可以在解构赋值的同时定义默认值,这适用于前面刚提到的引用的属性不存在于源对象中的
情况:
解构在内部使用函数ToObject()(不能在运行时环境中直接访问)把源数据结构转换为对象。这
意味着在对象解构的上下文中,原始值会被当成对象。这也意味着(根据ToObject()的定义),null
和undefined 不能被解构,否则会抛出错误。
解构并不要求变量必须在解构表达式中声明。不过,如果是给事先声明的变量赋值,则赋值表达式
必须包含在一对括号中:
1. 嵌套解构
解构对于引用嵌套的属性或赋值目标没有限制。为此,可以通过解构来复制对象属性:
解构赋值可以使用嵌套结构,以匹配嵌套的属性:
在外层属性没有定义的情况下不能使用嵌套解构。无论源对象还是目标对象都一样:
2. 部分解构
需要注意的是,涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及
多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分:
3. 参数上下文匹配
在函数参数列表中也可以进行解构赋值。对参数的解构赋值不会影响arguments 对象,但可以在函数签名中声明在函数体内使用局部变量:


8.2 创建对象


虽然使用Object 构造函数或对象字面量可以方便地创建对象,但这些方式也有明显不足:创建具
有同样接口的多个对象需要重复编写很多代码。


8.2.1 概述


综观ECMAScript 规范的历次发布,每个版本的特性似乎都出人意料。ECMAScript 5.1 并没有正式
支持面向对象的结构,比如类或继承。但是,正如接下来几节会介绍的,巧妙地运用原型式继承可以成
功地模拟同样的行为。
ECMAScript 6 开始正式支持类和继承。ES6 的类旨在完全涵盖之前规范设计的基于原型的继承模
式。不过,无论从哪方面看,ES6 的类都仅仅是封装了ES5.1 构造函数加原型继承的语法糖而已。
注意 不要误会:采用面向对象编程模式的JavaScript 代码还是应该使用ECMAScript 6 的
类。但不管怎么说,理解ES6 类出现之前的惯例总是有益无害的。特别是ES6 的类定义本
身就相当于对原有结构的封装。因此,在介绍ES6 的类之前,本书会循序渐进地介绍被类
取代的那些底层概念。


8.2.2 工厂模式


工厂模式是一种众所周知的设计模式,广泛应用于软件工程领域,用于抽象创建特定对象的过程。
(本书后面还会讨论其他设计模式及其在JavaScript 中的实现。)下面的例子展示了一种按照特定接口创
建对象的方式:
这里,函数createPerson()接收3 个参数,根据这几个参数构建了一个包含Person 信息的对象。
可以用不同的参数多次调用这个函数,每次都会返回包含3 个属性和1 个方法的对象。这种工厂模式虽
然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)。


8.2.3 构造函数模式


前面几章提到过,ECMAScript 中的构造函数是用于创建特定类型对象的。像Object 和Array 这
样的原生构造函数,运行时可以直接在执行环境中使用。当然也可以自定义构造函数,以函数的形式为
自己的对象类型定义属性和方法。
比如,前面的例子使用构造函数模式可以这样写:
在这个例子中,Person()构造函数代替了createPerson()工厂函数。实际上,Person()内部
的代码跟createPerson()基本是一样的,只是有如下区别。
 没有显式地创建对象。
 属性和方法直接赋值给了this。
 没有return。
另外,要注意函数名Person 的首字母大写了。按照惯例,构造函数名称的首字母都是要大写的,
非构造函数则以小写字母开头。这是从面向对象编程语言那里借鉴的,有助于在ECMAScript 中区分构
造函数和普通函数。毕竟ECMAScript 的构造函数就是能创建对象的函数。
要创建Person 的实例,应使用new 操作符。以这种方式调用构造函数会执行如下操作。
(1) 在内存中创建一个新对象。
(2) 这个新对象内部的[[Prototype]]特性被赋值为构造函数的prototype 属性。
(3) 构造函数内部的this 被赋值为这个新对象(即this 指向新对象)。
(4) 执行构造函数内部的代码(给新对象添加属性)。
(5) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
上一个例子的最后,person1 和person2 分别保存着Person 的不同实例。这两个对象都有一个
constructor 属性指向Person,如下所示:
constructor 本来是用于标识对象类型的。不过,一般认为instanceof 操作符是确定对象类型
更可靠的方式。前面例子中的每个对象都是Object 的实例,同时也是Person 的实例,如下面调用
instanceof 操作符的结果所示:
定义自定义构造函数可以确保实例被标识为特定类型,相比于工厂模式,这是一个很大的好处。在
这个例子中,person1 和person2 之所以也被认为是Object 的实例,是因为所有自定义对象都继承
自Object(后面再详细讨论这一点)。
构造函数不一定要写成函数声明的形式。赋值给变量的函数表达式也可以表示构造函数:
在实例化时,如果不想传参数,那么构造函数后面的括号可加可不加。只要有new 操作符,就可以
调用相应的构造函数:
1. 构造函数也是函数
构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。并没有把某个
函数定义为构造函数的特殊语法。任何函数只要使用new 操作符调用就是构造函数,而不使用new 操
作符调用的函数就是普通函数。比如,前面的例子中定义的Person()可以像下面这样调用:
这个例子一开始展示了典型的构造函数调用方式,即使用new 操作符创建一个新对象。然后是普通
函数的调用方式,这时候没有使用new 操作符调用Person(),结果会将属性和方法添加到window 对
象。这里要记住,在调用一个函数而没有明确设置this 值的情况下(即没有作为对象的方法调用,或
者没有使用call()/apply()调用),this 始终指向Global 对象(在浏览器中就是window 对象)。
因此在上面的调用之后,window 对象上就有了一个sayName()方法,调用它会返回"Greg"。最后展
示的调用方式是通过call()(或apply())调用函数,同时将特定对象指定为作用域。这里的调用将
对象o 指定为Person()内部的this 值,因此执行完函数代码后,所有属性和sayName()方法都会添
加到对象o 上面。
2. 构造函数的问题
构造函数虽然有用,但也不是没有问题。构造函数的主要问题在于,其定义的方法会在每个实例上
都创建一遍。因此对前面的例子而言,person1 和person2 都有名为sayName()的方法,但这两个方
法不是同一个Function 实例。我们知道,ECMAScript 中的函数是对象,因此每次定义函数时,都会
初始化一个对象。逻辑上讲,这个构造函数实际上是这样的:

JavaScript

function Person(name, age, job){

this.name = name;

this.age = age;

this.job = job;

this.sayName = new Function("console.log(this.name)"); // 逻辑等价

}

这样理解这个构造函数可以更清楚地知道,每个Person 实例都会有自己的Function 实例用于显
示name 属性。当然了,以这种方式创建函数会带来不同的作用域链和标识符解析。但创建新Function
实例的机制是一样的。因此不同实例上的函数虽然同名却不相等,如下所示:
console.log(person1.sayName == person2.sayName); // false
因为都是做一样的事,所以没必要定义两个不同的Function 实例。况且,this 对象可以把函数
与对象的绑定推迟到运行时。
要解决这个问题,可以把函数定义转移到构造函数外部:

JavaScript

function Person(name, age, job){

this.name = name;

this.age = age;

this.job = job;

this.sayName = sayName;

}

function sayName() {

console.log(this.name);

}



let person1 = new Person("Nicholas", 29, "Software Engineer");

let person2 = new Person("Greg", 27, "Doctor");

person1.sayName(); // Nicholas

person2.sayName(); // Greg

在这里,sayName()被定义在了构造函数外部。在构造函数内部,sayName 属性等于全局sayName()
函数。因为这一次sayName 属性中包含的只是一个指向外部函数的指针,所以person1 和person2
共享了定义在全局作用域上的sayName()函数。这样虽然解决了相同逻辑的函数重复定义的问题,但
全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法,
那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新
问题可以通过原型模式来解决。


8.2.4 原型模式


每个函数都会创建一个prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例
共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处
是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋给对象实例的值,可以
直接赋值给它们的原型,如下所示:

JavaScript

function Person() {}

Person.prototype.name = "Nicholas";

Person.prototype.age = 29;

Person.prototype.job = "Software Engineer";

Person.prototype.sayName = function() {

console.log(this.name);

};



let person1 = new Person();

person1.sayName(); // "Nicholas"

let person2 = new Person();

person2.sayName(); // "Nicholas"

console.log(person1.sayName == person2.sayName); // true

使用函数表达式也可以:


let Person = function() {};
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
  console.log(this.name);
};

let person1 = new Person();
person1.sayName(); // "Nicholas"
let person2 = new Person();
person2.sayName(); // "Nicholas"
console.log(person1.sayName == person2.sayName); // true

这里,所有属性和sayName()方法都直接添加到了Person 的prototype 属性上,构造函数体中
什么也没有。但这样定义之后,调用构造函数创建的新对象仍然拥有相应的属性和方法。与构造函数模
式不同,使用这种原型模式定义的属性和方法是由所有实例共享的。因此person1 和person2 访问的
都是相同的属性和相同的sayName()函数。要理解这个过程,就必须理解ECMAScript 中原型的本质。
1. 理解原型
无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个prototype 属性(指向
原型对象)。默认情况下,所有原型对象自动获得一个名为constructor 的属性,指回与之关联的构
造函数。对前面的例子而言,Person.prototype.constructor 指向Person。然后,因构造函数而
异,可能会给原型对象添加其他属性和方法。
在自定义构造函数时,原型对象默认只会获得constructor 属性,其他的所有方法都继承自
Object。每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构
造函数的原型对象。脚本中没有访问这个[[Prototype]]特性的标准方式,但Firefox、Safari 和Chrome
会在每个对象上暴露__proto__属性,通过这个属性可以访问对象的原型。在其他实现中,这个特性
完全被隐藏了。关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之
间没有。
这种关系不好可视化,但可以通过下面的代码来理解原型的行为:


/**
* 构造函数可以是函数表达式
* 也可以是函数声明,因此以下两种形式都可以:
* function Person() {}
* let Person = function() {}
*/
function Person() {}
/**
* 声明之后,构造函数就有了一个
* 与之关联的原型对象:
*/
console.log(typeof Person.prototype);
console.log(Person.prototype);
// {
// constructor: f Person(),
// __proto__: Object
// }
/**
* 如前所述,构造函数有一个prototype 属性
* 引用其原型对象,而这个原型对象也有一个
* constructor 属性,引用这个构造函数
* 换句话说,两者循环引用:
*/
console.log(Person.prototype.constructor === Person); // true
/**
* 正常的原型链都会终止于Object 的原型对象
* Object 原型的原型是null
*/
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Person.prototype.__proto__.constructor === Object); // true
console.log(Person.prototype.__proto__.__proto__ === null); // true
console.log(Person.prototype.__proto__);
// {
// constructor: f Object(),
// toString: ...
// hasOwnProperty: ...
// isPrototypeOf: ...
// ...
// }
let person1 = new Person(),
person2 = new Person();
/**
* 构造函数、原型对象和实例
* 是3 个完全不同的对象:
*/
console.log(person1 !== Person); // true
console.log(person1 !== Person.prototype); // true
console.log(Person.prototype !== Person); // true
/**
* 实例通过__proto__链接到原型对象,
* 它实际上指向隐藏特性[[Prototype]]
*
* 构造函数通过prototype 属性链接到原型对象
*
* 实例与构造函数没有直接联系,与原型对象有直接联系
*/
console.log(person1.__proto__ === Person.prototype); // true
conosle.log(person1.__proto__.constructor === Person); // true
/**
* 同一个构造函数创建的两个实例
* 共享同一个原型对象:
*/
console.log(person1.__proto__ === person2.__proto__); // true
/**
* instanceof 检查实例的原型链中
* 是否包含指定构造函数的原型:
*/
console.log(person1 instanceof Person); // true
console.log(person1 instanceof Object); // true
console.log(Person.prototype instanceof Object); // true

对于前面例子中的Person 构造函数和Person.prototype,可以通过图8-1 看出各个对象之间的
关系。
图 8-1
图8-1 展示了Person 构造函数、Person 的原型对象和Person 现有两个实例之间的关系。注意,
Person.prototype 指向原型对象,而Person.prototype.contructor 指回Person 构造函数。原
型对象包含constructor 属性和其他后来添加的属性。Person 的两个实例person1 和person2 都只
有一个内部属性指回Person.prototype,而且两者都与构造函数没有直接联系。另外要注意,虽然这两
个实例都没有属性和方法,但person1.sayName()可以正常调用。这是由于对象属性查找机制的原因。
虽然不是所有实现都对外暴露了[[Prototype]],但可以使用isPrototypeOf()方法确定两个对
象之间的这种关系。本质上,isPrototypeOf()会在传入参数的[[Prototype]]指向调用它的对象时
返回true,如下所示:


console.log(Person.prototype.isPrototypeOf(person1)); // true
console.log(Person.prototype.isPrototypeOf(person2)); // true

这里通过原型对象调用isPrototypeOf()方法检查了person1 和person2。因为这两个例子内
部都有链接指向Person.prototype,所以结果都返回true。
ECMAScript 的Object 类型有一个方法叫Object.getPrototypeOf(),返回参数的内部特性
[[Prototype]]的值。例如:
第一行代码简单确认了Object.getPrototypeOf()返回的对象就是传入对象的原型对象。第二
行代码则取得了原型对象上name 属性的值,即"Nicholas"。使用Object.getPrototypeOf()可以
方便地取得一个对象的原型,而这在通过原型实现继承时显得尤为重要(本章后面会介绍)。
个新值。这样就可以重写一个对象的原型继承关系:
警告 Object.setPrototypeOf()可能会严重影响代码性能。Mozilla 文档说得很清楚:
“在所有浏览器和JavaScript 引擎中,修改继承关系的影响都是微妙且深远的。这种影响并
不仅是执行Object.setPrototypeOf()语句那么简单,而是会涉及所有访问了那些修
改过[[Prototype]]的对象的代码。”
为避免使用Object.setPrototypeOf()可能造成的性能下降,可以通过Object.create()来创
建一个新对象,同时为其指定原型:
2. 原型层级
在通过对象访问属性时,会按照这个属性的名称开始搜索。搜索开始于对象实例本身。如果在这个
实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着指针进入原
型对象,然后在原型对象上找到属性后,再返回对应的值。因此,在调用person1.sayName()时,会
发生两步搜索。首先,JavaScript 引擎会问:“person1 实例有sayName 属性吗?”答案是没有。然后,
继续搜索并问:“person1 的原型有sayName 属性吗?”答案是有。于是就返回了保存在原型上的这
个函数。在调用person2.sayName()时,会发生同样的搜索过程,而且也会返回相同的结果。这就是
原型用于在多个对象实例间共享属性和方法的原理。
注意 前面提到的constructor 属性只存在于原型对象,因此通过实例对象也是可以访
问到的。
虽然可以通过实例读取原型对象上的值,但不可能通过实例重写这些值。如果在实例上添加了一个
与原型对象中同名的属性,那就会在实例上创建这个属性,这个属性会遮住原型对象上的属性。下面看
一个例子:
在这个例子中,person1 的name 属性遮蔽了原型对象上的同名属性。虽然person1.name 和
person2.name 都返回了值,但前者返回的是"Greg"(来自实例),后者返回的是"Nicholas"(来自
原型)。当console.log()访问person1.name 时,会先在实例上搜索个属性。因为这个属性在实例
上存在,所以就不会再搜索原型对象了。而在访问person2.name 时,并没有在实例上找到这个属性,
所以会继续搜索原型对象并使用定义在原型上的属性。
只要给对象实例添加一个属性,这个属性就会遮蔽(shadow)原型对象上的同名属性,也就是虽然
不会修改它,但会屏蔽对它的访问。即使在实例上把这个属性设置为null,也不会恢复它和原型的联
系。不过,使用delete 操作符可以完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索
原型对象。
这个修改后的例子中使用delete 删除了person1.name,这个属性之前以"Greg"遮蔽了原型上
的同名属性。然后原型上name 属性的联系就恢复了,因此再访问person1.name 时,就会返回原型对
象上这个属性的值。
hasOwnProperty()方法用于确定某个属性是在实例上还是在原型对象上。这个方法是继承自Object
的,会在属性存在于调用它的对象实例上时返回true,如下面的例子所示:
在这个例子中,通过调用hasOwnProperty()能够清楚地看到访问的是实例属性还是原型属性。
调用person1.hasOwnProperty("name")只在重写person1 上name 属性的情况下才返回true,表
明此时name 是一个实例属性,不是原型属性。图8-2 形象地展示了上面例子中各个步骤的状态。(为简
单起见,图中省略了Person 构造函数。)

image.png


注意 ECMAScript 的Object.getOwnPropertyDescriptor()方法只对实例属性有
效。要取得原型属性的描述符,就必须直接在原型对象上调用Object.getOwnProperty-
Descriptor()。
3. 原型和in 操作符
有两种方式使用in 操作符:单独使用和在for-in 循环中使用。在单独使用时,in 操作符会在可
以通过对象访问指定属性时返回true,无论该属性是在实例上还是在原型上。来看下面的例子:
在上面整个例子中,name 随时可以通过实例或通过原型访问到。因此,调用"name" in persoon1
时始终返回true,无论这个属性是否在实例上。如果要确定某个属性是否存在于原型上,则可以像下
面这样同时使用hasOwnProperty()和in 操作符:
只要通过对象可以访问,in 操作符就返回true,而hasOwnProperty()只有属性存在于实例上
时才返回true。因此,只要in 操作符返回true 且hasOwnProperty()返回false,就说明该属性
是一个原型属性。来看下面的例子:
在这里,name 属性首先只存在于原型上,所以hasPrototypeProperty()返回true。而在实例
上重写这个属性后,实例上也有了这个属性,因此hasPrototypeProperty()返回false。即便此时
原型对象还有name 属性,但因为实例上的属性遮蔽了它,所以不会用到。
在for-in 循环中使用in 操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例
属性和原型属性。遮蔽原型中不可枚举([[Enumerable]]特性被设置为false)属性的实例属性也会
在for-in 循环中返回,因为默认情况下开发者定义的属性都是可枚举的。
要获得对象上所有可枚举的实例属性,可以使用Object.keys()方法。这个方法接收一个对象作
为参数,返回包含该对象所有可枚举属性名称的字符串数组。比如:
这里,keys 变量保存的数组中包含"name"、"age"、"job"和"sayName"。这是正常情况下通过
for-in 返回的顺序。而在Person 的实例上调用时,Object.keys()返回的数组中只包含"name"和
"age"两个属性。
如果想列出所有实例属性,无论是否可以枚举,都可以使用Object.getOwnPropertyNames():
注意,返回的结果中包含了一个不可枚举的属性constructor。Object.keys()和Object.
getOwnPropertyNames()在适当的时候都可用来代替for-in 循环。
在ECMAScript 6 新增符号类型之后,相应地出现了增加一个Object.getOwnPropertyNames()
的兄弟方法的需求,因为以符号为键的属性没有名称的概念。因此,Object.getOwnProperty-
Symbols()方法就出现了,这个方法与Object.getOwnPropertyNames()类似,只是针对符号而已:
4. 属性枚举顺序
for-in 循环、Object.keys()、Object.getOwnPropertyNames()、Object.getOwnProperty-
Symbols()以及Object.assign()在属性枚举顺序方面有很大区别。for-in 循环和Object.keys()
的枚举顺序是不确定的,取决于JavaScript 引擎,可能因浏览器而异。
Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和Object.assign()
的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。在对象字面量中
定义的键以它们逗号分隔的顺序插入。

JavaScript

let k1 = Symbol('k1'),

k2 = Symbol('k2');

let o = {

1: 1,

first: 'first',

[k1]: 'sym2',

second: 'second',

0: 0

};

o[k2] = 'sym2';

o[3] = 3;

o.third = 'third';

o[2] = 2;

console.log(Object.getOwnPropertyNames(o));

// ["0", "1", "2", "3", "first", "second", "third"]

console.log(Object.getOwnPropertySymbols(o));

// [Symbol(k1), Symbol(k2)]

8.2.5 对象迭代


在JavaScript 有史以来的大部分时间内,迭代对象属性都是一个难题。ECMAScript 2017 新增了两
个静态方法,用于将对象内容转换为序列化的——更重要的是可迭代的——格式。这两个静态方法
Object.values()和Object.entries()接收一个对象,返回它们内容的数组。Object.values()
返回对象值的数组,Object.entries()返回键/值对的数组。
下面的示例展示了这两个方法:

JavaScript

const o = {

foo: 'bar',

baz: 1,

qux: {}

};

console.log(Object.values(o));

// ["bar", 1, {}]

console.log(Object.entries((o)));

// [["foo", "bar"], ["baz", 1], ["qux", {}]]

注意,非字符串属性会被转换为字符串输出。另外,这两个方法执行对象的浅复制:

JavaScript

const o = {

qux: {}

};

console.log(Object.values(o)[0] === o.qux);

// true

console.log(Object.entries(o)[0][1] === o.qux);

// true

符号属性会被忽略:

JavaScript

const sym = Symbol();

const o = {

[sym]: 'foo'

};

console.log(Object.values(o));

// []

console.log(Object.entries((o)));

// []

1. 其他原型语法
有读者可能注意到了,在前面的例子中,每次定义一个属性或方法都会把Person.prototype 重
写一遍。为了减少代码冗余,也为了从视觉上更好地封装原型功能,直接通过一个包含所有属性和方法
的对象字面量来重写原型成为了一种常见的做法,如下面的例子所示:

function Person() {}
Person.prototype = {
  name: "Nicholas",
  age: 29,
  job: "Software Engineer",
  sayName() {
    console.log(this.name);
  }
};

在这个例子中,Person.prototype 被设置为等于一个通过对象字面量创建的新对象。最终结果
是一样的,只有一个问题:这样重写之后,Person.prototype 的constructor 属性就不指向Person
了。在创建函数时,也会创建它的prototype 对象,同时会自动给这个原型的constructor 属性赋
值。而上面的写法完全重写了默认的prototype 对象,因此其constructor 属性也指向了完全不同
的新对象(Object 构造函数),不再指向原来的构造函数。虽然instanceof 操作符还能可靠地返回
值,但我们不能再依靠constructor 属性来识别类型了,如下面的例子所示:


let friend = new Person();
console.log(friend instanceof Object); // true
console.log(friend instanceof Person); // true
console.log(friend.constructor == Person); // false
console.log(friend.constructor == Object); // true

这里,instanceof 仍然对Object 和Person 都返回true。但constructor 属性现在等于Object
而不是Person 了。如果constructor 的值很重要,则可以像下面这样在重写原型对象时专门设置一
下它的值

这次的代码中特意包含了constructor 属性,并将它设置为Person,保证了这个属性仍然包含
恰当的值。
但要注意,以这种方式恢复constructor 属性会创建一个[[Enumerable]]为true 的属性。而
原生constructor 属性默认是不可枚举的。因此,如果你使用的是兼容ECMAScript 的JavaScript 引擎,
那可能会改为使用Object.defineProperty()方法来定义constructor 属性:

function Person() {}
Person.prototype = {
  name: "Nicholas",
  age: 29,
  job: "Software Engineer",
  sayName() {
    console.log(this.name);
  }
};
// 恢复constructor 属性
Object.defineProperty(Person.prototype, "constructor", {
  enumerable: false,
  value: Person
});

2. 原型的动态性
因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对
象所做的修改也会在实例上反映出来。下面是一个例子:

let friend = new Person();
Person.prototype.sayHi = function() {
  console.log("hi");
};
friend.sayHi(); // "hi",没问题!

以上代码先创建一个Person 实例并保存在friend 中。然后一条语句在Person.prototype 上
添加了一个名为sayHi()的方法。虽然friend 实例是在添加方法之前创建的,但它仍然可以访问这个
方法。之所以会这样,主要原因是实例与原型之间松散的联系。在调用friend.sayHi()时,首先会从
这个实例中搜索名为sayHi 的属性。在没有找到的情况下,运行时会继续搜索原型对象。因为实例和
原型之间的链接就是简单的指针,而不是保存的副本,所以会在原型上找到sayHi 属性并返回这个属
性保存的函数。
虽然随时能给原型添加属性和方法,并能够立即反映在所有对象实例上,但这跟重写整个原型是两
回事。实例的[[Prototype]]指针是在调用构造函数时自动赋值的,这个指针即使把原型修改为不同
的对象也不会变。重写整个原型会切断最初原型与构造函数的联系,但实例引用的仍然是最初的原型。
记住,实例只有指向原型的指针,没有指向构造函数的指针。来看下面的例子:


function Person() {}
let friend = new Person();
Person.prototype = {
  constructor: Person,
  name: "Nicholas",
  age: 29,
  job: "Software Engineer",
  sayName() {
    console.log(this.name);
  }
};
friend.sayName(); // 错误

在这个例子中,Person 的新实例是在重写原型对象之前创建的。在调用friend.sayName()的时
候,会导致错误。这是因为firend 指向的原型还是最初的原型,而这个原型上并没有sayName 属性。
图8-3 展示了这里面的原因

image.png


重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最
初的原型。
3. 原生对象原型
原型模式之所以重要,不仅体现在自定义类型上,而且还因为它也是实现所有原生引用类型的模式。
所有原生引用类型的构造函数(包括Object、Array、String 等)都在原型上定义了实例方法。比如,
数组实例的sort()方法就是Array.prototype 上定义的,而字符串包装对象的substring()方法也
是在String.prototype 上定义的,如下所示:

通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法。可以
像修改自定义对象原型一样修改原生对象原型,因此随时可以添加方法。比如,下面的代码就给String
原始值包装类型的实例添加了一个startsWith()方法:
如果给定字符串的开头出现了调用startsWith()方法的文本,那么该方法会返回true。因为这
个方法是被定义在String.prototype 上,所以当前环境下所有的字符串都可以使用这个方法。msg
是个字符串,在读取它的属性时,后台会自动创建String 的包装实例,从而找到并调用startsWith()
方法。
注意 尽管可以这么做,但并不推荐在产品环境中修改原生对象原型。这样做很可能造成
误会,而且可能引发命名冲突(比如一个名称在某个浏览器实现中不存在,在另一个实现
中却存在)。另外还有可能意外重写原生的方法。推荐的做法是创建一个自定义的类,继
承原生类型。
4. 原型的问题
原型模式也不是没有问题。首先,它弱化了向构造函数传递初始化参数的能力,会导致所有实例默
认都取得相同的属性值。虽然这会带来不便,但还不是原型的最大问题。原型的最主要问题源自它的共
享特性。
我们知道,原型上的所有属性是在实例间共享的,这对函数来说比较合适。另外包含原始值的属性
也还好,如前面例子中所示,可以通过在实例上添加同名属性来简单地遮蔽原型上的属性。真正的问题
来自包含引用值的属性。来看下面的例子:
这里,Person.prototype 有一个名为friends 的属性,它包含一个字符串数组。然后这里创建
了两个Person 的实例。person1.friends 通过push 方法向数组中添加了一个字符串。由于这个
friends 属性存在于Person.prototype 而非person1 上,新加的这个字符串也会在(指向同一个
数组的)person2.friends 上反映出来。如果这是有意在多个实例间共享数组,那没什么问题。但一
般来说,不同的实例应该有属于自己的属性副本。这就是实际开发中通常不单独使用原型模式的原因。


8.3 继承


继承是面向对象编程中讨论最多的话题。很多面向对象语言都支持两种继承:接口继承和实现继承。
前者只继承方法签名,后者继承实际的方法。接口继承在ECMAScript 中是不可能的,因为函数没有签
名。实现继承是ECMAScript 唯一支持的继承方式,而这主要是通过原型链实现的。


8.3.1 原型链


ECMA-262 把原型链定义为ECMAScript 的主要继承方式。其基本思想就是通过原型继承多个引用
类型的属性和方法。重温一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型有
一个属性指回构造函数,而实例有一个内部指针指向原型。如果原型是另一个类型的实例呢?那就意味
着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函
数。这样就在实例和原型之间构造了一条原型链。这就是原型链的基本构想。
实现原型链涉及如下代码模式:
以上代码定义了两个类型:SuperType 和SubType。这两个类型分别定义了一个属性和一个方法。
这两个类型的主要区别是SubType 通过创建SuperType 的实例并将其赋值给自己的原型SubTtype.
prototype 实现了对SuperType 的继承。这个赋值重写了SubType 最初的原型,将其替换为
SuperType 的实例。这意味着SuperType 实例可以访问的所有属性和方法也会存在于SubType.
prototype。这样实现继承之后,代码紧接着又给SubType.prototype,也就是这个SuperType 的
实例添加了一个新方法。最后又创建了SubType 的实例并调用了它继承的getSuperValue()方法。
图8-4 展示了子类的实例与两个构造函数及其对应的原型之间的关系。

image.png


这个例子中实现继承的关键,是SubType 没有使用默认原型,而是将其替换成了一个新的对象。这个
新的对象恰好是SuperType 的实例。这样一来,SubType 的实例不仅能从SuperType 的实例中继承属性
和方法,而且还与SuperType 的原型挂上了钩。于是instance(通过内部的[[Prototype]])指向
SubType.prototype,而SubType.prototype(作为SuperType 的实例又通过内部的[[Prototype]])
指向SuperType.prototype。注意,getSuperValue()方法还在SuperType.prototype 对象上,
而property 属性则在SubType.prototype 上。这是因为getSuperValue()是一个原型方法,而
property 是一个实例属性。SubType.prototype 现在是SuperType 的一个实例,因此property
才会存储在它上面。还要注意,由于SubType.prototype 的constructor 属性被重写为指向
SuperType,所以instance.constructor 也指向SuperType。
原型链扩展了前面描述的原型搜索机制。我们知道,在读取实例上的属性时,首先会在实例上搜索
这个属性。如果没找到,则会继承搜索实例的原型。在通过原型链实现继承之后,搜索就可以继承向上,
搜索原型的原型。对前面的例子而言,调用instance.getSuperValue()经过了3 步搜索:instance、
SubType.prototype 和SuperType.prototype,最后一步才找到这个方法。对属性和方法的搜索会
一直持续到原型链的末端。
1. 默认原型
实际上,原型链中还有一环。默认情况下,所有引用类型都继承自Object,这也是通过原型链实
现的。任何函数的默认原型都是一个Object 的实例,这意味着这个实例有一个内部指针指向
Object.prototype。这也是为什么自定义类型能够继承包括toString()、valueOf()在内的所有默
认方法的原因。因此前面的例子还有额外一层继承关系。图8-5 展示了完整的原型链。
图 8-5
SubType 继承SuperType,而SuperType 继承Object。在调用instance.toString()时,实
际上调用的是保存在Object.prototype 上的方法。
2. 原型与继承关系
原型与实例的关系可以通过两种方式来确定。第一种方式是使用instanceof 操作符,如果一个实
例的原型链中出现过相应的构造函数,则instanceof 返回true。如下例所示:
从技术上讲,instance 是Object、SuperType 和SubType 的实例,因为instance 的原型链
中包含这些构造函数的原型。结果就是instanceof 对所有这些构造函数都返回true。
确定这种关系的第二种方式是使用isPrototypeOf()方法。原型链中的每个原型都可以调用这个
方法,如下例所示,只要原型链中包含这个原型,这个方法就返回true:
3. 关于方法
子类有时候需要覆盖父类的方法,或者增加父类没有的方法。为此,这些方法必须在原型赋值之后
再添加到原型上。来看下面的例子:
在上面的代码中,加粗的部分涉及两个方法。第一个方法getSubValue()是SubType 的新方法,
而第二个方法getSuperValue()是原型链上已经存在但在这里被遮蔽的方法。后面在SubType 实例
上调用getSuperValue()时调用的是这个方法。而SuperType 的实例仍然会调用最初的方法。重点
在于上述两个方法都是在把原型赋值为SuperType 的实例之后定义的。
另一个要理解的重点是,以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写
了原型链。下面是一个例子:
在这段代码中,子类的原型在被赋值为SuperType 的实例后,又被一个对象字面量覆盖了。覆盖
后的原型是一个Object 的实例,而不再是SuperType 的实例。因此之前的原型链就断了。SubType
和SuperType 之间也没有关系了。
4. 原型链的问题
原型链虽然是实现继承的强大工具,但它也有问题。主要问题出现在原型中包含引用值的时候。前
面在谈到原型的问题时也提到过,原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会
在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型
的实例。这意味着原先的实例属性摇身一变成为了原型属性。下面的例子揭示了这个问题:
在这个例子中,SuperType 构造函数定义了一个colors 属性,其中包含一个数组(引用值)。每
个SuperType 的实例都会有自己的colors 属性,包含自己的数组。但是,当SubType 通过原型继承
SuperType 后,SubType.prototype 变成了SuperType 的一个实例,因而也获得了自己的colors
属性。这类似于创建了SubType.prototype.colors 属性。最终结果是,SubType 的所有实例都会
共享这个colors 属性。这一点通过instance1.colors 上的修改也能反映到instance2.colors
上就可以看出来。
原型链的第二个问题是,子类型在实例化时不能给父类型的构造函数传参。事实上,我们无法在不
影响所有对象实例的情况下把参数传进父类的构造函数。再加上之前提到的原型中包含引用值的问题,
就导致原型链基本不会被单独使用。


8.3.2 盗用构造函数


为了解决原型包含引用值导致的继承问题,一种叫作“盗用构造函数”(constructor stealing)的技
术在开发社区流行起来(这种技术有时也称作“对象伪装”或“经典继承”)。基本思路很简单:在子类
构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用
apply()和call()方法以新创建的对象为上下文执行构造函数。来看下面的例子:

JavaScript

function SuperType() {

this.colors = ["red", "blue", "green"];

}

function SubType() {

// 继承SuperType

SuperType.call(this);

}

let instance1 = new SubType();

instance1.colors.push("black");

console.log(instance1.colors); // "red,blue,green,black"

let instance2 = new SubType();

console.log(instance2.colors); // "red,blue,green"

示例中加粗的代码展示了盗用构造函数的调用。通过使用call()(或apply())方法,SuperType
构造函数在为SubType 的实例创建的新对象的上下文中执行了。这相当于新的SubType 对象上运行了
SuperType()函数中的所有初始化代码。结果就是每个实例都会有自己的colors 属性。
1. 传递参数
相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参。来
看下面的例子:

JavaScript

function SuperType(name){

this.name = name;

}

function SubType() {

// 继承SuperType 并传参

SuperType.call(this, "Nicholas");

// 实例属性

this.age = 29;

}

let instance = new SubType();

console.log(instance.name); // "Nicholas";

console.log(instance.age); // 29

在这个例子中,SuperType 构造函数接收一个参数name,然后将它赋值给一个属性。在SubType
构造函数中调用SuperType 构造函数时传入这个参数,实际上会在SubType 的实例上定义name 属性。
为确保SuperType 构造函数不会覆盖SubType 定义的属性,可以在调用父类构造函数之后再给子类实
例添加额外的属性。
2. 盗用构造函数的问题
盗用构造函数的主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,
因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模
式。由于存在这些问题,盗用构造函数基本上也不能单独使用。


8.3.3 组合继承


组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。基
本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方
法定义在原型上以实现重用,又可以让每个实例都有自己的属性。来看下面的例子:

JavaScript

function SuperType(name){

this.name = name;

this.colors = ["red", "blue", "green"];

}

SuperType.prototype.sayName = function() {

console.log(this.name);

};

function SubType(name, age){

// 继承属性

SuperType.call(this, name);

this.age = age;

}



// 继承方法

SubType.prototype = new SuperType();

SubType.prototype.sayAge = function() {

console.log(this.age);

};

let instance1 = new SubType("Nicholas", 29);

instance1.colors.push("black");

console.log(instance1.colors); // "red,blue,green,black"

instance1.sayName(); // "Nicholas";

instance1.sayAge(); // 29

let instance2 = new SubType("Greg", 27);

console.log(instance2.colors); // "red,blue,green"

instance2.sayName(); // "Greg";

instance2.sayAge(); // 27

在这个例子中,SuperType 构造函数定义了两个属性,name 和colors,而它的原型上也定义了
一个方法叫sayName()。SubType 构造函数调用了SuperType 构造函数,传入了name 参数,然后又
定义了自己的属性age。此外,SubType.prototype 也被赋值为SuperType 的实例。原型赋值之后,
又在这个原型上添加了新方法sayAge()。这样,就可以创建两个SubType 实例,让这两个实例都有
自己的属性,包括colors,同时还共享相同的方法。
组合继承弥补了原型链和盗用构造函数的不足,是JavaScript 中使用最多的继承模式。而且组合继
承也保留了instanceof 操作符和isPrototypeOf()方法识别合成对象的能力。


8.3.4 原型式继承


2006 年,Douglas Crockford 写了一篇文章:《JavaScript 中的原型式继承》(“Prototypal Inheritance in
JavaScript”)。这篇文章介绍了一种不涉及严格意义上构造函数的继承方法。他的出发点是即使不自定义
类型也可以通过原型实现对象之间的信息共享。文章最终给出了一个函数:
这个object()函数会创建一个临时构造函数,将传入的对象赋值给这个构造函数的原型,然后返
回这个临时类型的一个实例。本质上,object()是对传入的对象执行了一次浅复制。来看下面的例子:
Crockford 推荐的原型式继承适用于这种情况:你有一个对象,想在它的基础上再创建一个新对象。
你需要把这个对象先传给object(),然后再对返回的对象进行适当修改。在这个例子中,person 对
象定义了另一个对象也应该共享的信息,把它传给object()之后会返回一个新对象。这个新对象的原型
是person,意味着它的原型上既有原始值属性又有引用值属性。这也意味着person.friends 不仅是
person 的属性,也会跟anotherPerson 和yetAnotherPerson 共享。这里实际上克隆了两个person。
ECMAScript 5 通过增加Object.create()方法将原型式继承的概念规范化了。这个方法接收两个
参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。在只有一个参数时,
Object.create()与这里的object()方法效果相同:
Object.create()的第二个参数与Object.defineProperties()的第二个参数一样:每个新增
属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性。比如:
原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但要记住,
属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。


8.3.5 寄生式继承


与原型式继承比较接近的一种继承方式是寄生式继承(parasitic inheritance),也是Crockford 首倡的
一种模式。寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种
方式增强对象,然后返回这个对象。基本的寄生继承模式如下:
在这段代码中,createAnother()函数接收一个参数,就是新对象的基准对象。这个对象original
会被传给object()函数,然后将返回的新对象赋值给clone。接着给clone 对象添加一个新方法
sayHi()。最后返回这个对象。可以像下面这样使用createAnother()函数:
这个例子基于person 对象返回了一个新对象。新返回的anotherPerson 对象具有person 的所
有属性和方法,还有一个新方法叫sayHi()。
寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。object()函数不是寄生式
继承所必需的,任何返回新对象的函数都可以在这里使用。
注意 通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似。


8.3.6 寄生式组合继承


组合继承其实也存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:一次在是
创建子类原型时调用,另一次是在子类构造函数中调用。本质上,子类原型最终是要包含超类对象的所
有实例属性,子类构造函数只要在执行时重写自己的原型就行了。再来看一看这个组合继承的例子:
代码中加粗的部分是调用SuperType 构造函数的地方。在上面的代码执行后,SubType.prototype
上会有两个属性:name 和colors。它们都是SuperType 的实例属性,但现在成为了SubType 的原型
属性。在调用SubType 构造函数时,也会调用SuperType 构造函数,这一次会在新对象上创建实例属
性name 和colors。这两个实例属性会遮蔽原型上同名的属性。图8-6 展示了这个过程。

image.png

image.png


如图8-6 所示,有两组name 和colors 属性:一组在实例上,另一组在SubType 的原型上。这是
调用两次SuperType 构造函数的结果。好在有办法解决这个问题。
寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调
用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父
类原型,然后将返回的新对象赋值给子类原型。寄生式组合继承的基本模式如下所示:
这个inheritPrototype()函数实现了寄生式组合继承的核心逻辑。这个函数接收两个参数:子
类构造函数和父类构造函数。在这个函数内部,第一步是创建父类原型的一个副本。然后,给返回的
prototype 对象设置constructor 属性,解决由于重写原型导致默认constructor 丢失的问题。最
后将新创建的对象赋值给子类型的原型。如下例所示,调用inheritPrototype()就可以实现前面例
子中的子类型原型赋值:
这里只调用了一次SuperType 构造函数,避免了SubType.prototype 上不必要也用不到的属性,
因此可以说这个例子的效率更高。而且,原型链仍然保持不变,因此instanceof 操作符和
isPrototypeOf()方法正常有效。寄生式组合继承可以算是引用类型继承的最佳模式。


8.4 类


前几节深入讲解了如何只使用ECMAScript 5 的特性来模拟类似于类(class-like)的行为。不难看出,
各种策略都有自己的问题,也有相应的妥协。正因为如此,实现继承的代码也显得非常冗长和混乱。
为解决这些问题,ECMAScript 6 新引入的class 关键字具有正式定义类的能力。类(class)是
ECMAScript 中新的基础性语法糖结构,因此刚开始接触时可能会不太习惯。虽然ECMAScript 6 类表面
上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念。


8.4.1 类定义


与函数类型相似,定义类也有两种主要方式:类声明和类表达式。这两种方式都使用class 关键
字加大括号:
与函数表达式类似,类表达式在它们被求值前也不能引用。不过,与函数定义不同的是,虽然函数
声明可以提升,但类定义不能:
另一个跟函数声明不同的地方是,函数受函数作用域限制,而类受块作用域限制:
类的构成
类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。
空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行。
与函数构造函数一样,多数编程风格都建议类名的首字母要大写,以区别于通过它创建的实例(比
如,通过class Foo {}创建实例foo):
类表达式的名称是可选的。在把类表达式赋值给变量后,可以通过name 属性取得类表达式的名称
字符串。但不能在类表达式作用域外部访问这个标识符。


8.4.2 类构造函数


constructor 关键字用于在类定义块内部创建类的构造函数。方法名constructor 会告诉解释器
在使用new 操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函
数相当于将构造函数定义为空函数。
1. 实例化
使用new 操作符实例化Person 的操作等于使用new 调用其构造函数。唯一可感知的不同之处就
是,JavaScript 解释器知道使用new 和类意味着应该使用constructor 函数进行实例化。
使用new 调用类的构造函数会执行如下操作。
(1) 在内存中创建一个新对象。
(2) 这个新对象内部的[[Prototype]]指针被赋值为构造函数的prototype 属性。
(3) 构造函数内部的this 被赋值为这个新对象(即this 指向新对象)。
(4) 执行构造函数内部的代码(给新对象添加属性)。
(5) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
来看下面的例子:
类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的:
默认情况下,类构造函数会在执行之后返回this 对象。构造函数返回的对象会被用作实例化的对
象,如果没有什么引用新创建的this 对象,那么这个对象会被销毁。不过,如果返回的不是this 对
象,而是其他对象,那么这个对象不会通过instanceof 操作符检测出跟类有关联,因为这个对象的原
型指针并没有被修改。
类构造函数与构造函数的主要区别是,调用类构造函数必须使用new 操作符。而普通构造函数如果
不使用new 调用,那么就会以全局的this(通常是window)作为内部对象。调用类构造函数时如果
忘了使用new 则会抛出错误:
类构造函数没有什么特殊之处,实例化之后,它会成为普通的实例方法(但作为类构造函数,仍然
要使用new 调用)。因此,实例化之后可以在实例上引用它:

JavaScript

class Person {}

// 使用类创建一个新实例

let p1 = new Person();

p1.constructor();

// TypeError: Class constructor Person cannot be invoked without 'new'

// 使用对类构造函数的引用创建一个新实例

let p2 = new p1.constructor();

2. 把类当成特殊函数
ECMAScript 中没有正式的类这个类型。从各方面来看,ECMAScript 类就是一种特殊函数。声明一
个类之后,通过typeof 操作符检测类标识符,表明它是一个函数:

JavaScript

class Person {}

console.log(Person); // class Person {}

console.log(typeof Person); // function

类标识符有prototype 属性,而这个原型也有一个constructor 属性指向类自身:

JavaScript

class Person{}

console.log(Person.prototype); // { constructor: f() }

console.log(Person === Person.prototype.constructor); // true

与普通构造函数一样,可以使用instanceof 操作符检查构造函数原型是否存在于实例的原型链中:

JavaScript

class Person {}

let p = new Person();

console.log(p instanceof Person); // true

由此可知,可以使用instanceof 操作符检查一个对象与类构造函数,以确定这个对象是不是类的
实例。只不过此时的类构造函数要使用类标识符,比如,在前面的例子中要检查p 和Person。
如前所述,类本身具有与普通构造函数一样的行为。在类的上下文中,类本身在使用new 调用时就
会被当成构造函数。重点在于,类中定义的constructor 方法不会被当成构造函数,在对它使用
instanceof 操作符时会返回false。但是,如果在创建实例时直接将类构造函数当成普通构造函数来
使用,那么instanceof 操作符的返回值会反转:

JavaScript

class Person {}

let p1 = new Person();

console.log(p1.constructor === Person); // true

console.log(p1 instanceof Person); // true

console.log(p1 instanceof Person.constructor); // false

let p2 = new Person.constructor();

console.log(p2.constructor === Person); // false

console.log(p2 instanceof Person); // false

console.log(p2 instanceof Person.constructor); // true

类是JavaScript 的一等公民,因此可以像其他对象或函数引用一样把类作为参数传递:

// 类可以像函数一样在任何地方定义,比如在数组中

let classList = [

class {

constructor(id) {

this.id_ = id;

console.log(`instance ${this.id_}`);

}

}

];

function createInstance(classDefinition, id) {

return new classDefinition(id);

}

let foo = createInstance(classList[0], 3141); // instance 3141

与立即调用函数表达式相似,类也可以立即实例化:


// 因为是一个类表达式,所以类名是可选的
let p = new class Foo {
  constructor(x) {
    console.log(x);
  }
}('bar'); // bar
console.log(p); // Foo {}

8.4.3 实例、原型和类成员
类的语法可以非常方便地定义应该存在于实例上的成员、应该存在于原型上的成员,以及应该存在
于类本身的成员。
1. 实例成员
每次通过new 调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this)
添加“自有”属性。至于添加什么样的属性,则没有限制。另外,在构造函数执行完毕后,仍然可以给
实例继续添加新成员。
每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享:


class Person {
  constructor() {
    // 这个例子先使用对象包装类型定义一个字符串
    // 为的是在下面测试两个对象的相等性
    this.name = new String('Jack');
    this.sayName = () => console.log(this.name);
    this.nicknames = ['Jake', 'J-Dog']
  }
}
let p1 = new Person(),
  p2 = new Person();
p1.sayName(); // Jack
p2.sayName(); // Jack
console.log(p1.name === p2.name); // false
console.log(p1.sayName === p2.sayName); // false
console.log(p1.nicknames === p2.nicknames); // false
p1.name = p1.nicknames[0];
p2.name = p2.nicknames[1];
p1.sayName(); // Jake

2. 原型方法与访问器
为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法

class Person {
  constructor() {
    // 添加到this 的所有内容都会存在于不同的实例上
    this.locate = () => console.log('instance');
  }
  // 在类块中定义的所有内容都会定义在类的原型上
  locate() {
    console.log('prototype');
  }
}
let p = new Person();
p.locate(); // instance
Person.prototype.locate(); // prototype

可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据:


class Person {
name: 'Jake'
}
// Uncaught SyntaxError: Unexpected token

类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键:


const symbolKey = Symbol('symbolKey');
class Person {
  stringKey() {
    console.log('invoked stringKey');
  }
  [symbolKey]() {
    console.log('invoked symbolKey');
  }
  ['computed' + 'Key']() {
    console.log('invoked computedKey');
  }
}
let p = new Person();
p.stringKey(); // invoked stringKey
p[symbolKey](); // invoked symbolKey
p.computedKey(); // invoked computedKey

类定义也支持获取和设置访问器。语法与行为跟普通对象一样:

class Person {
  set name(newName) {
    this.name_ = newName;
  }
  get name() {
    return this.name_;
  }
}
let p = new Person();
p.name = 'Jake';
console.log(p.name); // Jake

3. 静态类方法
可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。
与原型成员类似,静态成员每个类上只能有一个。
静态类成员在类定义中使用static 关键字作为前缀。在静态成员中,this 引用类自身。其他所
有约定跟原型成员一样:


class Person {
  constructor() {
    // 添加到this 的所有内容都会存在于不同的实例上
    this.locate = () => console.log('instance', this);
  }
  // 定义在类的原型对象上
  locate() {
    console.log('prototype', this);
  }
  // 定义在类本身上
  static locate() {
    console.log('class', this);
  }
}
let p = new Person();
p.locate(); // instance, Person {}
Person.prototype.locate(); // prototype, {constructor: ... }
Person.locate(); // class, class Person {}

静态类方法非常适合作为实例工厂:
4. 非函数原型和类成员
虽然类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加:
注意 类定义中之所以没有显式支持添加数据成员,是因为在共享目标(原型和类)上添
加可变(可修改)数据成员是一种反模式。一般来说,对象实例应该独自拥有通过this
引用的数据。
5. 迭代器与生成器方法
类定义语法支持在原型和类本身上定义生成器方法:

JavaScript

class Person {

// 在原型上定义生成器方法

*createNicknameIterator() {

yield 'Jack';

yield 'Jake';

yield 'J-Dog';

}

// 在类上定义生成器方法

static *createJobIterator() {

yield 'Butcher';

yield 'Baker';

yield 'Candlestick maker';

}

}

let jobIter = Person.createJobIterator();

console.log(jobIter.next().value); // Butcher

console.log(jobIter.next().value); // Baker

console.log(jobIter.next().value); // Candlestick maker

let p = new Person();

let nicknameIter = p.createNicknameIterator();

console.log(nicknameIter.next().value); // Jack

console.log(nicknameIter.next().value); // Jake

console.log(nicknameIter.next().value); // J-Dog

因为支持生成器方法,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象:

JavaScript

class Person {

constructor() {

this.nicknames = ['Jack', 'Jake', 'J-Dog'];

}

*[Symbol.iterator]() {

yield *this.nicknames.entries();

}

}

let p = new Person();

for (let [idx, nickname] of p) {

console.log(nickname);

}

// Jake

// J-Dog

也可以只返回迭代器实例:

JavaScript

class Person {

constructor() {

this.nicknames = ['Jack', 'Jake', 'J-Dog'];

}

[Symbol.iterator]() {

return this.nicknames.entries();

}

}

let p = new Person();

for (let [idx, nickname] of p) {

console.log(nickname);

}

// Jack

// Jake

// J-Dog

8.4.4 继承

本章前面花了大量篇幅讨论如何使用ES5 的机制实现继承。ECMAScript 6 新增特性中最出色的一
个就是原生支持了类继承机制。虽然类继承使用的是新语法,但背后依旧使用的是原型链。
1. 继承基础
ES6 类支持单继承。使用extends 关键字,就可以继承任何拥有[[Construct]]和原型的对象。
很大程度上,这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容):
派生类都会通过原型链访问到类和原型上定义的方法。this 的值会反映调用相应方法的实例或者类:
注意 extends 关键字也可以在类表达式中使用,因此let Bar = class extends Foo {}
是有效的语法。
2. 构造函数、HomeObject 和super()
派生类的方法可以通过super 关键字引用它们的原型。这个关键字只能在派生类中使用,而且仅
限于类构造函数、实例方法和静态方法内部。在类构造函数中使用super 可以调用父类构造函数。
在静态方法中可以通过super 调用继承的类上定义的静态方法:
注意 ES6 给类构造函数和静态方法添加了内部特性[[HomeObject]],这个特性是一个
指针,指向定义该方法的对象。这个指针是自动赋值的,而且只能在JavaScript 引擎内部
访问。super 始终会定义为[[HomeObject]]的原型。
在使用super 时要注意几个问题。
 super 只能在派生类构造函数和静态方法中使用。
 不能单独引用super 关键字,要么用它调用构造函数,要么用它引用静态方法。
 调用super()会调用父类构造函数,并将返回的实例赋值给this。
 super()的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入。
 如果没有定义类构造函数,在实例化派生类时会调用super(),而且会传入所有传给派生类的
参数。
 在类构造函数中,不能在调用super()之前引用this。
 如果在派生类中显式定义了构造函数,则要么必须在其中调用super(),要么必须在其中返回
一个对象。
3. 抽象基类
有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化。虽然ECMAScript 没
有专门支持这种类的语法 ,但通过new.target 也很容易实现。new.target 保存通过new 关键字调
用的类或函数。通过在实例化时检测new.target 是不是抽象基类,可以阻止对抽象基类的实例化:
另外,通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法。因为原型方法在
调用类构造函数之前就已经存在了,所以可以通过this 关键字来检查相应的方法:
4. 继承内置类型
ES6 类为继承内置引用类型提供了顺畅的机制,开发者可以方便地扩展内置类型:
有些内置类型的方法会返回新实例。默认情况下,返回实例的类型与原始实例的类型是一致的:
如果想覆盖这个默认行为,则可以覆盖Symbol.species 访问器,这个访问器决定在创建返回的
实例时使用的类:
5. 类混入
把不同类的行为集中到一个类是一种常见的JavaScript 模式。虽然ES6 没有显式支持多类继承,但
通过现有特性可以轻松地模拟这种行为。
注意 Object.assign()方法是为了混入对象行为而设计的。只有在需要混入类的行为
时才有必要自己实现混入表达式。如果只是需要混入多个对象的属性,那么使用
Object.assign()就可以了。
在下面的代码片段中,extends 关键字后面是一个JavaScript 表达式。任何可以解析为一个类或一
个构造函数的表达式都是有效的。这个表达式会在求值类定义时被求值:

JavaScript

class Vehicle {}

function getParentClass() {

console.log('evaluated expression');

return Vehicle;

}

class Bus extends getParentClass() {}

// 可求值的表达式

混入模式可以通过在一个表达式中连缀多个混入元素来实现,这个表达式最终会解析为一个可以被
继承的类。如果Person 类需要组合A、B、C,则需要某种机制实现B 继承A,C 继承B,而Person
再继承C,从而把A、B、C 组合到这个超类中。实现这种模式有不同的策略。
一个策略是定义一组“可嵌套”的函数,每个函数分别接收一个超类作为参数,而将混入类定义为
这个参数的子类,并返回这个类。这些组合函数可以连缀调用,最终组合成超类表达式:

JavaScript

class Vehicle {}

let FooMixin = (Superclass) => class extends Superclass {

foo() {

console.log('foo');

}

};

let BarMixin = (Superclass) => class extends Superclass {

bar() {

console.log('bar');

}

};

let BazMixin = (Superclass) => class extends Superclass {

baz() {

console.log('baz');

}

};

class Bus extends FooMixin(BarMixin(BazMixin(Vehicle))) {}

let b = new Bus();

b.foo(); // foo

b.bar(); // bar

b.baz(); // baz

通过写一个辅助函数,可以把嵌套调用展开:

JavaScript

class Vehicle {}

let FooMixin = (Superclass) => class extends Superclass {

foo() {

console.log('foo');

}

};

let BarMixin = (Superclass) => class extends Superclass {

bar() {

console.log('bar');

}

};

let BazMixin = (Superclass) => class extends Superclass {

baz() {

console.log('baz');

}

};

function mix(BaseClass, ...Mixins) {

return Mixins.reduce((accumulator, current) => current(accumulator), BaseClass);

}

class Bus extends mix(Vehicle, FooMixin, BarMixin, BazMixin) {}

let b = new Bus();

b.foo(); // foo

b.bar(); // bar

b.baz(); // baz

注意 很多JavaScript 框架(特别是React)已经抛弃混入模式,转向了组合模式(把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。这反映了那个众所周知的软件设计原则:“组合胜过继承(composition over inheritance)。”这个设计原则被很多人遵循,在代码设计中能提供极大的灵活性。

8.5 小结

对象在代码执行过程中的任何时候都可以被创建和增强,具有极大的动态性,并不是严格定义的实体。下面的模式适用于创建对象。
工厂模式就是一个简单的函数,这个函数可以创建对象,为它添加属性和方法,然后返回这个对象。这个模式在构造函数模式出现后就很少用了。
使用构造函数模式可以自定义引用类型,可以使用new 关键字像创建内置类型实例一样创建自定义类型的实例。不过,构造函数模式也有不足,主要是其成员无法重用,包括函数。考虑到函数本身是松散的、弱类型的,没有理由让函数不能在多个对象实例间共享。
原型模式解决了成员共享的问题,只要是添加到构造函数prototype 上的属性和方法就可以共享。而组合构造函数和原型模式通过构造函数定义实例属性,通过原型定义共享的属性和方法。
JavaScript 的继承主要通过原型链来实现。原型链涉及把构造函数的原型赋值为另一个类型的实例。这样一来,子类就可以访问父类的所有属性和方法,就像基于类的继承那样。原型链的问题是所有继承的属性和方法都会在对象实例间共享,无法做到实例私有。盗用构造函数模式通过在子类构造函数中调用父类构造函数,可以避免这个问题。这样可以让每个实例继承的属性都是私有的,但要求类型只能通过构造函数模式来定义(因为子类不能访问父类原型上的方法)。目前最流行的继承模式是组合继承,即通过原型链继承共享的属性和方法,通过盗用构造函数继承实例属性。
除上述模式之外,还有以下几种继承模式。
原型式继承可以无须明确定义构造函数而实现继承,本质上是对给定对象执行浅复制。这种操作的结果之后还可以再进一步增强。
与原型式继承紧密相关的是寄生式继承,即先基于一个对象创建一个新对象,然后再增强这个新对象,最后返回新对象。这个模式也被用在组合继承中,用于避免重复调用父类构造函数导致的浪费。
寄生组合继承被认为是实现基于类型继承的最有效方式。
ECMAScript 6 新增的类很大程度上是基于既有原型机制的语法糖。类的语法让开发者可以优雅地定义向后兼容的类,既可以继承内置类型,也可以继承自定义类型。类有效地跨越了对象实例、对象原型和对象类之间的鸿沟。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值