剑客
关注科技互联网

[译] 你不懂 JS:原型(Prototype)

[译] 你不懂 JS:原型(Prototype)

前言

你所不懂js连载中断几天之后,今天它又来了。相信这又是让你们一篇稍后阅读的文章了。今天继续由前端早读课专栏作者@HetfieldJoe带来连载《你不懂JS》的分享。ps:基础原理总是苦涩的。

正文从这开始~

你不懂JS:this与对象原型 第五章:原型(Prototype)

【第767期】你不懂JS:混合(淆)“类”的对象 【第766期】你不懂JS:对象 中,我们几次提到了[[Prototype]]链,但我们没有讨论它到底是什么。现在我们就详细讲解一下原型(prototype)。

注意: 所有模拟类拷贝行为的企图,也就是我们在前面第四章描述的内容,称为各种种类的“mixin”,和我们要在本章中讲解的[[Prototype]]链机制完全不同。

[[Prototype]]

JavaScript中的对象有一个内部属性,在语言规范中称为[[Prototype]],它只是一个其他对象的引用。几乎所有的对象在被创建时,它的这个属性都被赋予了一个非null值。

注意: 我们马上就会看到,一个对象拥有一个空的[[Prototype]]链接是 可能 的,虽然这有些不寻常。

考虑下面的代码:

[译] 你不懂 JS:原型(Prototype)

[[Prototype]]引用有什么用?在 【第766期】你不懂JS:对象 中,我们讲解了[[Get]]操作,它会在你引用一个对象上的属性时被调用,比如myObject.a。对于默认的[[Get]]操作来说,第一步就是检查对象本身是否拥有一个a属性,如果有,就使用它。

注意: ES6的代理(Proxy)超出了我们要在本书内讨论的范围(将会在本系列的后续书目中涵盖!),但是如果加入Proxy,我们在这里讨论的关于普通[[Get]]和[[Put]]的行为都是不被采用的。

但是如果myObject上 不 存在a属性时,我们就将注意力转向对象的[[Prototype]]链。

如果默认的[[Get]]操作不能直接在对象上找到被请求的属性,那么会沿着对象的[[Prototype]]链 继续处理。

[译] 你不懂 JS:原型(Prototype)

注意: 我们马上就会解释Object.create(..)是做什么,如何做的。眼下先假设,它创建了一个对象,这个对象带有一个链到指定的对象的[[Prototype]]链接,这个链接就是我们要讲解的。

那么,我们现在让myObject“[[Prototype]]链到了anotherObject。虽然很明显myObject.a实际上不存在,但是无论如何属性访问成功了(在anotherObject中找到了),而且确实找到了值2。

但是,如果在anotherObject上也没有找到a,而且如果它的[[Prototype]]链不为空,就沿着它继续查找。

这个处理持续进行,直到找到名称匹配的属性,或者[[Prototype]]链终结。如果在链条的末尾都没有找到匹配的属性,那么[[Get]]操作的返回结果为undefined。

和这种[[Prototype]]链查询处理相似,如果你使用for..in循环迭代一个对象,所有在它的链条上可以到达的(并且是enumerable——见第三章)属性都会被枚举。如果你使用in操作符来测试一个属性在一个对象上的存在性,in将会检查对象的整个链条(不管 可枚举性)。

[译] 你不懂 JS:原型(Prototype)

所以,当你以各种方式进行属性查询时,[[Prototype]]链就会一个链接一个链接地被查询。一旦找到属性或者链条终结,这种查询会就会停止。

Object.prototype

但是[[Prototype]]链到底在 哪里 “终结”?

每个 普通 的[[Prototype]]链的最顶端,是内建的Object.prototype。这个对象包含各种在整个JS中被使用的共通工具,因为JavaScript中所有普通(内建,而非被宿主环境扩展的)的对象都“衍生自”(也就是,使它们的[[Prototype]]顶端为)Object.prototype对象。

你会在这里发现一些你可能很熟悉的工具,比如.toString()和.valueOf()。在第三章中,我们介绍了另一个:.hasOwnProperty(..)。还有另外一个你可能不太熟悉,但我们将在这一章里讨论的Object.prototype上的函数是.isPrototypeOf(..)。

设置与遮蔽属性

回到第三章,我们提到过在对象上设置属性要比仅仅在对象上添加新属性或改变既存属性的值更加微妙。现在我们将更完整地重温这个话题。

如果myObject对象已直接经拥有了普通的名为foo的数据访问器属性,那么这个赋值就和改变既存属性的值一样简单。

如果foo还没有直接存在于myObject,[[Prototype]]就会被遍历,就像[[Get]]操作那样。如果在链条的任何地方都没有找到foo,那么就会像我们期望的那样,属性foo就以指定的值被直接添加到myObject上。

然而,如果foo已经存在于链条更高层的某处,myObject.foo = "bar"赋值就可能会发生微妙的(也许令人诧异的)行为。我们一会儿就详细讲解。

如果属性名foo同时存在于myObject本身和从myObject开始的[[Prototype]]链的更高层,这样的情况称为 遮蔽。直接存在于myObject上的foo属性会 遮蔽 任何出现在链条高层的foo属性,因为myObject.foo查询总是在寻找链条最底层的foo属性。

正如我们被暗示的那样,在myObject上的foo遮蔽没有看起来那么简单。我们现在来考察myObject.foo = "bar"赋值的三种场景,当foo 不直接存在 于myObject,但 存在 于myObject的[[Prototype]]链的更高层:

如果一个普通的名为foo的数据访问属性在[[Prototype]]链的高层某处被找到,而且没有被标记为只读(writable:false),那么一个名为foo的新属性就直接添加到myObject上,形成一个 遮蔽属性。

如果一个foo在[[Prototype]]链的高层某处被找到,但是它被标记为 只读(writable:false) ,那么设置既存属性和在myObject上创建遮蔽属性都是 不允许 的。如果代码运行在strict mode下,一个错误会被抛出。否则,这个设置属性值的操作会被无声地忽略。不论怎样,没有发生遮蔽。

如果一个foo在[[Prototype]]链的高层某处被找到,而且它是一个setter(见第三章),那么这个setter总是被调用。没有foo会被添加到(也就是遮蔽在)myObject上,这个foosetter也不会被重定义。

大多数开发者认为,如果一个属性已经存在于[[Prototype]]链的高层,那么对它的赋值([[Put]])将总是造成遮蔽。但如你所见,这仅在刚才描述的三中场景中的一种(第一种)中是对的。

如果你想在第二和第三种情况中遮蔽foo,那你就不能使用=赋值,而必须使用Object.defineProperty(..)(见第三章)将foo添加到myObject。

注意: 第二种情况可能是三种情况中最让人诧异的了。只读 属性的存在会阻止同名属性在[[Prototype]]链的低层被创建(遮蔽)。这个限制的主要原因是为了增强类继承属性的幻觉。如果你想象位于链条高层的foo被继承(拷贝)至myObject, 那么在myObject上强制foo属性不可写就有道理。但如果你将幻觉和现实分开,而且认识到 实际上 没有这样的继承拷贝发生(见第四,五章),那么仅因为某些其他的对象上拥有不可写的foo,而导致myObject不能拥有foo属性就有些不自然。而且更奇怪的是,这个限制仅限于=赋值,当使用Object.defineProperty(..)时不被强制。

如果你需要在方法间进行委托,方法 的遮蔽会导致难看的 显式假想多态(见第四章)。一般来说,遮蔽与它带来的好处相比太过复杂和微妙了,所以你应当尽量避免它。第六章介绍另一种设计模式,它提倡干净而且不鼓励遮蔽。

遮蔽甚至会以微妙的方式隐含地发生,所以要想避免它必须小心。考虑这段代码:

[译] 你不懂 JS:原型(Prototype)

虽然看起来myObject.a++应当(通过委托)查询并 原地 递增anotherObject.a属性,但是++操作符相当于myObject.a = myObject.a + 1。结果就是在[[Prototype]]上进行a的[[Get]]查询,从anotherObject.a得到当前的值2,将这个值递增1,然后将值3用[[Put]]赋值到myObject上的新遮蔽属性a上。噢!

修改你的委托属性时要非常小心。如果你想递增anotherObject.a, 那么唯一正确的方法是anotherObject.a++。

“类”

现在你可能会想知道:“为什么 一个对象需要链到另一个对象?”真正的好处是什么?这是一个很恰当的问题,但在我们能够完全理解和体味它是什么和如何有用之前,我们必须首先理解[[Prototype]] 不是 什么。

正如我们在第四章讲解的,在JavaScript中,对于对象来说没有抽象模式/蓝图,即没有面向类的语言中那样的称为类的东西。JavaScript 只有 对象。

实际上,在所有语言中,JavaScript 几乎是独一无二的,也许是唯一的可以被称为“面向对象”的语言,因为可以根本没有类而直接创建对象的语言很少,而JavaScript就是其中之一。

在JavaScript中,类不能(因为根本不存在)描述对象可以做什么。对象直接定义它自己的行为。这里 仅有 对象。

“类”函数

在JavaScript中有一种奇异的行为被无耻地滥用了许多年来 山寨 成某些 看起来 像“类”的东西。我们来仔细看看这种方式。

“某种程度的类”这种奇特的行为取决于函数的一个奇怪的性质:所有的函数默认都会得到一个公有的,不可枚举的属性,称为prototype,它可以指向任意的对象。

[译] 你不懂 JS:原型(Prototype)

这个对象经常被称为“Foo的原型”,因为我们通过一个不幸地被命名为Foo.prototype的属性引用来访问它。然而,我们马上会看到,这个术语命中注定地将我们搞糊涂。为了取代它,我将它称为“以前被认为是Foo的原型的对象”。只是开个玩笑。“一个被随意标记为‘Foo点儿原型’的对象”,怎么样?

不管我们怎么称呼它,这个对象到底是什么?

解释它的最直接的方法是,每个由调用new Foo()(见第二章)而创建的对象将最终(有些随意地)被[[Prototype]]链接到这个“Foo点儿原型”对象。

让我们描绘一下:

[译] 你不懂 JS:原型(Prototype)

当通过调用new Foo()创建a时,会发生的事情之一(见第二章了解所有 四个 步骤)是,a得到一个内部[[Prototype]]链接,此链接链到Foo.prototype所指向的对象。

停一会来思考一下这句话的含义。

在面向类的语言中,可以制造一个类的多个 拷贝(即“实例”),就像从模具中冲压出某些东西一样。我们在第四章中看到,这是因为初始化(或者继承)类的处理意味着,“将行为计划从这个类拷贝到物理对象中”,对于每个新实例这都会发生。

但是在JavaScript中,没有这样的拷贝处理发生。你不会创建类的多个实例。你可以创建多个对象,它们的[[Prototype]]连接至一个共通对象。但默认地,没有拷贝发生,如此这些对象彼此间最终不会完全分离和切断关系,而是 链接在一起。

new Foo()得到一个新对象(我们叫他a),这个新对象a内部地被[[Prototype]]链接至Foo.prototype对象。

结果我们得到两个对象,彼此链接。 如是而已。我们没有初始化一个对象。当然我们也没有做任何从一个“类”到一个实体对象拷贝。我们只是让两个对象互相链接在一起。

事实上,这个使大多数JS开发者无法理解的秘密,是因为new Foo()函数调用实际上几乎和建立链接的处理没有任何 直接 关系。它是某种偶然的副作用。new Foo()是一个间接的,迂回的方法来得到我们想要的:一个被链接到另一个对象的对象。

我们能用更直接的方法得到我们想要的吗?可以! 这位英雄就是Object.create(..)。我们过会儿就谈到它。

名称的意义何在?

在JavaScript中,我们不从一个对象(“类”)向另一个对象(“实例”) 拷贝。我们在对象之间制造 链接。对于[[Prototype]]机制,视觉上,箭头的移动方向是从右至左,由下至上。

[译] 你不懂 JS:原型(Prototype)

这种机制常被称为“原型继承(prototypal inheritance)”(我们很快就用代码说明),它经常被说成是动态语言版的“类继承”。这种说法试图建立在面向类世界中对“继承”含义的共识上。但是 弄拧(意思是:抹平) 了被理解语义,来适应动态脚本。

先入为主,“继承”这个词有很强烈的含义(见第四章)。仅仅在它前面加入“原型”来区别于JavaScript中 实际上几乎相反 的行为,使真相在泥泞般的困惑中沉睡了近二十年。

我想说,将“原型”贴在“继承”之前很大程度上搞反了它的实际意义,就像一只手拿着一个桔子,另一手拿着一个苹果,而坚持说苹果是一个“红色的桔子”。无论我在它前面放什么令人困惑的标签,那都不会改变一个水果是苹果而另一个是桔子的 事实。

更好的方法是直白地将苹果称为苹果——使用最准确和最直接的术语。这样能更容易地理解它们的相似之处和 许多不同之处,因为我们都对“苹果”的意义有一个简单的,共享的理解。

由于用语的模糊和歧义,我相信,对于解释JavaScript机制真正如何工作来说,“原型继承”这个标签(以及试图错误地应用所有面向类的术语,比如“类”,“构造器”,“实例”,“多态”等)本身带来的 危害比好处多。

“继承”意味着 拷贝 操作,而JavaScript不拷贝对象属性(原生上,默认地)。相反,JS在两个对象间建立链接,一个对象实质上可以将对属性/函数的访问 委托 到另一个对象上。对于描述JavaScript对象链接机制来说,“委托”是一个准确得多的术语。

另一个有时被扔到JavaScript旁边的术语是“差分继承”。它的想法是,我们可以用一个对象与一个更泛化的对象的 不同 来描述一个它的行为。比如,你要解释汽车是一种载具,与其重新描述组成一个一般载具的所有特点,不如只说它有4个轮子。

如果你试着想象,在JS中任何给定的对象都是通过委托可用的所有行为的总和,而且 在你思维中你扁平化 所有的行为到一个有形的 东西 中,那么你就可以(八九不离十地)看到“差分继承”是如何自圆其说的。

但正如“原型继承”,“差分继承”假意使你的思维模型比在语言中物理发生的事情更重要。它忽视了这样一个事实:对象B实际上不是一个差异结构,而是由一些定义好的特定性质,与一些没有任何定义的“漏洞”组成的。正是通过这些“漏洞”(缺少定义),委托可以接管并且动态地用委托行为“填补”它们。

对象不是像“差分继承”的思维模型所暗示的那样,原生默认地,通过拷贝 扁平化到一个单独的差异对象中。如此,对于描述JavaScript的[[Prototype]]机制如何工作来说,“差分继承”就不是自然合理。

你 可以选择 偏向“差分继承”这个术语和思维模型,这是个人口味的问题,但是不能否认这个事实:它 仅仅 符合你思维中的主观过程,不是引擎的物理行为。

"构造器"(Constructors)

让我们回到早先的代码:

[译] 你不懂 JS:原型(Prototype)

到底是什么导致我们认为Foo是一个“类”?

其一,我们看到了new关键字的使用,就像面向类语言中人们构建类的对象那样。另外,它看起来我们事实上执行了一个类的 构造器 方法,因为Foo()实际上是个被调用的方法,就像当你初始化一个真实的类时这个类的构造器被调用的那样。

为了使“构造器”的语义更使人糊涂,被随意贴上标签的Foo.prototype对象还有另外一招。考虑这段代码:

[译] 你不懂 JS:原型(Prototype)

Foo.prototype对象默认地(就在代码段中第一行中声明的地方!)得到一个公有的,称为.constructor的不可枚举(见第三章)属性,而且这个属性回头指向这个对象关联的函数(这里是Foo)。另外,我们看到被“构造器”调用new Foo()创建的对象a 看起来 也拥有一个称为.constructor的属性,也相似地指向“创建它的函数”。

注意: 这实际上不是真的。a上没有.constructor属性,而a.constructor确实解析成了Foo函数,“constructor”并不像它看起来的那样实际意味着“被XX创建”。我们很快就会解释这个奇怪的地方。

哦,是的,另外……根据JavaScript世界中的惯例,“类”都以大写字母开头的单词命名,所以使用Foo而不是foo强烈地意味着我们打算让它成为一个“类”。这对你来说太明显了,对吧!?

注意: 这个惯例是如此强大,以至于如果你在一个小写字母名称的方法上使用new调用,或并没有在一个大写字母开头的函数上使用new,许多JS语法检查器将会报告错误。这是因为我们如此努力地想要在JavaScript中将(假的)“面向类” 搞对,所以我们建立了这些语法规则来确保我们使用了大写字母,即便对JS引擎来讲,大写字母根本没有 任何意义。

构造器还是调用?

上面的代码的段中,我们试图认为Foo是一个“构造器”,是因为我们用new调用它,而且我们观察到它“构建”了一个对象。

在现实中,Foo不会比你的程序中的其他任何函数“更像构造器”。函数自身 不是 构造器。但是,当你在普通函数调用前面放一个new关键字时,这就将函数调用变成了“构造器调用”。事实上,new在某种意义上劫持了普通函数并将它以另一种方式调用:构建一个对象,外加这个函数要做的其他任何事。

举个例子:

[译] 你不懂 JS:原型(Prototype)

NothingSpecial仅仅是一个普通的函数,但当用new调用时,几乎是一种副作用,它会 构建 一个对象,并被我们赋值到a。这个 调用 是一个 构造器调用,但是NothingSpecial本身并不是一个 构造器。

换句话说,在JavaScript中,更合适的说法是,“构造器”是在前面 用new关键字调用的任何函数。

函数不是构造器,但是当且仅当new被使用时,函数调用是一个“构造器调用”。

机制

仅仅是这些原因使得JavaScript中关于“类”的讨论变得命运多舛吗?

不全是。 JS开发者们努力地尽可能的模拟面向类:

[译] 你不懂 JS:原型(Prototype)

这段代码展示了另外两种“面向类”的花招:

this.name = name:在每个对象(分别在a和b上;参照第二章关于this绑定的内容)上添加了.name属性,和类的实例包装数据值很相似。

Foo.prototype.myName = …:这也许是更有趣的技术,它在Foo.prototype对象上添加了一个属性(函数)。现在,也许让人惊奇,a.myName()可以工作。但是是如何工作的?

在上面的代码段中,有很强的倾向认为当a和b被创建时,Foo.prototype上的属性/函数被 拷贝 到了a与b俩个对象上。但是,这没有发生。

在本章开头,我们解释了[[Prototype]]链,和它作为默认的[[Get]]算法的一部分,如何在不能直接在对象上找到属性引用时提供后备的查询步骤。

于是,得益于他们被创建的方式,a和b都最终拥有一个内部的[[Prototype]]链接链到Foo.prototype。当无法分别在a和b中找到myName时,就会在Foo.prototype上找到(通过委托,见第六章)。

复活"构造器"

回想我们刚才对.constructor属性的讨论,怎么看起来a.constructor === Foo为true意味着a上实际拥有一个.constructor属性,指向Foo?不对。

这只是一种不幸的混淆。实际上,.constructor引用也 委托 到了Foo.prototype,它 恰好 有一个指向Foo的默认属性。

这 看起来 方便得可怕,一个被Foo构建的对象可以访问指向Foo的.constructor属性。但这只不过是安全感上的错觉。它是一个欢乐的巧合,几乎是误打误撞,通过默认的[[Prototype]]委托a.constructor 恰好 指向Foo。实际上.construcor意味着“被XX构建”这种注定失败的臆测会以几种方式来咬到你。

第一,在Foo.prototype上的.constructor属性仅当Foo函数被声明时才出现在对象上。如果你创建一个新对象,并用它替换函数默认的.prototype对象引用,这个新对象上将不会魔法般地得到.contructor。

考虑这段代码:

[译] 你不懂 JS:原型(Prototype)

Object(..)没有“构建”a1,是吧?看起来确实是Foo()“构建了”它。许多开发者认为Foo()在执行构建,但当你认为“构造器”意味着“被XX构建”时,一切就都崩塌了,因为如果那样的话,a1.construcor应当是Foo,但它不是!

发生了什么?a1没有.constructor属性,所以它沿者[[Prototype]]链向上委托到了Foo.prototype。但是这个对象也没有.constructor(默认的Foo.prototype对象就会有!),所以它继续委托,这次轮到了Object.prototype,委托链的最顶端。那个 对象上确实拥有.constructor,它指向内建的Object(..)函数。

误解,消除。

当然,你可以把.constructor加回到Foo.prototype对象上,但是要做一些手动工作,特别是如果你想要它与原生的行为吻合,并不可枚举时(见第三章)。

举例来说:

[译] 你不懂 JS:原型(Prototype)

要修复.constructor要花不少功夫。而且,我们做的一切是为了延续“构造器”意味着“被XX构建”的误解。这是一种昂贵的假象。

事实上,一个对象上的.construcor默认地随意指向一个函数,而这个函数反过来拥有一个指向被这个对象称为.prototype的对象。“构造器”和“原型”这两个词仅有松散的默认含义,可能是真的也可能不是真的。最佳方案是提醒你自己,“构造器不是意味着被XX构建”。

.constructor不是一个魔法般不可变的属性。它是不可枚举的(见上面的代码段),但是它的值是可写的(可以改变),而且,你可以在[[Prototype]]链上的任何对象上添加或覆盖(有意或无意地)名为constructor的属性,用你感觉合适的任何值。

根据[[Get]]算法如何遍历[[Prototype]]链,在任何地方找到的一个.constructor属性引用解析的结果可能与你期望的十分不同。

看到它的实际意义有多随便了吗?

结果?某些像a1.constructor这样随意的对象属性引用实际上不能被认为是默认的函数引用。还有,我们马上就会看到,通过一个简单的省略,a1.constructor可以最终指向某些令人诧异,没道理的地方。

a1.constructor是极其不可靠的,在你的代码中不应依赖的不安全引用。一般来说,这样的引用应当尽量避免。

“(原型)继承”

我们已经看到了一些近似的“类”机制骇进JavaScript程序。但是如果我们没有一种近似的“继承”,JavaScript的“类”将会更空洞。

实际上,我们已经看到了一个常被称为“原型继承”的机制如何工作:a可以“继承自”Foo.prototype,并因此可以访问myName()函数。但是我们传统的想法认为“继承”是两个“类”间的关系,而非“类”与“实例”的关系。

[译] 你不懂 JS:原型(Prototype)

回想之前这幅图,它不仅展示了从对象(也就是“实例”)a1到对象Foo.prototype的委托,而且从Bar.prototype到Foo.prototype,这酷似类继承的亲自概念。酷似,除了方向,箭头表示的是委托链接,而不是拷贝操作。

这里是一段典型的创建这样的链接的“原型风格”代码:

[译] 你不懂 JS:原型(Prototype)

注意: 要想知道为什么上面代码中的this指向a,参见第二章。

重要的部分是Bar.prototype = Object.create( Foo.prototype )。Object.create(..)凭空 创建 了一个“新”对象,并将这个新对象内部的[[Prototype]]链接到你指定的对象上(在这里是Foo.prototype)。

换句话说,这一行的意思是:“做一个 新的 链接到‘Foo点儿prototype’的‘Bar点儿prototype’对象”。

当function Bar() { .. }被声明时,就像其他函数一样,拥有一个链到默认对象的.prototype链接。但是 那个 对象没有链到我们希望的Foo.prototype。所以,我们创建了一个 新 对象,链到我们希望的地方,并将原来的错误链接的对象扔掉。

注意: 这里一个常见的误解/困惑是,下面两种方法 也 能工作,但是他们不会如你期望的那样工作:

[译] 你不懂 JS:原型(Prototype)

Bar.prototype = Foo.prototype不会创建新对象让Bar.prototype链接。它只是让Bar.prototype成为Foo.prototype的另一个引用,将Bar直接链到Foo链着的 同一个对象:Foo.prototype。这意味着当你开始赋值时,比如Bar.prototype.myLabel = …,你修改的 不是一个分离的对象 而是那个被分享的Foo.prototype对象本身,它将影响到所有链接到Foo.prototype的对象。这几乎可以确定不是你想要的。如果这正是你想要的,那么你根本就不需要Bar,你应当仅使用Foo来使你的代码更简单。

Bar.prototype = new Foo()确实 创建了一个新的对象,这个新对象也的确链接到了我们希望的Foo.prototype。但是,它是用Foo(..)“构造器调用”来这样做的。如果这个函数有任何副作用(比如logging,改变状态,注册其他对象,向this添加数据属性,等等),这些副作用就会在链接时发生(而且很可能是对错误的对象!),而不是像可能希望的那样,仅最终在Bar()的“后裔”被创建时发生。

于是,我们剩下的选择就是使用Object.create(..)来制造一个新对象,这个对象被正确地链接,而且没有调用Foo(..)时所产生的副作用。一个轻微的缺点是,我们不得不创建新对象,并把旧的扔掉,而不是修改提供给我们的默认既存对象。

如果有一种标准且可靠地方法来修改既存对象的链接就好了。ES6之前,有一个非标准的,而且不是完全对所有浏览器通用的方法:通过可以设置的.__proto__属性。ES6中增加了Object.setPrototypeOf(..)辅助工具,它提供了标准且可预见的方法。

让我们一对一地比较ES6之前和ES6标准的技术如何处理将Bar.prototype链接至Foo.prototype:

[译] 你不懂 JS:原型(Prototype)

如果忽略Object.create(..)方式在性能上的轻微劣势(扔掉一个对象,然后被回收),其实它相对短一些而且可能比ES6+的方式更易读。但两种方式可能都只是语法表面现象。

考察“类”关系

如果你有一个对象a并且希望找到它委托至哪个对象呢(如果有的话)?考察一个实例(一个JS对象)的继承血统(在JS中是委托链接),在传统的面向类环境中称为 自省(introspection)(或 反射(reflection))。

考虑下面的代码:

[译] 你不懂 JS:原型(Prototype)

那么我们如何自省a来找到它的“祖先”(委托链)呢?一种方式是接受“类”的困惑:

instanceof操作符的左边操作数接收一个普通对象,右边操作数接收一个 函数。instanceof回答的问题是:在a的整个[[Prototype]]链中,有没有出现被那个被Foo.prototype所随便指向的对象?

不幸的是,这意味着如果你拥有可以用于测试的 函数(Foo,和它带有的.prototype引用),你只能查询某些对象(a)的“祖先”。如果你有两个任意的对象,比如a和b,而且你想调查是否 这些对象 通过[[Prototype]]链相互关联,单靠instanceof帮不上什么忙。

注意: 如果你使用内建的.bind(..)工具来制造一个硬绑定的函数(见第二章),这个被创建的函数将不会拥有.prototype属性。将instanceof与这样的函数一起使用时,将会透明地替换为创建这个硬绑定函数的 目标函数 的.prototype。

将硬绑定函数用于“构造器调用”十分罕见,但如果你这么做,它会表现得好像是 目标函数 被调用了,这意味着将instanceof与硬绑定函数一起使用也会参照原版函数。

下面这段代码展示了试图通过“类”的语义和instanceof来推导 两个对象 间的关系是多么荒谬:

[译] 你不懂 JS:原型(Prototype)

在isRelatedTo(..)内部,我们借用一个一次性的函数F,重新对它的.prototype赋值,使他随意地指向某个对象o2,之后问是否o1是F的“一个实例”。很明显,o1实际上不是继承或遗传自F,甚至不是由F构建的,所以显而易见这种实践是愚蠢且让人困惑的。这个问题归根结底是将类的语义强加于JavaScript的尴尬,在这个例子中是由instanceof的间接语义揭露的。

第二种,也是更干净的方式,[[Prototype]]反射:

注意在这种情况下,我们并不真正关心(甚至 不需要)Foo,我们仅需要一个 对象(在我们的例子中就是随意标志为Foo.prototype)来与另一个 对象 测试。isPrototypeOf(..)回答的问题是:在a的整个[[Prototype]]链中,Foo.prototype出现过吗?

同样的问题,和完全同样的答案。但是在第二种方式中,我们实际上不需要间接地引用一个.prototype属性将被自动查询的 函数(Foo)。

我们 只需要 两个 对象 来考察它们之间的关系。比如:

[译] 你不懂 JS:原型(Prototype)

注意,这种方法根本不要求有一个函数(“类”)。它仅仅使用对象的直接引用b和c,来查询他们的关系。换句话说,我们上面的isRelatedTo(..)工具是内建在语言中的,它的名字叫isPrototypeOf(..)。

我们也可以直接取得一个对象的[[Prototype]]。在ES5中,这么做的标准方法是:

而且你将注意到对象引用是我们期望的:

大多数浏览器(不是全部!)还一种长期支持的,非标准方法可以访问内部的[[Prototype]]:

这个奇怪的.__proto__(直到ES6才标准化!)属性“魔法般地”取得一个对象内部的[[Prototype]]作为引用,如果你想要直接考察(甚至遍历:.__proto__.__proto__…)[[Prototype]]链,这个引用十分有用。

和我们早先看到的.constructor一样,.__proto__实际上不存在于你考察的对象上(在我们的例子中是a)。事实上,它存在于(不可枚举地;见第二章)内建的Object.prototype上,和其他的共通工具在一起(.toString(), .isPrototypeOf(..), 等等)。

而且,.__proto__看起来像一个属性,但实际上将它看做是一个getter/setter(见第三章)更合适。

大致地,我们可以这样描述.__proto__实现(见第三章,对象属性的定义):

[译] 你不懂 JS:原型(Prototype)

所以,当我们访问a.__proto__(取得它的值)时,就好像调用a.__proto__()(调用getter函数)。虽然getter函数存在于Object.prototype上(参照第二章,this绑定规则),但这个函数调用将a用作它的this,所以它相当于在说Object.getPrototypeOf( a )。

.__proto__还是一个可设置的属性,就像早先展示过的ES6Object.setPrototypeOf(..)。然而,一般来说你 不应该改变一个既存对象的[[Prototype]]。

在某些允许对Array定义“子类”的框架中,深度地使用了一些非常复杂,高级的技术,但是在一般的编程实践中经常是让人皱眉头的,因为这通常导致非常难理解/维护的代码。

注意: 在ES6中,关键字class将允许某些近似方法,对像Array这样的内建类型“定义子类”。参见附录A中关于ES6中加入的class的讨论。

仅有一小部分例外(就像前面提到过的),会设置一个默认函数.prototype对象的[[Prototype]],使它引用其他的对象(Object.prototype之外的对象)。它们会避免将这个默认对象完全替换为一个新的链接对象。否则,为了在以后更容易地阅读你的代码 最好将对象的[[Prototype]]链接作为只读性质对待。

注意: 针对双下划线,特别是在像__proto__这样的属性中开头的部分,JavaScript社区非官方地创造了一个术语:“dunder”。所以,那些JavaScript的“酷小子”们通常将__proto__读作“dunder proto”。

对象链接

正如我们看到的,[[Prototype]]机制是一个内部链接,它存在于一个对象上,这个对象引用一些其他的对象。

这种链接(主要)在对第一个对象进行属性/方法引用,但这样的属性/方法不存在时实施。在这种情况下,[[Prototype]]链接告诉引擎在那个被链接的对象上查找这个属性/方法。接下来,如果这个对象不能满足查询,它的[[Prototype]]又会被查找,如此继续。这个在对象间的一系列链接构成了所谓的“原形链”。

创建链接

我们已经彻底揭露了为什么JavaScript的[[Prototype]]机制和 类 不 一样,而且我们也看到了如何在正确的对象间创建 链接。

[[Prototype]]机制的意义是什么?为什么总是见到JS开发者们费那么大力气(模拟类)在他们的代码中搞乱这些链接?

记得我们在本章很靠前的地方说过Object.create(..)是英雄吗?现在,我们准备好看看为什么了。

[译] 你不懂 JS:原型(Prototype)

Object.create(..)创建了一个链接到我们指定的对象(foo)上的新对象(bar),这给了我们[[Prototype]]机制的所有力量(委托),而且没有new函数作为类和构造器调用产生的任何没必要的复杂性,搞乱.prototype和.constructor 引用,或任何其他的多余的东西。

注意: Object.create(null)创建一个拥有空(也就是null)[[Prototype]]链接的对象,如此这个对象不能委托到任何地方。因为这样的对象没有原形链,instancof操作符(前面解释过)没有东西可检查,所以它总返回false。由于他们典型的用途是在属性中存储数据,这种特殊的空[[Prototype]]对象经常被称为“dictionaries(字典)”,这主要是因为它们没有可能受到在[[Prototype]]链上任何委托属性/函数的影响,所以它们是纯粹的扁平数据存储。

我们不 需要 类来在两个对象间创建有意义的关系。我们需要 真正关心 的唯一问题是对象为了委托而链接在一起,而Object.create(..)给我们这种链接并且没有一切关于类的烂设计。

填补Object.create()

Object.create(..)在ES5中被加入。你可能需要支持ES5之前的环境(比如老版本的IE),所以让我们来看一个Object.create(..)的简单 部分 填补工具,它甚至能在更老的JS环境中给我们所需的能力:

[译] 你不懂 JS:原型(Prototype)

这个填补工具通过一个一次性的F函数并覆盖它的.prototype属性来指向我们想连接到的对象。之后我们用new F()构造器调用来制造一个将会链到我们指定对象上的新对象。

Object.create(..)的这种用法是目前最常见的用法,因为他的这一部分是 可以 填补的。ES5标准的内建Object.create(..)还提供了一个附加的功能,它是 不能 被ES5之前的版本填补的。如此,这个功能的使用远没有那么常见。为了完整性,让我么看看这个附加功能:

[译] 你不懂 JS:原型(Prototype)

Object.create(..)的第二个参数指定了要添加在新对象上的属性名,通过声明每个新属性的 属性描述符(见第三章)。因为在ES5之前的环境中填补属性描述符是不可能的,所以Object.create(..)的这个附加功能无法填补。

因为Object.create(..)的绝大多数用途都是使用填补安全的功能子集,所以大多数开发者在ES5之前的环境中使用这种 部分填补 也没有问题。

有些开发者采取严格得多的观点,也就是除非能够被 完全 填补,否则没有函数应该被填补。因为Object.create(..)可以部分填补的工具之一,这种较狭窄的观点会说,如果你需要在ES5之前的环境中使用Object.create(..)的任何功能,你应当使用自定义的工具,而不是填充,而且应当彻底远离使用Object.create这个名字。你可以定义自己的工具,比如:

[译] 你不懂 JS:原型(Prototype)

我不会分享这种严格的观点。我完全拥护如上面展示的Object.create(..)的常见部分填补,甚至在ES5之前的环境下在你的代码中使用它。我将选择权留给你。

链接作为候补?

也许这么想很吸引人:这些对象间的链接 主要 是为了给“缺失”的属性和方法提供某种候补。虽然这是一个可观察到的结果,但是我不认为这是考虑[[Prototype]]的正确方法。

考虑下面的代码:

[译] 你不懂 JS:原型(Prototype)

得益于[[Prototype]],这段代码可以工作,但如果你这样写是为了 万一 myObject不能处理某些开发者可能会调用的属性/方法,而让anotherObject作为一个候补,你的软件大概会变得有点儿“魔法”并且更难于理解和维护。

这不是说候补在任何情况下都不是一个合适的设计模式,但它不是一个在JS中很常见的用法,所以如果你发现自己在这么做,那么你可能想要退一步并重新考虑它是否真的是合适且合理的设计。

注意: 在ES6中,引入了一个称为Proxy(代理)的高级功能,它可以提供某种“方法未找到”类型的行为。Proxy超出了本书的范围,但会在以后的 “你不懂JS” 系列图书中详细讲解。

这里不要错过一个重要的细节。

例如,你打算为一个开发者设计软件,如果即使在myObject上没有cool()方法时调用myObject.cool()也能工作,会在你的API设计上引入一些“魔法”气息,这可能会使未来维护你的软件的开发者很吃惊。

然而你可以在你的API设计上少用些“魔法”,而仍然利用[[Prototype]]链接的力量。

[译] 你不懂 JS:原型(Prototype)

这里,我们调用myObject.doCool(),它是一个 实际存在于 myObject上的方法,这使我们的API设计更清晰(没那么“魔法”)。在它内部,我们的实现依照 委托设计模式(见第六章),利用[[Prototype]]委托到anotherObject.cool()。

换句话说,如果委托是一个内部实现细节,而非在你的API结构设计中简单地暴露出来,它倾向于减少意外/困惑。我们会在下一章中详细解释 委托。

复习

当试图在一个对象上进行属性访问,而对象没有该属性时,对象内部的[[Prototype]]链接定义了[[Get]]操作(见第三章)下一步应当到哪里寻找它。这种对象到对象的串行链接定义了对象的“原形链”(和嵌套的作用域链有些相似),在解析属性时发挥作用。

所有普通的对象用内建的Object.prototype作为原形链的顶端(就像作用域查询的顶端是全局作用域),如果属性没能在链条的前面任何地方找到,属性解析就会在这里停止。toString(),valueOf(),和其他几种共同工具都存在于这个Object.prototype对象上,这解释了语言中所有的对象是如何能够访问他们的。

使两个对象相互链接在一起的最常见的方法是将new关键字与函数调用一起使用,在它的四个步骤中(见第二章),就会建立一个新对象链接到另一个对象。

那个用new调用的函数有一个被随便地命名为.prototype的属性,这个属性所引用的对象恰好就是这个新对象链接到的“另一个对象”。带有new的函数调用通常被称为“构造器”,尽管实际上它们并没有像传统的面相类语言那样初始化一个类。

虽然这些JavaScript机制看起来和传统面向类语言的“初始化类”和“类继承”类似,而在JavaScript中的关键区别是,没有拷贝发生。取而代之的是对象最终通过[[Prototype]]链链接在一起。

由于各种原因,不光是前面提到的术语,“继承”(和“原型继承”)与所有其他的OO用语,在考虑JavaScript实际如何工作时都没有道理。

相反,“委托”是一个更确切的术语,因为这些关系不是 拷贝 而是委托 链接。

关于本文

译者:@HetfieldJoe

原文:http://dwz.cn/4EJiLF

[译] 你不懂 JS:原型(Prototype) 每天早读,三万同行一起成长

投稿合作联系 (微信号) :fezaodujun

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址