目录#
简介#
本文翻译自 JavaScript - The prototype chain in depth
本文主要阐述了JavaScript的 原型链
与 继承
的概念;你将了解到:
- 一个对象是如何链接到另一个对象的
继承
是如何实现的- 这些对象之间的关系是如何的
我们的目标#
作为开发者,我们的编写代码时的主要任务通常是处理数据:将一些相关联的数据存储在某个地方作为数据集,再对数据集执行操作。
将操作和与数据集绑定在一起,能让编程思路更为清晰;例如一个 Player
对象:
1 | { |
如何执行一个操作来改变其中 score
的值?应该如何声明一个 setScore
函数方法?
对象#
我们通常使用对象来存储一些有关联的数据,类似于将一些相关联的物体放进一个容器中;
在深入解析之前,首先要了解什么是 Object(对象)
以及创建对象的几种方法。
Object Literal 对象字面量#
1 | const player1 = { |
以上这种初始化一个对象的写法被称为:对象字面量;每当执行这样的语句,就会创建一个新的对象。
通过 .
操作符或 []
操作符,也都可以创建或访问对象:
1 | const player1 = { |
Object.create#
另一种创建对象的方法是使用 Object.create
函数:
1 | const player1 = Object.create(null) |
Object.create
总是会返回一个空对象,但如果调用时传入一个对象,会得到一个“特别”的空对象(在后文说明其“特别”之处);
更加自动化#
每次都通过像上文代码那样去手动生成一个对象,并不划算,所以此时需要一个函数来帮助我们创建 Player
对象。
Factory Functions 工厂函数#
1 | function createPlayer(userName, score) { |
这种创建方法在设计模式中被称为 “工厂模式”;就像工厂中输出物体的传送带一样,将相关的参数传入 createPlayer
函数,就能得到所需要的对象。
当执行两次 createPlayer
函数会发生什么呢?
1 | const player1 = createPlayer('sag1v', 700); |
生成了如下两个对象
1 | { |
我们可以观察到存在一些重复的部分:在每个对象实例中都存在相同的 setScore
方法,这违反了编程中的 D.R.Y(Don’t Repeat Yourself) 法则。
那如何做到只在某处只存储一次,但仍然能够通过函数实例来执行 setScore
呢?(形如:player1.setScore(1000)
)。
OLOO - Objects Linked To Other Objects 对象委托#
重新回到 Object.create
这个话题,它总是会创建一个空对象,而它的“特殊”之处就在于当我们将一个对象作为它的参数时,会得到一个有“特殊”属性的空对象,如码所示:
1 | const playerFunctions = { |
上述代码为对象委托,它与工厂模式最为重要的区别在于:对象委托模式所创建的对象自身内部并没有 setScore
方法, 但这个对象链接到了 playerFunctions
方法。
原来,任意一个JavaScript对象(称为对象A)都有一个称为 __proto__
的隐藏属性,并且,这个属性的值如果是一个对象(称为对象B),JavaScript解释器会将对象B的所有属性,也当作原对象A的属性;换句话来说,对象A能通过 __proto__
像访问自己的属性一样访问到对象B的属;
⚠️ Note
不要混淆了 __proto__
和 prototype
,prototype
只存在于函数中;而 __proto__
只存在于对象中(译者注:而函数是个特殊的对象,所以函数也有 __proto__
)。
以下是语义更易于理解的代码示例:
1 | const playerFunctions = { |
这意味着, player1
和 player2
都可以借助 __proto__
的特性, 直接访问到 playerFunctions
里的属性, 也就是说, 它们都可以执行 setScore
:
1 | player1.setScore(1000); |
现在我们达到了在工厂模式章节的末尾所说的目标, 一个遵守了D.R.Y法则, 同时能访问到数据和方法的对象。
简单总结本节内容来看, 创建一个存在原型的对象, 我们需要做以下步骤:
- 创建一个 对象B, 包含处理 对象A 数据的方法
- 需要通过
Object.create
创建 对象A, 同时将 对象B 链接到 对象A 的__proto__
上 - 我们需要手动给新对象 对象A 初始化各种属性(即数据)
如何让JavaScript本身帮我们做以上这几个步骤中的一些部分呢?
new
操作符与构造函数#
我们使用 new
操作符即可回答上一问, 但在开始阐述前, 让我们明确一下: 什么是函数?
1 | function double(num) { |
函数可以由我们定义, 可以通过括号 ()
调用。但从上面的例子, 我们可以访问函数的属性, 也可以为函数创建属性, 看上去和普通的对象非常类似, 所以函数可以被当作是一个特殊的对象。
prototype
属性#
所有的函数都有 prototype
属性(除了箭头函数), 同时重申一下这个小Tips:
Not
__proto__
or[[Prototype]]
, butprototype
.
再看回到 new
操作符和构造函数;
下面的例子展示了编写一个构造函数和使用 new
操作符:
⚠️ 如果你还没有100%确定你理解了 this
关键字, 你应该去读一读 JavaScript - The “this” key word in depth
(译者注: this 的值到底是什么? 和 JS 的 new 到底是干什么的? 推一下这两篇)
1 | function Player(userName, score){ |
逐行分析一下代码#
我们通过 new
操作符执行了 Player
函数 (提示: 这里将createPlayer
重命名为Player
仅仅是因为这样符合开发者的约定) , 这也标志着 Player
函数是一个应该通过 new
操作符执行的 构造函数。
当通过 new
操作符执行 构造函数, JavaScript帮我们做了这四件事:
- 创建一个新的临时对象
- 将这个临时对象绑定到
this上下文
- 帮你绑定原型: 临时对象的
__proto__
指向构造函数的prototype
(即Player
的prototype
) - 返回这个临时对象
如果要我们自己来实现上述步骤, 代码看起来会像下面这样:
1 | function Player(userName, score){ |
其中第三行代码:
将临时对象的
__proto__
指向构造函数的prototype
这代表着我们可以把任意函数都挂载到 Player.prototype
, 这样新的对象就可以访问到这些函数, 这一步也就是示例中的这行代码:
1 | Player.prototype.setScore = function(newScore){ |
这就是通过构造函数来实现原型链的链接;
除此之外, 下面代码能确保构造函数是通过 new
操作符来调用的:
1 | function Player(username, score){ |
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 | class Player { |
写一个constructor#
将前文构造函数的内容移植到 constructor
函数内:
1 | class Player { |
为 类
添加函数#
将前文 Player.prototype
里的函数都移植到 类
内:
1 | class Player { |
以下为完整代码:
1 | class Player { |
可以看到, 类
在原型链上所执行的操作与构造函数并无差别, 仅仅是语法上的区别, 同时 类
自带了是否由 new
调用的验证。
继承#
如果想要定义一个特殊的 Player
, 他是那种氪金玩家, 解锁了普通 Player
所没有的特性, 比如能修改自己的用户名, 那应该怎么做呢?
我们可以将我们的需求列出来:
- 需要一个普通用户, 他有着
userName
,score
属性和setScore
方法; - 我们也需要一个氪金用户, 他除了普通玩家有的东西外 (即为 继承 了普通玩家), 还额外有一个
setUserName
方法, 但普通玩家他不充钱就不配有改名的能力😟
在深入其中前, 先来看一看一直在说的原型链是长什么样的:
1 | function double(num){ |
通过前面的内容, 我们得知了当一个属性在对象本身中不存在时, JavaScript解释器会到对象的 __proto__
中找这个属性, 但如果 __proto__
指向的对象也没有这个属性怎么办呢? 先前我们说明过, 所有对象都有 __proto__
属性, 只要这个对象的 __proto__
的指向不为空, 解释器就会一层一层地找下去, 直到最后一个对象的 __proto__
指向为空 (Object.prototype.__proto__
为空, 大部分都是找到这里为止)
有一些绕口, 所以这里直接通过两个例子来解释一下:
1 | double.toString() |
- 首先,
double
本身不包含toString
方法 ✖️; - 去
double.__proto__
中找toString
; double.__proto__
指向的是Function.prototype
, 它包含了toString
方法, 查找成功 ✔️;
1 | double.hasOwnProperty('name') |
double
没有hasOwnProperty
方法 ✖️;- 去
double.__proto__
找; double.__proto__
指向的是Function.prototype
;Function.prototype
没有hasOwnProperty
方法 ✖️;- 去
Function.prototype.__proto__
中找; Function.prototype.__proto__
指向的是Object.prototype
;Object.prototype
包含了hasOwnProperty
方法, 查找成功 ✔️;
原文作者po了一个Gif来表现这个过程:
至此, 创建一个氪金玩家的思路在眼前初步形成了, 我们再通过 对象委托模式、构造函数、类 来实现这个需求, 同时考察一下各种模式的特性;
现在开始深入探索 继承 吧 💪
对象委托 - 继承#
对象委托模式的氪金玩家模型的示例代码如下:
1 | const playerFunctions = { |
这里 createPlayer
的实现没有任何变化, 而 createPaidPlayer
的实现需要一些小技巧。
我们使用 createPlayer
来作为 createPaidPlayer
里创建普通用户的内容, 但这样氪金玩家的 __proto__
就错误地指向了普通玩家的 __proto__
, 所以我们需要使用 Object.setPrototypeOf
方法来修复它的指向。我们新建了一个对象, 作为氪金玩家修复后需要指向 __proto__
对象, 然后将其作为参数传入 Object.setPrototypeOf
来进行修复; 在示例中, 这个对象就是 paidPlayerFunctions
。
但此时破坏了氪金玩家与 playerFunctions
的链接, 也代表着失去了 setScore
能力; 所以需要重新通过 Object.setPrototypeOf
修复一下 paidPlayerFunctions
与 playerFunctions
的链接。
对象委托模式中一个两层的原型链的代码以及显得复杂, 四五层的原型链的复杂程度可想而知。
构造函数 - 继承#
现在来试试通过构造函数来实现继承:
1 | function Player(userName, score) { |
如果通过工厂模式, 也能得到相同的结果, 而且有些内容由 new
帮我们做了, 这能节省几行代码, 但这也会带来新的挑战。
首先第一个挑战就是: 我们通过使用 Player
函数来表现初始化 Player
的逻辑; 但这里的实现却不是使用 new
关键字 (与我们的直觉相悖), 示例里使用了 .call
方法来调用, 这样做的优点是能改变 Player
内this的指向, 以便于对象属性的初始化, 缺点是 Player
不作为构造函数后, 不会自动创建新对象, 也不会自动绑定 this
1 | function PaidPlayer(userName, score, balance) { |
这里使用 Player
时, 其内部的 this
指向了 PaidPlayer
;
另一个挑战是, 需要将 PaidPlayer
所返回的实例, 链接到 Player
的实例上, 我们使用 Object.setPrototypeOf
将 Player.prototype
设置为 PaidPlayer.prototype
的原型;
1 | Object.setPrototypeOf(PaidPlayer.prototype, Player.prototype); |
如你所见,JavaScript解释器为我们做的事情越多,我们需要编写的代码就越少,但是随着抽象程度的增加,我们理解JavaScript解释器中正在发生的事情会更困难一些。
类 - 继承#
使用类,抽象程度能变得更高,这代表着更少的代码:
1 | class Player { |
由上可见, 类只是构造函数的一种语法糖🤔
引用自文档:
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 | class PaidPlayer extends Player { |
总结#
我们学习了几种 链接对象/数据赋值/绑定逻辑 并将其集中到一起 的方法, 我们也了解到了 JavaScript/原型链/多层原型链
的 “继承”。
经过各种例子的再三体会,越是抽象,我们就会得到更多的 “东西”, 越少的代码,但这使我们更难跟踪代码的变化。
每一种模式都有其有点和缺点:
Object.create
需要写更多的代码, 在多层继承中显得复杂且无聊, 但能得到对象上更细粒度的控制;- 构造函数能通过JavaScript自动赋值, 但代码可能会有些反常, 还需要对调用是否通过
new
的验证, 否则会遇到一些奇怪的Bug, 多层继承同样不是很乐观; - 使用类,我们可以获得更简洁的语法,并有了
new
调用的内置检查。当 “继承” 时,类由于其语法糖特性最吸引人,只需使用extended
关键字并调用super()
,而不会像其他方法那样陷入循环。语法也更接近于其他语言,并且比较容易学习。而Javascript的类与其他语言中的类非常不同,它仍然使用旧的“原型继承”,上面有很多抽象层, 这样应该是它的缺点了吧。
译者注#
原作者的更多文章请访问 debuggr.io
关于原型链, 本文原作者的心智模型与 工业聚 - 深入理解 JavaScript 原型 所述基本是不同的, 建议同时参阅工业聚的文章, 来从一个更广的视角和更细节的实现来看待和学习原型链。