深入JavaScript的原型链系统(译).md

目录#

简介#

本文翻译自 JavaScript - The prototype chain in depth

本文主要阐述了JavaScript的 原型链继承 的概念;你将了解到:

  1. 一个对象是如何链接到另一个对象的
  2. 继承 是如何实现的
  3. 这些对象之间的关系是如何的

我们的目标#

作为开发者,我们的编写代码时的主要任务通常是处理数据:将一些相关联的数据存储在某个地方作为数据集,再对数据集执行操作。

将操作和与数据集绑定在一起,能让编程思路更为清晰;例如一个 Player 对象:

1
2
3
4
{
userName: 'sag1v',
score: '700'
}

如何执行一个操作来改变其中 score 的值?应该如何声明一个 setScore 函数方法?

对象#

我们通常使用对象来存储一些有关联的数据,类似于将一些相关联的物体放进一个容器中;

在深入解析之前,首先要了解什么是 Object(对象) 以及创建对象的几种方法。

Object Literal 对象字面量#

1
2
3
4
5
6
7
const player1 = {
userName: 'sag1v',
score: '700',
setScore(newScore){
player1.score = newScore;
}
}

以上这种初始化一个对象的写法被称为:对象字面量;每当执行这样的语句,就会创建一个新的对象。

通过 . 操作符或 [] 操作符,也都可以创建或访问对象:

1
2
3
4
5
6
7
8
9
const player1 = {
name: 'Sagiv',
}

player1.userName = 'sag1v';
player1['score'] = 700;
player1.setScore = function(newScore) {
player1.score = newScore;
}

Object.create#

另一种创建对象的方法是使用 Object.create 函数:

1
2
3
4
5
6
const player1 = Object.create(null)
player1.userName = 'sag1v';
player1['score'] = 700;
player1.setScore = function(newScore) {
player1.score = newScore;
}

Object.create 总是会返回一个空对象,但如果调用时传入一个对象,会得到一个“特别”的空对象(在后文说明其“特别”之处);

更加自动化#

每次都通过像上文代码那样去手动生成一个对象,并不划算,所以此时需要一个函数来帮助我们创建 Player 对象。

Factory Functions 工厂函数#

1
2
3
4
5
6
7
8
9
10
11
12
function createPlayer(userName, score) {
const newPlayer = {
userName,
score,
setScore(newScore) {
newPlayer.score = newScore;
}
}
return newPlayer;
}

const player1 = createPlayer('sag1v', 700);

这种创建方法在设计模式中被称为 “工厂模式”;就像工厂中输出物体的传送带一样,将相关的参数传入 createPlayer 函数,就能得到所需要的对象。

当执行两次 createPlayer 函数会发生什么呢?

1
2
const player1 = createPlayer('sag1v', 700);
const player2 = createPlayer('sarah', 900);

生成了如下两个对象

1
2
3
4
5
6
7
8
9
10
11
{
userName: 'sag1v',
score: 700,
setScore: ƒ
}

{
userName: 'sarah',
score: 900,
setScore: ƒ
}

我们可以观察到存在一些重复的部分:在每个对象实例中都存在相同的 setScore 方法,这违反了编程中的 D.R.Y(Don’t Repeat Yourself) 法则。

那如何做到只在某处只存储一次,但仍然能够通过函数实例来执行 setScore 呢?(形如:player1.setScore(1000))。

OLOO - Objects Linked To Other Objects 对象委托#

重新回到 Object.create 这个话题,它总是会创建一个空对象,而它的“特殊”之处就在于当我们将一个对象作为它的参数时,会得到一个有“特殊”属性的空对象,如码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const playerFunctions = {
setScore(newScore) {
this.score = newScore;
}
}

function createPlayer(userName, score) {
const newPlayer = Object.create(playerFunctions);
newPlayer.userName = userName;
newPlayer.score = score;
return newPlayer;
}

const player1 = createPlayer('sag1v', 700);
const player2 = createPlayer('sarah', 900);

上述代码为对象委托,它与工厂模式最为重要的区别在于:对象委托模式所创建的对象自身内部并没有 setScore 方法, 但这个对象链接到了 playerFunctions 方法。

原来,任意一个JavaScript对象(称为对象A)都有一个称为 __proto__ 的隐藏属性,并且,这个属性的值如果是一个对象(称为对象B),JavaScript解释器会将对象B的所有属性,也当作原对象A的属性;换句话来说,对象A能通过 __proto__ 像访问自己的属性一样访问到对象B的属;

⚠️ Note
不要混淆了 __proto__prototypeprototype 只存在于函数中;而 __proto__只存在于对象中(译者注:而函数是个特殊的对象,所以函数也有 __proto__

以下是语义更易于理解的代码示例:

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
const playerFunctions = {
setScore(newScore) {
this.score = newScore;
}
}

function createPlayer(userName, score) {
const newPlayer = Object.create(playerFunctions);
newPlayer.userName = userName;
newPlayer.score = score;
return newPlayer;
}

const player1 = createPlayer('sag1v', 700);
const player2 = createPlayer('sarah', 900);

console.log(player1)
console.log(player2)

// 输出的内容:

player1: {
userName: 'sag1v',
score: 700,
__proto__: playerFunctions
}

player2: {
userName: 'sarah',
score: 900,
__proto__: playerFunctions
}

这意味着, player1player2 都可以借助 __proto__ 的特性, 直接访问到 playerFunctions 里的属性, 也就是说, 它们都可以执行 setScore:

1
2
player1.setScore(1000);
player2.setScore(2000);

现在我们达到了在工厂模式章节的末尾所说的目标, 一个遵守了D.R.Y法则, 同时能访问到数据和方法的对象。

简单总结本节内容来看, 创建一个存在原型的对象, 我们需要做以下步骤:

  1. 创建一个 对象B, 包含处理 对象A 数据的方法
  2. 需要通过 Object.create 创建 对象A, 同时将 对象B 链接到 对象A__proto__
  3. 我们需要手动给新对象 对象A 初始化各种属性(即数据)

如何让JavaScript本身帮我们做以上这几个步骤中的一些部分呢?

new 操作符与构造函数#

我们使用 new 操作符即可回答上一问, 但在开始阐述前, 让我们明确一下: 什么是函数?

1
2
3
4
5
6
7
8
9
10
function double(num) {
return num * 2;
}

double.someProp = 'Hi there!';

double(5); // 10
double.someProp // Hi there!

double.prototype // {}

函数可以由我们定义, 可以通过括号 () 调用。但从上面的例子, 我们可以访问函数的属性, 也可以为函数创建属性, 看上去和普通的对象非常类似, 所以函数可以被当作是一个特殊的对象。

prototype 属性#

所有的函数都有 prototype 属性(除了箭头函数), 同时重申一下这个小Tips:

Not __proto__ or [[Prototype]], but prototype.

再看回到 new 操作符和构造函数;

下面的例子展示了编写一个构造函数和使用 new 操作符:

⚠️ 如果你还没有100%确定你理解了 this 关键字, 你应该去读一读 JavaScript - The “this” key word in depth
(译者注: this 的值到底是什么?JS 的 new 到底是干什么的? 推一下这两篇)

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
function Player(userName, score){
this.userName = userName;
this.score = score;
}

Player.prototype.setScore = function(newScore){
this.score = newScore;
}

const player1 = new Player('sag1v', 700);
const player2 = new Player('sarah', 900);

console.log(player1)
console.log(player2)

// 输出的内容:

Player {
userName: "sag1v",
score: 700,
__proto__: Player.prototype
}

Player {
userName: "sarah",
score: 900,
__proto__: Player.prototype
}

逐行分析一下代码#

我们通过 new 操作符执行了 Player 函数 (提示: 这里将createPlayer重命名为Player仅仅是因为这样符合开发者的约定) , 这也标志着 Player 函数是一个应该通过 new 操作符执行的 构造函数

当通过 new 操作符执行 构造函数, JavaScript帮我们做了这四件事:

  1. 创建一个新的临时对象
  2. 将这个临时对象绑定到 this上下文
  3. 帮你绑定原型: 临时对象的 __proto__ 指向构造函数的 prototype (即 Playerprototype)
  4. 返回这个临时对象

如果要我们自己来实现上述步骤, 代码看起来会像下面这样:

1
2
3
4
5
6
7
8
9
function Player(userName, score){
this = {} // ⚠️ done by JavaScript
this.__proto__ = Player.prototype // ⚠️ done by JavaScript

this.userName = userName;
this.score = score;

return this // ⚠️ done by JavaScript
}

其中第三行代码:

将临时对象的 __proto__ 指向构造函数的 prototype

这代表着我们可以把任意函数都挂载到 Player.prototype, 这样新的对象就可以访问到这些函数, 这一步也就是示例中的这行代码:

1
2
3
Player.prototype.setScore = function(newScore){
this.score = newScore;
}

这就是通过构造函数来实现原型链的链接;

除此之外, 下面代码能确保构造函数是通过 new 操作符来调用的:

1
2
3
4
5
6
7
8
9
10
11
function Player(username, score){

if(!(this instanceof Player)){
throw new Error('Player must be called with new')
}

// ES2015 syntax
if(!new.target){
throw new Error('Player must be called with new')
}
}

Class#

如果你不喜欢手写工厂模式, 也不中意构造函数, 但是还是想使用 new 操作符, JavaScript 提供了 类(class) (从 ES2015 起), 当然, 实质上只是一种构造函数的语法糖, 它与其他语言里传统意义上的 不一样的地方在于: 它还有着 原型链继承

引用自 MDN

JavaScript classes, introduced in ECMAScript 2015, are primarily syntactical sugar over JavaScript’s existing prototype-based inheritance. The class syntax does not introduce a new object-oriented inheritance model to JavaScript.

让我们一步步把构造函数转化成 :

声明一个类#

通过 class 关键字来声明 (此处命名和前文中的构造函数一致)

1
2
3
class Player {

}

写一个constructor#

将前文构造函数的内容移植到 constructor 函数内:

1
2
3
4
5
6
class Player {
constructor(userName, score) {
this.userName = userName;
this.score = score;
}
}

添加函数#

将前文 Player.prototype 里的函数都移植到 内:

1
2
3
4
5
6
7
8
9
10
class Player {
constructor(userName, score) {
this.userName = userName;
this.score = score;
}

setScore(newScore) {
this.score = newScore;
}
}

以下为完整代码:

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
class Player {
constructor(userName, score) {
this.userName = userName;
this.score = score;
}

setScore(newScore) {
this.score = newScore;
}
}

const player1 = new Player('sag1v', 700);
const player2 = new Player('sarah', 900);

console.log(player1)
console.log(player2)

// 输出与之前的构造函数一致

Player {
userName: "sag1v",
score: 700,
__proto__: Player.prototype
}

Player {
userName: "sarah",
score: 900,
__proto__: Player.prototype
}

可以看到, 在原型链上所执行的操作与构造函数并无差别, 仅仅是语法上的区别, 同时 自带了是否由 new 调用的验证。

继承#

如果想要定义一个特殊的 Player, 他是那种氪金玩家, 解锁了普通 Player 所没有的特性, 比如能修改自己的用户名, 那应该怎么做呢?

我们可以将我们的需求列出来:

  1. 需要一个普通用户, 他有着 userName, score 属性和 setScore 方法;
  2. 我们也需要一个氪金用户, 他除了普通玩家有的东西外 (即为 继承 了普通玩家), 还额外有一个 setUserName方法, 但普通玩家他不充钱就不配有改名的能力😟

在深入其中前, 先来看一看一直在说的原型链是长什么样的:

1
2
3
4
5
6
7
8
9
10
11
function double(num){
return num * 2;
}

double.toString() // where is this method coming from?

Function.prototype // {toString: f, call: f, bind: f}

double.hasOwnProperty('name') // where is this method coming from?

Function.prototype.__proto__ // -> Object.prototype {hasOwnProperty: f}

通过前面的内容, 我们得知了当一个属性在对象本身中不存在时, JavaScript解释器会到对象的 __proto__ 中找这个属性, 但如果 __proto__ 指向的对象也没有这个属性怎么办呢? 先前我们说明过, 所有对象都有 __proto__ 属性, 只要这个对象的 __proto__ 的指向不为空, 解释器就会一层一层地找下去, 直到最后一个对象的 __proto__ 指向为空 (Object.prototype.__proto__ 为空, 大部分都是找到这里为止)

有一些绕口, 所以这里直接通过两个例子来解释一下:

1
double.toString()
  1. 首先, double 本身不包含 toString 方法 ✖️;
  2. double.__proto__ 中找 toString;
  3. double.__proto__ 指向的是 Function.prototype, 它包含了 toString 方法, 查找成功 ✔️;
1
double.hasOwnProperty('name')
  1. double 没有 hasOwnProperty 方法 ✖️;
  2. double.__proto__ 找;
  3. double.__proto__ 指向的是 Function.prototype;
  4. Function.prototype 没有 hasOwnProperty 方法 ✖️;
  5. Function.prototype.__proto__ 中找;
  6. Function.prototype.__proto__ 指向的是 Object.prototype;
  7. Object.prototype 包含了 hasOwnProperty 方法, 查找成功 ✔️;

原文作者po了一个Gif来表现这个过程:

原型链查找图示

至此, 创建一个氪金玩家的思路在眼前初步形成了, 我们再通过 对象委托模式构造函数 来实现这个需求, 同时考察一下各种模式的特性;

现在开始深入探索 继承 吧 💪

对象委托 - 继承#

对象委托模式的氪金玩家模型的示例代码如下:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
const playerFunctions = {
setScore(newScore) {
this.score = newScore;
}
}

function createPlayer(userName, score) {
const newPlayer = Object.create(playerFunctions);
newPlayer.userName = userName;
newPlayer.score = score;
return newPlayer;
}

const paidPlayerFunctions = {
setUserName(newName) {
this.userName = newName;
}
}

// link paidPlayerFunctions object to createPlayer object
Object.setPrototypeOf(paidPlayerFunctions, playerFunctions);

function createPaidPlayer(userName, score, balance) {
const paidPlayer = createPlayer(name, score);
// we need to change the pointer here
Object.setPrototypeOf(paidPlayer, paidPlayerFunctions);
paidPlayer.balance = balance;
return paidPlayer
}

const player1 = createPlayer('sag1v', 700);
const paidPlayer = createPaidPlayer('sag1v', 700, 5);

console.log(player1)
console.log(paidPlayer)

// 输出内容为:

player1 {
userName: "sag1v",
score: 700,
__proto__: playerFunctions {
setScore: ƒ
}
}

paidPlayer {
userName: "sarah",
score: 900,
balance: 5,
__proto__: paidPlayerFunctions {
setUserName: ƒ,
__proto__: playerFunctions {
setScore: ƒ
}
}
}

这里 createPlayer 的实现没有任何变化, 而 createPaidPlayer 的实现需要一些小技巧。

我们使用 createPlayer 来作为 createPaidPlayer 里创建普通用户的内容, 但这样氪金玩家的 __proto__ 就错误地指向了普通玩家的 __proto__, 所以我们需要使用 Object.setPrototypeOf 方法来修复它的指向。我们新建了一个对象, 作为氪金玩家修复后需要指向 __proto__ 对象, 然后将其作为参数传入 Object.setPrototypeOf 来进行修复; 在示例中, 这个对象就是 paidPlayerFunctions

但此时破坏了氪金玩家与 playerFunctions 的链接, 也代表着失去了 setScore 能力; 所以需要重新通过 Object.setPrototypeOf 修复一下 paidPlayerFunctionsplayerFunctions 的链接。

对象委托模式中一个两层的原型链的代码以及显得复杂, 四五层的原型链的复杂程度可想而知。

构造函数 - 继承#

现在来试试通过构造函数来实现继承:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
function Player(userName, score) {
this.userName = userName;
this.score = score;
}

Player.prototype.setScore = function(newScore) {
this.score = newScore;
}


function PaidPlayer(userName, score, balance) {
this.balance = balance;
/* 这里不通过 "new" 关键字来调用 "Player"
我们使用 "call" 方法来替代, 这样能改变 this 的指向
并且能给各个属性赋值*/
Player.call(this, userName, score);
}

PaidPlayer.prototype.setUserName = function(newName) {
this.userName = newName;
}

// 设置Player.prototype 为 PaidPlayer.prototype 的原型
Object.setPrototypeOf(PaidPlayer.prototype, Player.prototype);


const player1 = new Player('sag1v', 700);
const paidPlayer = new PaidPlayer('sarah', 900, 5);

console.log(player1)
console.log(paidPlayer)

// 输出如下:

Player {
userName: "sag1v",
score: 700,
__proto__: Player.prototype {
setScore: ƒ
}
}

PaidPlayer {
userName: "sarah",
score: 900,
balance: 5,
__proto__: PaidPlayer.prototype:{
setUserName: ƒ,
__proto__: Player.prototype {
setScore: ƒ
}
}
}

如果通过工厂模式, 也能得到相同的结果, 而且有些内容由 new 帮我们做了, 这能节省几行代码, 但这也会带来新的挑战。

首先第一个挑战就是: 我们通过使用 Player 函数来表现初始化 Player 的逻辑; 但这里的实现却不是使用 new 关键字 (与我们的直觉相悖), 示例里使用了 .call 方法来调用, 这样做的优点是能改变 Player 内this的指向, 以便于对象属性的初始化, 缺点是 Player 不作为构造函数后, 不会自动创建新对象, 也不会自动绑定 this

1
2
3
4
function PaidPlayer(userName, score, balance) {
this.balance = balance;
Player.call(this, userName, score);
}

这里使用 Player 时, 其内部的 this 指向了 PaidPlayer;

另一个挑战是, 需要将 PaidPlayer 所返回的实例, 链接到 Player 的实例上, 我们使用 Object.setPrototypeOfPlayer.prototype 设置为 PaidPlayer.prototype 的原型;

1
Object.setPrototypeOf(PaidPlayer.prototype, Player.prototype);

如你所见,JavaScript解释器为我们做的事情越多,我们需要编写的代码就越少,但是随着抽象程度的增加,我们理解JavaScript解释器中正在发生的事情会更困难一些。

类 - 继承#

使用类,抽象程度能变得更高,这代表着更少的代码:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Player {
constructor(userName, score) {
this.userName = userName;
this.score = score;
}

setScore(newScore) {
this.score = newScore;
}
}

class PaidPlayer extends Player {
constructor(userName, score, balance) {
super(userName, score);
this.balance = balance;
}

setUserName(newName) {
this.userName = newName;
}
}



const player1 = new Player('sag1v', 700);
const paidPlayer = new PaidPlayer('sarah', 900, 5);

console.log(player1)
console.log(paidPlayer)

// 输出如下:

Player {
userName: "sag1v",
score: 700,
__proto__: Player.prototype {
setScore: ƒ
}
}

PaidPlayer {
userName: "sarah",
score: 900,
balance: 5,
__proto__: PaidPlayer.prototype:{
setUserName: ƒ,
__proto__: Player.prototype {
setScore: ƒ
}
}
}

由上可见, 类只是构造函数的一种语法糖🤔

引用自文档:

JavaScript classes, introduced in ECMAScript 2015, are primarily syntactical sugar over JavaScript’s existing prototype-based inheritance…

当使用 extends 关键字时, 为什么需要 super 函数呢?

回忆一下构造函数章节的这一行”奇怪”代码:

1
Player.call(this, userName, score)

嗯, super(userName, score) 就是在模拟这行代码。

我们描述的更准确一些: 它使用了ES2015引入的新功能 Reflect.construct

引用自文档:

The static Reflect.construct() method acts like the new operator, but as a function. It is equivalent to calling new target(…args). It gives also the added option to specify a different prototype.

由此以来, 我们就不需要手动去”hack”构造函数了, 而在JavaScript解释器中, super 是通过 Reflect.construct 实现的。同时有一条重要的提示: 当 extends 一个类时, 在 constructor 里, 在 super 函数执行前不能使用 this, 因为此时的 this 还未初始化。

1
2
3
4
5
6
7
8
9
10
class PaidPlayer extends Player {
constructor(userName, score, balance) {
super(userName, score);
this.balance = balance;
}

setUserName(newName) {
this.userName = newName;
}
}

总结#

我们学习了几种 链接对象/数据赋值/绑定逻辑 并将其集中到一起 的方法, 我们也了解到了 JavaScript/原型链/多层原型链 的 “继承”。

经过各种例子的再三体会,越是抽象,我们就会得到更多的 “东西”, 越少的代码,但这使我们更难跟踪代码的变化。

每一种模式都有其有点和缺点:

  • Object.create 需要写更多的代码, 在多层继承中显得复杂且无聊, 但能得到对象上更细粒度的控制;
  • 构造函数能通过JavaScript自动赋值, 但代码可能会有些反常, 还需要对调用是否通过 new 的验证, 否则会遇到一些奇怪的Bug, 多层继承同样不是很乐观;
  • 使用类,我们可以获得更简洁的语法,并有了 new 调用的内置检查。当 “继承” 时,类由于其语法糖特性最吸引人,只需使用 extended 关键字并调用 super(),而不会像其他方法那样陷入循环。语法也更接近于其他语言,并且比较容易学习。而Javascript的类与其他语言中的类非常不同,它仍然使用旧的“原型继承”,上面有很多抽象层, 这样应该是它的缺点了吧。

译者注#

原作者的更多文章请访问 debuggr.io

关于原型链, 本文原作者的心智模型与 工业聚 - 深入理解 JavaScript 原型 所述基本是不同的, 建议同时参阅工业聚的文章, 来从一个更广的视角和更细节的实现来看待和学习原型链。