开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。
红宝书《JavaScript高级程序设计(第5版)》学习大纲 - 大前端全栈开发 - SegmentFault 思否
创建对象:
虽然使用Object构造函数或对象字面量可以方便地创建对象,但这些方式也有明显不足:创建具有同样接口的多个对象需要重复编写很多代码。
概述:
综观ECMAScript规范的历次发布,每个版本的特性似乎都出人意料。ECMAScript 5.1并没有正式支持面向对象的结构,比如类或继承。但是,正如接下来几节会介绍的,巧妙地运用原型式继承可以成功地模拟同样的行为。
ECMAScript 6开始正式支持类和继承。ECMAScript的类旨在完全涵盖之前规范设计的基于原型的继承模式。不过,无论从哪方面看,类都仅仅是封装了ES5.1构造函数加原型继承的语法糖而已。
编写面向对象编程模式的JavaScript代码还是应该使用ECMAScript类。但不管怎么说,理解ES6类出现之前的惯例总是有益无害的。特别是ECMAScript类定义本身就相当于对原有结构的封装。因此,在介绍类之前,本书会循序渐进地介绍被类取代的那些底层概念。
构造函数模式:
前几章提到过,ECMAScript中的构造函数是用于创建特定类型对象的。像Object和Array这样的原生构造函数,运行时可以直接在执行环境中使用。当然也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。
来看一个使用构造函数模式的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = function() {
console.log(this.name);
};
}
let person1 = new Person("Alice", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
person1.sayName(); // Alice
person2.sayName(); // Greg
如果不用new,this会指向window:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 function Person(name) { console.log(this); // window (全局对象) this.name = name; // 没有返回值 } let p2 = Person("Bob"); console.log(p2); // undefined console.log(window.name); // Bob (污染了全局) console.log(p2 instanceof Person); // false // 当我们写 new Person() 时,JavaScript 底层做了这些事: function Person(name) { // 1. 隐式创建一个新对象:let this = {} // 2. 设置原型:this.__proto__ = Person.prototype // 3. 执行函数体 this.name = name; // 4. 隐式返回 this // return this; }
对于这个例子,要注意以下几点:
没有显式地创建对象。
属性和方法直接赋值给了this。
没有return。
另外,要注意函数名Person的首字母大写了。按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。这是从面向对象编程语言那里借鉴的,有助于在ECMAScript中区分构造函数和普通函数。毕竟ECMAScript的构造函数就是能创建对象的函数。
要创建Person的实例,应使用new操作符。以这种方式调用构造函数会执行如下操作。
(1)在内存中创建一个新对象。
(2)这个新对象内部的 [[Prototype]] 特性被赋值为构造函数的prototype属性。
(3)构造函数内部的this被赋值为这个新对象(即this指向新对象)。
(4)执行构造函数内部的代码(给新对象添加属性)。
(5)如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
上一个例子的最后,person1和person2分别保存着Person的不同实例。这两个对象都有一个constructor属性指向Person,如下所示:
console.log(person1.constructor == Person); // true
console.log(person2.constructor == Person); // true
constructor本来是用于标识对象类型的。不过,一般认为instanceof操作符是确定对象类型更可靠的方式。前面例子中的每个对象都是Object的实例,同时也是Person的实例,如下面调用instanceof操作符的结果所示:
constructor属性是每个对象通过原型链继承的属性,它指向创建该对象的构造函数。
1
2
3
4
console.log(person1 instanceof Object); // true
console.log(person1 instanceof Person); // true
console.log(person2 instanceof Object); // true
console.log(person2 instanceof Person); // true
定义自定义构造函数可以确保实例被标识为特定类型。在这个例子中,person1和person2之所以也被认为是Object的实例,是因为所有自定义对象都继承自Object(后面再详细讨论这一点)。
构造函数不一定要写成函数声明的形式。赋值给变量的函数表达式也可以作为构造函数:
任何函数(除了箭头函数)都可以作为构造函数,只要它被设计用来与
new关键字配合使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let Person = function(name, age, job) {
// 和最一开始function Person(name, age, job)的例子相比
// 最大区别是let Person = function(name, age, job)不会被提升
// 任何函数(除了箭头函数)都可以作为构造函数,只要它被设计用来与 new关键字配合使用
this.name = name;
this.age = age;
this.job = job;
this.sayName = function() {
console.log(this.name);
};
}
let person1 = new Person("Alice", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
person1.sayName(); // Alice
person2.sayName(); // Greg
console.log(person1 instanceof Object); // true
console.log(person1 instanceof Person); // true
console.log(person2 instanceof Object); // true
console.log(person2 instanceof Person); // true
在实例化时,如果不想传参数,那么构造函数后面的括号可加可不加。只要有new操作符,就可以调用相应的构造函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Person() {
this.name = "Jake";
this.sayName = function() {
console.log(this.name);
};
}
let person1 = new Person();
let person2 = new Person; // 可以不加
person1.sayName(); // Jake
person2.sayName(); // Jake
console.log(person1 instanceof Object); // true
console.log(person1 instanceof Person); // true
console.log(person2 instanceof Object); // true
console.log(person2 instanceof Person); // true
构造函数也是函数:
构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。并没有把某个函数定义为构造函数的特殊语法。
任何函数只要使用new操作符调用就是构造函数,而不使用new操作符调用的函数就是普通函数。比如,前面的例子中定义的Person()可以像下面这样调用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 作为构造函数
let person = new Person("Alice", 29, "Software Engineer");
person.sayName(); // "Alice"
// 作为函数调用
Person("Greg", 27, "Doctor"); // 添加到window对象
window.sayName(); // "Greg"
// 在另一个对象的作用域中调用
let o = new Object();
Person.call(o, "Kristen", 25, "Nurse");
o.sayName(); // "Kristen"
// call 方法的语法
function.call(thisArg, arg1, arg2, ...)
// 作用:调用函数,并显式指定 this 的值
// 1. thisArg:函数执行时 this 的指向
// 2. arg1, arg2, ...:传递给函数的参数
这个例子展示了典型的构造函数调用方式,即使用new操作符创建一个新对象。然后是普通函数的调用方式,这时候没有使用new操作符调用Person(),结果会将属性和方法添加到window对象。
这里要记住,在调用一个函数而没有明确设置this值的情况下(即没有作为某个对象的方法调用,或者没有使用call()/apply()调用),this始终指向Global对象(在浏览器中就是window对象)。因此在上面的调用之后,window对象上就有了一个sayName()方法,调用它会返回 “Greg”。
最后展示的调用方式是通过call()(或apply())调用函数,同时将特定对象指定为作用域。这里的调用将对象o指定为Person()内部的this值,因此执行完函数代码后,所有属性和sayName()方法都会添加到对象o上面。步骤可以解释如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 第一步:创建一个空对象
let o = {}; // 等价于 new Object()
// 第二步:通过 call 调用 Person 函数
Person.call(o, "Kristen", 25, "Nurse");
// 这相当于手动执行了以下代码:
o.name = "Kristen";
o.age = 25;
o.job = "Nurse";
o.sayName = function() {
console.log(this.name); // 这里的 this 指向 o
};
构造函数的问题:
构造函数虽然有用,但有一个核心问题:方法在每个实例上都创建一遍。 每个实例的 sayName 看起来是同一个函数,实际上各自都是一个新的 Function 实例:
1
2
3
4
5
6
7
8
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.sayName = new Function("console.log(this.name)"); // 逻辑等价
}
console.log(person1.sayName == person2.sayName); // false
函数是对象,每次定义都会初始化一个新实例。两个实例做的是同样的事,没必要创建两个不同的 Function。
关键在于内存浪费。每次
new Person()都会创建一个新的 Function 对象,每个 Function 对象都要占用独立的内存空间。1000 个实例就有 1000 个一模一样的sayName函数,但它们做的事情完全相同。
解决思路及其新问题:
既然方法逻辑相同,可以把函数定义移到构造函数外部,让所有实例共享同一个函数引用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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("Alice", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
person1.sayName(); // Alice
person2.sayName(); // Greg
方法不再重复创建了,但带来了新问题:sayName 被定义在了全局作用域,却只能被 Person 实例调用。如果对象需要多个方法,就要在全局定义多个函数,自定义类型的代码被拆散到各处,无法聚拢。
构造函数的两难:方法放内部 → 每个实例重复创建;移到外部 → 污染全局作用域。这个困境,正是原型模式要解决的。
原型模式:
每个函数都会创建一个 prototype 属性,它是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。在原型上定义的属性和方法可以被所有实例共享,解决了构造函数模式中方法重复创建的问题:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Person() {}
Person.prototype.name = "Alice";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
console.log(this.name);
};
let person1 = new Person();
person1.sayName(); // "Alice"
let person2 = new Person();
person2.sayName(); // "Alice"
console.log(person1.sayName == person2.sayName); // true
与构造函数模式的区别:构造函数中属性和方法直接赋给实例,每个实例各有一份;原型模式中,属性和方法定义在 prototype 上,所有实例共享同一份。构造函数体可以为空,但新对象仍然拥有对应的属性和方法。
原型模式的核心:不在实例上重复创建,而是把共享内容放到原型对象上,所有实例通过原型链访问同一份属性和方法。要理解这个过程,就需要理解 ECMAScript 中原型的本质。
理解原型:
无论何时创建一个函数,就会按特定规则为它创建 prototype 属性,指向原型对象。默认情况下,所有原型对象自动获得一个 constructor 属性,指回与之关联的构造函数,即 Person.prototype.constructor === Person。除此之外,原型对象的其他方法都继承自 Object。
原型对象就是
prototype属性指向的那个对象。
1 function Person() {}写完这一行,引擎自动做了这件事:
1 2 3 4 // 引擎内部自动创建 Person.prototype = { constructor: Person // 默认唯一的自有属性,指回构造函数 };原型对象用来存放所有实例共享的属性和方法。名字叫”原型对象”是因为它是实例的原型(模板),实例通过
[[Prototype]]链接到它,从而访问上面定义的属性和方法。此外,箭头函数没有
prototype。
每次调用构造函数创建新实例,实例内部会有一个指针指向构造函数的原型对象。在 ECMA-262 规范中,这个指针叫 [[Prototype]],脚本中不能直接访问,但浏览器在每个对象上暴露了 __proto__ 属性来访问原型。
实例与构造函数的原型之间有直接联系,但实例与构造函数之间没有。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Person (构造函数)
│
├── prototype ──→ Person.prototype (原型对象)
│ │
│ ├── constructor ──→ Person
│ ├── name: "Alice"
│ └── sayName()
│
└── new Person()
│
├── person1
│ └── [[Prototype]] ──→ Person.prototype
│
└── person2
└── [[Prototype]] ──→ Person.prototype
实例通过 [[Prototype]] 指向原型对象,原型对象通过 constructor 指回构造函数,但实例和构造函数之间没有直接链接。
这种关系不好可视化,但我们可以通过下面的代码片段表格来理解构造函数、原型与实例的关系。
对于前面例子中的Person构造函数和Person.prototype,可以通过图8-1看出各个对象之间的关系。
构造函数、原型对象和实例。
上图展示了Person构造函数、Person的原型对象和Person现有两个实例之间的关系。
注意,Person.prototype指向原型对象,而Person.prototype.contructor指回Person构造函数。原型对象包含constructor属性和其他后来添加的属性。Person的两个实例person1和person2都只有一个内部属性指回Person.prototype,而且两者都与构造函数没有直接联系。
另外要注意,虽然这两个实例都没有属性和方法,但person1.sayName()可以正常调用。这是由于对象属性查找机制的原因。
虽然 [[Prototype]] 不能直接访问,但 ECMAScript 提供了几个 API 来检测和操作原型关系。
isPrototypeOf():判断一个对象是否是另一个对象的原型。本质上,如果传入参数的 [[Prototype]] 指向调用它的对象,就返回 true:
1
2
console.log(Person.prototype.isPrototypeOf(person1)); // true
console.log(Person.prototype.isPrototypeOf(person2)); // true
person1 和 person2 内部都有链接指向 Person.prototype,所以结果都返回 true。
Object.getPrototypeOf():返回参数的 [[Prototype]] 值,即其原型对象:
1
2
console.log(Object.getPrototypeOf(person1) == Person.prototype); // true
console.log(Object.getPrototypeOf(person1).name); // "Alice"
第一行确认了 getPrototypeOf 返回的就是传入对象的原型对象,第二行则取得了原型对象上 name 属性的值。这个方法在通过原型实现继承时尤为重要(本章后面会介绍)。
Object.setPrototypeOf():修改已有对象的 [[Prototype]],重写其原型继承关系:
1
2
3
4
5
6
7
let biped = { numLegs: 2 };
let person = { name: 'Matt' };
Object.setPrototypeOf(person, biped);
console.log(person.numLegs); // 2
console.log(Object.getPrototypeOf(person) === biped); // true
性能警告:
Object.setPrototypeOf()会严重影响代码性能。修改继承关系的影响不仅是执行这条语句本身,而是涉及所有访问被修改过[[Prototype]]的对象的代码。Mozilla 文档明确指出这种影响是微妙且深远的。
Object.create():为避免使用Object.setPrototypeOf()可能造成的性能下降,可以通过Object.create()来创建一个新对象,同时为其指定原型:
1
2
3
4
5
6
let biped = { numLegs: 2 };
let person = Object.create(biped);
person.name = 'Matt';
console.log(person.numLegs); // 2
console.log(Object.getPrototypeOf(person) === biped); // true
需要指定原型时优先用
Object.create(),尽量避免在运行时用setPrototypeOf()修改已有对象的原型。
原型层级:
通过对象访问属性时,搜索过程是:先查实例本身,再查原型对象。
以 person1.sayName() 为例,引擎先问”person1 实例上有 sayName 吗?”没有,再问”person1 的原型上有 sayName 吗?”有,返回该函数。
这就是原型能在多个实例间共享属性和方法的原理:实例自身没有的属性,沿着 [[Prototype]] 到原型上找。
属性遮盖:
虽然可以通过实例读取原型上的值,但不能通过实例重写原型的值。在实例上添加与原型同名的属性,会在实例上创建该属性,遮盖(shadow)原型上的同名属性:
1
2
3
4
5
6
7
8
9
10
11
12
13
function Person() {}
Person.prototype.name = "Alice";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() { console.log(this.name); };
let person1 = new Person();
let person2 = new Person();
person1.name = "Greg";
console.log(person1.name); // "Greg",来自实例,找到即停,不再搜索原型
console.log(person2.name); // "Alice",来自原型,实例上没有,继续搜索原型
遮盖只是屏蔽了对原型属性的访问,不会修改原型上的值。即使在实例上把属性设为 null,也不会恢复与原型的联系,因为实例上仍然存在该属性,只是值为 null,搜索在实例这一层就停了。
只有用 delete 彻底删除实例上的属性,才能重新访问到原型上的同名属性:
1
2
delete person1.name;
console.log(person1.name); // "Alice",重新来自原型
区分属性来源:hasOwnProperty()
那怎么知道一个属性是实例自己的还是来自原型?
hasOwnProperty()(继承自 Object)可以判断:只有属性存在于调用它的对象实例上时才返回 true:
1
2
3
4
5
6
7
8
9
let person1 = new Person();
console.log(person1.hasOwnProperty("name")); // false,实例上还没有
person1.name = "Greg";
console.log(person1.hasOwnProperty("name")); // true,现在在实例上了
delete person1.name;
console.log(person1.hasOwnProperty("name")); // false,实例属性已删除
下图形象地展示了上面例子中各个步骤的状态。(为简单起见,图中省略了Person构造函数。)
Object.hasOwn()方法是Object.prototype.hasOwnProperty()的替代简写方法。因此下面两行代码是等价的:
1
2
person.hasOwnProperty("name");
Object.hasOwn(person, "name");
ECMAScript的Object.getOwnPropertyDescriptor()方法只对实例属性有效。要取得原型属性的描述符,必须直接在原型对象上调用Object.getOwnPropertyDescriptor()。
原型和in操作符:
in 操作符有两种使用方式:单独使用和在 for-in 循环中使用。
单独使用时,只要能通过对象访问到指定属性就返回 true,无论该属性在实例上还是原型上。这和 hasOwnProperty() 形成了互补,hasOwnProperty() 只认实例,in 不区分来源:
1
2
3
4
5
6
7
8
9
let person1 = new Person(); // 原型上有 name
console.log(person1.hasOwnProperty("name")); // false,不在实例上
console.log("name" in person1); // true,可以通过实例访问
person1.name = "Greg"; // 在实例上遮盖
console.log(person1.hasOwnProperty("name")); // true,现在在实例上了
console.log("name" in person1); // true,依然可以访问
既然 hasOwnProperty() 只认实例、in 不区分来源,那两者组合就能判断属性是否只存在于原型上:
1
2
3
function hasPrototypeProperty(object, name) {
return !object.hasOwnProperty(name) && (name in object);
}
in 返回 true(属性可访问)且 hasOwnProperty 返回 false(不在实例上)→ 属性一定在原型上。实例上遮盖后,hasOwnProperty 变为 true,hasPrototypeProperty 就返回 false,即使原型上还有这个属性,但已经被遮盖,不再是原型属性在生效了。
for-in 循环中,所有可通过对象访问且可枚举的属性都会返回,包括实例属性和原型属性。遮盖原型中不可枚举属性的实例属性也会被返回,因为手动定义的属性默认可枚举。
属性枚举的 API:
for-in 循环虽然方便,但有时需要更精确的控制。ECMAScript 提供了三个方法,按需选择:
| 方法 | 范围 | 可枚举性 |
|---|---|---|
Object.keys(obj) | 自有属性 | 仅可枚举 |
Object.getOwnPropertyNames(obj) | 自有属性 | 包含不可枚举(如 constructor) |
Object.getOwnPropertySymbols(obj) | 自有符号键属性 | 包含不可枚举 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 原型上的可枚举自有属性
Object.keys(Person.prototype); // ["name","age","job","sayName"]
// 原型上的所有自有属性,注意 constructor 也出来了
Object.getOwnPropertyNames(Person.prototype); // ["constructor","name","age","job","sayName"]
// 实例上的可枚举自有属性
let p1 = new Person();
p1.name = "Rob"; p1.age = 31;
Object.keys(p1); // ["name","age"]
// 符号键属性,字符串键的方法拿不到符号键,需要专用的 API
let k1 = Symbol('k1'), k2 = Symbol('k2');
let o = { [k1]: 'k1', [k2]: 'k2' };
Object.getOwnPropertySymbols(o); // [Symbol(k1), Symbol(k2)]
属性枚举顺序:
for-in循环、Object.keys()、Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()以及Object.assign()在属性枚举顺序方面有很大区别。for-in循环和Object.keys()的枚举顺序是不确定的,取决于JavaScript引擎,可能因浏览器而异。
Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和Object.assign()的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序(属性被添加到对象上的先后顺序)枚举字符串键和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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)]
对象迭代:
静态方法Object.values()和Object.entries()用于将对象内容转换为序列化且可迭代的格式。这两个方法都接收对象,返回数组。Object.values()返回对象值的数组,Object.entries()返回键-值对的数组。
1
2
3
4
5
6
7
8
9
10
11
const o = {
foo: 'bar',
baz: 1,
qux: {}
};
console.log(Object.values(o));
// ["bar", 1, {}]
console.log(Object.entries((o)));
// [["foo", "bar"], ["baz", 1], ["qux", {}]]
注意,非字符串属性会被转换为字符串输出。另外,这两个方法执行对象的浅复制:
1
2
3
4
5
6
7
8
9
const o = {
qux: {}
};
console.log(Object.values(o)[0] === o.qux);
// true
console.log(Object.entries(o)[0][1] === o.qux);
// true,即使只有一个键值对,返回的也是一个二维数组
符号属性会被忽略:
1
2
3
4
5
6
7
8
9
10
const sym = Symbol();
const o = {
[sym]: 'foo'
};
console.log(Object.values(o));
// []
console.log(Object.entries((o)));
// []
ECMAScript也提供了静态方法Object.fromEntries(),可以基于键-值对的集合构建对象。这个方法执行与Object.entries()相反的操作,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
const obj = {
foo: 'bar',
baz: 'qux'
};
const objEntries = Object.entries(obj);
console.log(objEntries);
// [["foo", "bar"], ["baz", "qux"]]
console.log(Object.fromEntries(objEntries));
// { foo: "bar", baz: "qux" }
这个静态方法的参数是一个可迭代对象,包含任意个数大小为2的可迭代对象。在需要把Map实例转换为Object实例时,这个方法非常方便。因为Map迭代器的输出恰好与fromEntries()参数的签名完全匹配:
1
2
3
4
const map = new Map().set('foo', 'bar');
console.log(Object.fromEntries(map));
// { foo: "bar" }
原型的动态性:
实例与原型之间是指针链接而非副本,这意味着实例并不保存原型属性的快照,而是在访问时沿指针动态搜索。所以随时给原型追加属性和方法,会立即反映在所有实例上,即使实例在修改之前就已创建:
1
2
3
4
5
6
7
let friend = new Person();
Person.prototype.sayHi = function() {
console.log("hi");
};
friend.sayHi(); // "hi"——没问题
调用 friend.sayHi() 时,实例上找不到,沿指针搜索原型,找到了新添加的方法。能访问到,是因为追加修改没有改变 Person.prototype 这个对象本身,只是在它上面加了属性,而 friend 的 [[Prototype]] 指针还指向它。
虽然随时能给原型添加属性和方法,并能够立即反映在所有对象实例上,但重写整个原型是另一回事。实例的 [[Prototype]] 指针在调用构造函数时自动赋值,指向的是当时的原型对象。重写原型等于创建了一个新对象替换掉 Person.prototype,但已有实例的指针不会跟着变,它还指向旧原型:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Person() {}
let friend = new Person(); // friend.[[Prototype]] → 旧原型对象
// 重写整个原型——Person.prototype 现在指向一个全新对象
Person.prototype = {
constructor: Person,
name: "Alice",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
// friend.[[Prototype]] 仍然 → 旧原型对象(只有默认 constructor)
friend.sayName(); // 错误!旧原型上没有 sayName
在这个例子中,Person的新实例是在重写原型对象之前创建的。在调用friend.sayName()的时候,会导致错误。这是因为firend指向的原型还是最初的原型,而这个原型上并没有sayName属性。
下图展示了这里面的原因。
重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最初的原型。
原生对象原型:
原型模式不仅用于自定义类型,所有原生引用类型(Object、Array、String 等)的实例方法也都定义在原型上:
1
2
console.log(typeof Array.prototype.sort); // "function"
console.log(typeof String.prototype.substring); // "function"
通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法。
既然是原型,就可以像修改自定义对象原型一样给原生类型添加方法。比如给 String 添加一个 startsWith():
1
2
3
4
5
6
String.prototype.startsWith = function(text) {
return this.indexOf(text) === 0;
};
let msg = "Hello world!";
console.log(msg.startsWith("Hello")); // true
msg 是原始字符串,读取属性时后台会自动创建 String 包装实例,从而在原型上找到并调用 startsWith()。定义在 String.prototype 上,当前环境下所有字符串都能使用。
不推荐在生产环境中修改原生对象原型。因为可能造成命名冲突(某方法在一个浏览器中不存在、另一个中已存在),也可能意外重写原生方法。推荐做法是创建自定义类继承原生类型。
原型的问题:
原型模式有两个问题:一是弱化了构造函数传参能力,所有实例默认取得相同的属性值,但这还不是最致命的;二是引用值属性的共享问题,这才是原型的最大问题。
弱化构造函数传参能力的意思是构造函数是空的,创建实例时无法传入不同的初始值。
构造函数模式可以在创建时传参,每个实例有不同的初始值:
1 2 3 4 5 6 7 function Person(name, age) { this.name = name; this.age = age; } let person1 = new Person("Alice", 29); let person2 = new Person("Bob", 30);原型模式的构造函数是空的,属性全定义在原型上:
1 2 3 4 5 6 7 8 9 10 function Person() {} // 没有参数 Person.prototype = { name: "Alice", // 所有实例共享同一个值 age: 29, ... }; let person1 = new Person(); // 没法传参,只能拿到原型的默认值 let person2 = new Person(); // 一样创建时无法做到
new Person("Bob", 30),因为构造函数根本不接收参数。想区分实例,只能在创建之后手动覆写:
1 2 3 let person1 = new Person(); person1.name = "Bob"; // 创建后再改 person1.age = 30;
原型上的属性在实例间共享,函数类型没问题,原始值属性也还好(可以通过实例同名属性遮盖)。但引用值属性就出问题了,实例修改的是同一个引用,所有实例都会受影响:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Person() {}
Person.prototype = {
constructor: Person,
name: "Alice",
age: 29,
job: "Software Engineer",
friends: ["Shelby", "Court"],
sayName() {
console.log(this.name);
}
};
let person1 = new Person();
let person2 = new Person();
person1.friends.push("Van");
console.log(person1.friends); // "Shelby,Court,Van"
console.log(person2.friends); // "Shelby,Court,Van",person2也被改了
console.log(person1.friends === person2.friends); // true,指向同一个数组
friends 存在于 Person.prototype 上,person1 和 person2 指向同一个数组。person1.push 修改了那个数组,person2 也看到了变化。除非有意在实例间共享引用值,否则不同实例应该有属于自己的属性副本。这就是实际开发中通常不单独使用原型模式的原因。
原型继承:
继承是面向对象编程中讨论最多的话题。
很多面向对象语言支持两种继承:接口继承和实现继承。前者只继承方法签名,后者继承实际的方法。
接口继承在ECMAScript中是不可能的,因为函数没有签名。实现继承是ECMAScript唯一支持的继承方式,而这主要是通过原型链实现的。
原型链:
ECMA-262 把原型链定义为 ECMAScript 的主要继承方式,核心思想是通过原型继承多个引用类型的属性和方法。
先回顾构造函数、原型和实例的关系:构造函数有 prototype 指向原型,原型有 constructor 指回构造函数,实例有 [[Prototype]] 指向原型。这是一层关系。
关键问题来了,如果原型本身是另一个类型的实例呢? 那这个原型就有一个 [[Prototype]] 指向另一个原型,而那个原型又指向另一个构造函数。实例 → 原型 → 原型的原型,层层向上,这就是原型链。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function SuperType() {
this.property = true;
}
SuperType.prototype.getSuperValue = function() {
return this.property;
};
function SubType() {
this.subproperty = false;
}
// 继承 SuperType,关键一步
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function() {
return this.subproperty;
};
let instance = new SubType();
console.log(instance.getSuperValue()); // true
关键一步是 SubType.prototype = new SuperType():用 SuperType 的实例替换了 SubType 原来的原型。这样一来,SubType.prototype 既是 SuperType 的实例(拥有 property 属性),其 [[Prototype]] 又指向 SuperType.prototype(拥有 getSuperValue 方法)。之后再给 SubType.prototype 添加新方法,注意必须在替换原型之后添加,否则会被重写覆盖。
搜索 instance.getSuperValue() 的过程:实例上没有 → SubType.prototype(SuperType 实例)上没有 → SuperType.prototype 上找到了。
这正是原型链在起作用,逐层向上搜索,直到找到属性或到达链的末端。函数及其对应的原型之间的关系。
原型链的本质就是原型的原型,通过让一个构造函数的原型等于另一个类型的实例,从而沿着
[[Prototype]]逐层向上搜索,实现继承。
下图展示了子类的实例与两个构造函数及其对应的原型之间的关系。
继承的关键在于 SubType 用 SuperType 的实例替换了默认原型。作为 SuperType 的实例,SubType.prototype 的 [[Prototype]] 自然指向 SuperType.prototype,链条由此形成:
1
instance → SubType.prototype(SuperType实例) → SuperType.prototype
注意属性分布的位置:getSuperValue() 在 SuperType.prototype 上,而 property 在 SubType.prototype 上。因为 getSuperValue 是原型方法,定义在 SuperType.prototype 上;property 是实例属性,而 SubType.prototype 现在就是 SuperType 的实例,所以 property 存储在它上面。
还有一个副作用:SubType.prototype 的 constructor 被重写为指向 SuperType,因此 instance.constructor 也指向 SuperType。
原型链扩展了搜索机制:
读取实例属性时,先搜实例,再搜原型,原型链让搜索可以继续向上,搜原型的原型。以 instance.getSuperValue() 为例,经过三步搜索,搜索会一直持续到原型链的末端才停止:
- instance,没找到
- SubType.prototype,没找到
- SuperType.prototype,找到了
默认原型:
到现在为止,我们一直未提及原型链的最后一环。默认情况下,所有引用类型都继承自Object,这也是通过原型链实现的。
任何函数的默认原型都是一个Object的实例,这意味着这个实例有一个内部指针指向Object.prototype。这也是为什么自定义类型能够继承包括toString()、valueOf()在内的所有默认方法的原因。因此前面的例子还有额外一层继承关系,图8-5展示了完整的原型链。
SubType继承SuperType,而SuperType继承Object。在调用instance.toString()时,实际上调用的是保存在Object.prototype上的方法。
原型与继承关系:
原型与实例的关系可以通过两种方式来确定。第一种方式是使用instanceof操作符,如果一个实例的原型链中出现过相应的构造函数,则instanceof返回true。如下例所示:
1
2
3
console.log(instance instanceof Object); // true
console.log(instance instanceof SuperType); // true
console.log(instance instanceof SubType); // true
严格来讲,instance是Object、SuperType和SubType的实例,因为instance的原型链中包含这些构造函数的原型。结果就是instanceof对所有这些构造函数都返回true。
确定这种关系的第二种方式是使用isPrototypeOf()方法。原型链中的每个原型都可以调用这个方法,如下例所示,只要原型链中包含这个原型,这个方法就返回true:
1
2
3
console.log(Object.prototype.isPrototypeOf(instance)); // true
console.log(SuperType.prototype.isPrototypeOf(instance)); // true
console.log(SubType.prototype.isPrototypeOf(instance)); // true
关于方法:
子类需要覆盖父类方法或添加新方法时,必须在原型赋值之后再定义,否则会被重写覆盖掉:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 继承 SuperType
SubType.prototype = new SuperType();
// 添加新方法,必须在原型赋值之后
SubType.prototype.getSubValue = function() {
return this.subproperty;
};
// 覆盖父类方法,同样必须在原型赋值之后
SubType.prototype.getSuperValue = function() {
return false;
};
let instance = new SubType();
console.log(instance.getSuperValue()); // false,调用的是覆盖后的版本
getSubValue() 是 SubType 的新方法,getSuperValue() 遮盖了原型链上父类的同名方法。注意 SuperType 的实例不受影响,仍然调用最初的版本。
SubType.prototype = new SuperType()是重写整个原型,之后添加的方法才能保留在新的原型对象上。写在赋值之前的方法会随旧原型一起丢失。
原型链的问题:
原型链有两个问题,这也是它基本不会被单独使用的原因。
问题一:引用值共享
之前讲过,原型上的引用值会在所有实例间共享,这正是属性通常定义在构造函数中而非原型上的原因。原型链继承会让这个问题更严重:SubType.prototype = new SuperType() 让原型变成了另一个类型的实例,原先的实例属性摇身一变成为了原型属性:
1
2
3
4
5
6
7
8
9
10
11
12
13
function SuperType() {
this.colors = ["red", "blue", "green"];
}
function SubType() {}
SubType.prototype = new SuperType();
let instance1 = new SubType();
instance1.colors.push("black");
let instance2 = new SubType();
console.log(instance2.colors); // "red,blue,green,black"——也被改了
SuperType 构造函数里的 this.colors 本来是每个实例各自拥有的,但 SubType.prototype 现在就是那个 SuperType 实例,colors 就变成了原型属性,所有 SubType 实例共享同一个数组。
问题二:不能向父类构造函数传参
子类型实例化时无法给父类型的构造函数传参,因为继承是通过 SubType.prototype = new SuperType() 实现的,没有办法在不影响所有实例的情况下把参数传进去。
对于原型链指向的补充:
对于原型链部分的例子,SubType 和 SuperType,这两个函数各自具有自己的原型,然后通过new一个实例,让一个函数的原型指向了另一个函数的原型(也就是让一个函数的原型等于另一个函数的实例),但不能让一个函数的原型指向另一个函数,而是只能成为另一个函数的实例。
如果直接指向原型会怎样:
1
SubType.prototype = SuperType.prototype;
这意味着 SubType 和 SuperType 共享同一个原型对象。任何对 SubType.prototype 的修改都会直接影响 SuperType.prototype:
1
2
3
4
5
6
SubType.prototype.getSubValue = function() {
return this.subproperty;
};
// 问题来了:SuperType.prototype 上也多了这个方法
// SuperType 的实例也能调用 getSubValue(),这不合理
而且 instanceof 也会失效,SubType 的实例会被认为同时是 SuperType 的直接实例,因为它们的原型是同一个对象。
为什么必须是实例:
new SuperType() 创建的是一个中间对象:
- 有自己的
[[Prototype]]指向 SuperType.prototype → 能访问父类原型方法 - 有自己的实例属性(如 property)→ 独立于 SuperType.prototype
- 是一个独立的对象 → 对它添加方法不会污染 SuperType.prototype
1
2
3
4
5
6
7
8
9
10
SubType.prototype = new SuperType() 产生的中间对象:
SubType.prototype (中间对象)
├── [[Prototype]] → SuperType.prototype (链到父原型)
├── property: true (父类实例属性)
└── getSubValue: function() {...} (子类添加的方法)
SuperType.prototype (不受影响)
├── constructor: SuperType
└── getSuperValue: function() {...}
直接指向原型会让两个类型共享同一个对象,互相污染;用实例做中间层,既建立了链式关系,又保持了各层原型的独立性。
理解原型链的关键在于是一个函数的原型指向了另一个函数的原型,而不是一个函数的原型对象成为了另一个函数的实例。
换句话说,原型链的核心是 [[Prototype]] 指针把对象串成一条链,属性搜索沿链向上查找,这才是继承的机制。
让一个函数的原型成为另一个函数的实例只是建立这条链的手段,不是目的,而且是个有缺陷的手段,它顺带把父类的实例属性也带下来了,这就是之前说的引用值共享问题。
如果只想要链条,不要实例属性的副作用,用 Object.create() 更简洁干净:
1
2
3
4
5
// 传统方式,建立链,但带上了实例属性(副作用)
SubType.prototype = new SuperType();
// 另一种方式,只建立链,不带实例属性
SubType.prototype = Object.create(SuperType.prototype);
Object.create(SuperType.prototype) 创建一个新对象,其 [[Prototype]] 指向 SuperType.prototype,效果和 new SuperType() 一样建立了链条,但不会执行 SuperType 构造函数,不会带上那些实例属性。





