继承与原型链

在编程中,继承是指将特性从父代传递给子代,以便新代码可以重用并基于现有代码的特性进行构建。JavaScript 使用对象实现继承。每个对象都有一条链接到另一个称作原型的对象的内部链。该原型对象有自己的原型,依此类推,直到原型是 null 的对象。根据定义,null 没有原型,并作为这条原型链中最后的一环。在运行时修改原型链的任何成员、甚至是换掉原型都是可能的,所以像静态分派这样的概念在 JavaScript 中不存在。

对于有基于类的语言(如 Java 或 C++)经验的开发者来说,JavaScript 有些令人困惑——因为它是动态的并且没有静态类型。尽管这个困惑通常被认为是 JavaScript 的弱点之一,但是原型继承模型实际上比类式模型更强大。例如,在原型模型的基础上构建类式模型(即的实现方式)相当简单。

尽管类现在被广泛使用并成为 JavaScript 中新的范式,但是类并没有引入新的继承模式。尽管类抽象掉了大部分的原型机制,但是理解原型的底层工作机制仍然十分有用。

基于原型链的继承

继承属性

JavaScript 对象是动态的属性(称为自有属性)“包”。JavaScript 对象有一条指向原型对象的链。当试图访问对象的属性时,不仅在该对象上查找属性,还会在该对象的原型上查找属性,以及原型的原型,依此类推,直到找到一个名字匹配的属性或到达原型链的末尾。

备注:根据 ECMAScript 标准,符号 someObject.[[Prototype]] 用于指定 someObject 的原型。使用 Object.getPrototypeOf()Object.setPrototypeOf() 函数分别访问和修改 [[Prototype]] 内部插槽。这与 JavaScript 访问器 __proto__ 是等价的,后者是非标准的,但许多 JavaScript 引擎实际上实现了它。为了保持简洁和避免困惑,在我们的表示法中,我们会避免使用 obj.__proto__,而是使用 obj.[[Prototype]]。其对应于 Object.getPrototypeOf(obj)

不应将它与函数的 func.prototype 属性弄混,后者表明的是指定函数作为构造函数时创建的所有对象实例[[Prototype]]。我们将在后面的小节中讨论构造函数的 prototype 属性。

有几种可以指定对象的 [[Prototype]] 的方法,这些方法将在后面的小节中列出。现在,我们将使用 __proto__ 语法进行说明。值得注意的是,{ __proto__: ... } 语法与 obj.__proto__ 访问器不同:前者是标准且未被弃用的。

在像 { a: 1, b: 2, __proto__: c } 这样的对象字面量中,值 c(其必须为 null 或另一个对象)将变成字面量所表示的对象的 [[Prototype]],而其他像 ab 这样的键将变成对象的自有属性。这种语法读起来非常自然,因为 [[Prototype]] 只是对象的“内部属性”。

下面演示当尝试访问属性时会发生什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const o = {
a: 1,
b: 2,
// __proto__ 设置了 [[Prototype]]。在这里它被指定为另一个对象字面量。
__proto__: {
b: 3,
c: 4,
},
};

// o.[[Prototype]] 具有属性 b 和 c。
// o.[[Prototype]].[[Prototype]] 是 Object.prototype(我们会在下文解释其含义)。
// 最后,o.[[Prototype]].[[Prototype]].[[Prototype]] 是 null。
// 这是原型链的末尾,
// 因为根据定义,null 没有 [[Prototype]]。
// 因此,完整的原型链看起来像这样:
// { a: 1, b: 2 } ---> { b: 3, c: 4 } ---> Object.prototype ---> null

console.log(o.a); // 1
// o 上有自有属性“a”吗?有,且其值为 1。

console.log(o.b); // 2
// o 上有自有属性“b”吗?有,且其值为 2。
// 原型也有“b”属性,但其没有被访问。
// 这被称为属性遮蔽(Property Shadowing)

console.log(o.c); // 4
// o 上有自有属性“c”吗?没有,检查其原型。
// o.[[Prototype]] 上有自有属性“c”吗?有,其值为 4。

console.log(o.d); // undefined
// o 上有自有属性“d”吗?没有,检查其原型。
// o.[[Prototype]] 上有自有属性“d”吗?没有,检查其原型。
// o.[[Prototype]].[[Prototype]] 是 Object.prototype 且
// 其默认没有“d”属性,检查其原型。
// o.[[Prototype]].[[Prototype]].[[Prototype]] 为 null,停止搜索,
// 未找到该属性,返回 undefined。

给对象设置属性会创建自有属性。获取和设置行为规则的唯一例外是当它被 getter 或 setter 拦截时。

同理,你可以创建更长的原型链,并在所有的原型链上查找属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const o = {
a: 1,
b: 2,
// __proto__ 设置了 [[Prototype]]。在这里它被指定为另一个对象字面量。
__proto__: {
b: 3,
c: 4,
__proto__: {
d: 5,
},
},
};

// { a: 1, b: 2 } ---> { b: 3, c: 4 } ---> { d: 5 } ---> Object.prototype ---> null

console.log(o.d); // 5

继承“方法”

JavaScript 中定义“方法”的形式和基于类的语言定义方法的形式不同。在 JavaScript 中,对象可以以属性的形式添加函数。继承的函数与其他属性一样,包括属性遮蔽(在这种情况下,是一种方法重写的形式)。

当执行继承的函数时,this 值指向继承对象,而不是将该函数作为其自有属性的原型对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const parent = {
value: 2,
method() {
return this.value + 1;
},
};

console.log(parent.method()); // 3
// 当调用 parent.method 时,“this”指向了 parent

// child 是一个继承了 parent 的对象
const child = {
__proto__: parent,
};
console.log(child.method()); // 3
// 调用 child.method 时,“this”指向了 child。
// 又因为 child 继承的是 parent 的方法,
// 首先在 child 上寻找属性“value”。
// 然而,因为 child 没有名为“value”的自有属性,
// 该属性会在 [[Prototype]] 上被找到,即 parent.value。

child.value = 4; // 将 child 上的属性“value”赋值为 4。
// 这会遮蔽 parent 上的“value”属性。
// child 对象现在看起来是这样的:
// { value: 4, __proto__: { value: 2, method: [Function] } }
console.log(child.method()); // 5
// 因为 child 现在拥有“value”属性,“this.value”现在表示 child.value

构造函数

原型的强大之处在于,如果一组属性应该出现在每一个实例上,那我们就可以复用它们——尤其是对于方法。假设我们要创建多个盒子,其中每一个盒子都是一个对象,包含一个可以通过 getValue 函数访问的值。一个简单的实现可能是:

1
2
3
4
5
const boxes = [
{ value: 1, getValue() { return this.value; } },
{ value: 2, getValue() { return this.value; } },
{ value: 3, getValue() { return this.value; } },
];

这是不够好的,因为每一个实例都有自己的,做相同事情的函数属性,这是冗余且不必要的。相反,我们可以将 getValue 移动到所有盒子的 [[Prototype]] 上:

1
2
3
4
5
6
7
8
9
10
11
const boxPrototype = {
getValue() {
return this.value;
},
};

const boxes = [
{ value: 1, __proto__: boxPrototype },
{ value: 2, __proto__: boxPrototype },
{ value: 3, __proto__: boxPrototype },
];

这样,所有盒子的 getValue 方法都会引用相同的函数,降低了内存使用率。但是,为每个对象创建手动绑定 __proto__ 仍旧非常不方便。这时,我们就可以使用构造函数,它会自动为每个构造的对象设置 [[Prototype]]。构造函数是使用 new 调用的函数。

1
2
3
4
5
6
7
8
9
10
11
// 构造函数
function Box(value) {
this.value = value;
}

// 使用 Box() 构造函数创建的所有盒子都将具有的属性
Box.prototype.getValue = function () {
return this.value;
};

const boxes = [new Box(1), new Box(2), new Box(3)];

我们说 new Box(1) 是通过 Box 构造函数创建的一个实例Box.prototype 与我们之前创建的 boxPrototype 并无太大区别——它只是一个普通的对象。通过构造函数创建的每一个实例都会自动将构造函数的 prototype 属性作为其 [[Prototype]]。即,Object.getPrototypeOf(new Box()) === Box.prototypeConstructor.prototype 默认具有一个自有属性:constructor,它引用了构造函数本身。即,Box.prototype.constructor === Box。这允许我们在任何实例中访问原始构造函数。

备注:如果构造函数返回非原始值,则该值将成为 new 表达式的结果。在这种情况下,可能无法正确绑定 [[Prototype]]——但在实践中应该很少发生。

将上面的构造函数重写为:

1
2
3
4
5
6
7
8
9
10
class Box {
constructor(value) {
this.value = value;
}

// 在 Box.prototype 上创建方法
getValue() {
return this.value;
}
}

类是构造函数的语法糖,这意味着你仍然可以修改 Box.prototype 来改变所有实例的行为。然而,由于类被设计为对底层原型机制的抽象,我们将在本教程中使用更轻量级的构造函数语法,以充分展示原型的工作原理。

因为 Box.prototype 引用的对象和所有实例的 [[Prototype]] 是同一个对象,所以我们可以通过改变 Box.prototype 来改变所有实例的行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
function Box(value) {
this.value = value;
}
Box.prototype.getValue = function () {
return this.value;
};
const box = new Box(1);

// 在创建实例后修改 Box.prototype
Box.prototype.getValue = function () {
return this.value + 1;
};
box.getValue(); // 2

有个推论是:重新赋值 Constructor.prototypeConstructor.prototype = ...)是一个不好的主意,原因有两点:

  • 在重新赋值之前创建的实例的 [[Prototype]] 引用的对象与重新赋值之后创建的实例的 [[Prototype]] 引用的对象现在是不同的——改变一个的 [[Prototype]] 不再改变另一个的 [[Prototype]]
  • 除非你手动重新设置 constructor 属性,否则无法再通过 instance.constructor 追踪到构造函数,这可能会破坏用户期望的行为。一些内置操作也会读取 constructor 属性,如果没有设置,它们可能无法按预期工作。

Constructor.prototype 仅在构造实例时有用。它与 Constructor.[[Prototype]] 无关,后者是构造函数的自有原型,即 Function.prototype。也就是说,Object.getPrototypeOf(Constructor) === Function.prototype

字面量的隐式构造函数

JavaScript 中的一些字面量语法会创建隐式设置 [[Prototype]] 的实例。例如:

1
2
3
4
5
6
7
8
9
10
11
// 对象字面量(没有 `__proto__` 键)自动将 `Object.prototype` 作为它们的 `[[Prototype]]`
const object = { a: 1 };
Object.getPrototypeOf(object) === Object.prototype; // true

// 数组字面量自动将 `Array.prototype` 作为它们的 `[[Prototype]]`
const array = [1, 2, 3];
Object.getPrototypeOf(array) === Array.prototype; // true

// 正则表达式字面量自动将 `RegExp.prototype` 作为它们的 `[[Prototype]]`
const regexp = /abc/;
Object.getPrototypeOf(regexp) === RegExp.prototype; // true

我们可以将它们“解糖(de-sugar)”为构造函数形式。

1
2
const array = new Array(1, 2, 3);
const regexp = new RegExp("abc");

例如,像 map() 这样的“数组方法”是仅在 Array.prototype 上定义的方法,而它们又自动在所有数组实例上可用,就是因为这个原因。

构建更长的继承链

Constructor.prototype 属性将成为构造函数实例的 [[Prototype]],包括 Constructor.prototype 自身的 [[Prototype]]。默认情况下,Constructor.prototype 是一个普通对象——即 Object.getPrototypeOf(Constructor.prototype) === Object.prototype。唯一的例外是 Object.prototype 本身,其 [[Prototype]]null——即 Object.getPrototypeOf(Object.prototype) === null。因此,一个典型的构造函数将构建以下原型链:

1
2
3
4
function Constructor() {}

const obj = new Constructor();
// obj ---> Constructor.prototype ---> Object.prototype ---> null

要构建更长的原型链,我们可以通过 Object.setPrototypeOf() 函数设置 Constructor.prototype[[Prototype]]

1
2
3
4
5
6
7
8
function Base() {}
function Derived() {}
// 将 `Derived.prototype` 的 `[[Prototype]]`
// 设置为 `Base.prototype`
Object.setPrototypeOf(Derived.prototype, Base.prototype);

const obj = new Derived();
// obj ---> Derived.prototype ---> Base.prototype ---> Object.prototype ---> null

在类的术语中,这等同于使用 extends 语法。

1
2
3
4
5
class Base {}
class Derived extends Base {}

const obj = new Derived();
// obj ---> Derived.prototype ---> Base.prototype ---> Object.prototype ---> null