Skip to content

💤

《你不知道的 JavaScript》

原型(prototype)

在第三,四章中,我们几次提到了[[Prototype]]链,但我们没有讨论它到底是什么。现在我们就详细讲解一下原型(prototype)。

NOTE

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

[[Prototype]]

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

NOTE

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

javascript
const obj = { a: 2 }

console.log(obj.a) // 2

[[Prototype]]引用有什么用?在第三章中,我们讲解了[[Get]]操作,它会在你引用一个对象上的属性时被调用,比如obj.a。对于默认的[[Get]]操作来说,第一步就是检查对象本身是否拥有一个a属性,如果有,那就是用它。

NOTE

ES6 的代理(Proxy)超出了我们要在本书内讨论的范围,但是如果加入Proxy,我们在这里讨论的关于普通[[Get]][[Put]]的行为都是不被采用的。

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

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

javascript
const anotherObj = { a: 2 }
const myObj = Object.create(anotherObj)

console.log(myObj.a) // 2

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

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

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

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

javascript
const anotherObj = { a: 2 }
const myObj = Object.create(anotherObj)

for (const k in myObj) {
  console.log(`found: ${k}`)
} // found: a

console.log('a' in myObj) // true

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

Object.prototype

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

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

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

设置与遮蔽属性

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

javascript
myObj.foo = 'bar'

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

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

然而,如果foo已经存在于链条更高层的某处,myObj.foo = 'bar'赋值就可能会发生微妙的(也许令人诧异的)行为。

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

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

  1. 如果一个普通的名为foo的数据访问属性在[[Prototype]]链的高层某处被找到,而且没有被标记为只读(也就是 writable: true),那么一个名为foo的新属性就直接添加到myObj上,形成一个遮蔽属性
Details
javascript
const obj = { foo: 2 }
const myObj = Object.create(obj)
myObj.foo = 123
console.log(myObj.foo, obj.foo) // 123 2
  1. 如果一个foo[[Prototype]]链的高层某处被找到,但是它被标记为只读(writable: false),那么设置既存属性和在myObj上创建遮蔽属性都是不允许的。如果代码运行在strict mode下,一个错误会被抛出。否则,这个设置属性值的操作会被无声地忽略。不论怎样,没有发生遮蔽
Details
javascript
const obj = {}
Object.defineProperty(obj, 'foo', {
  value: 2,
  writable: false,
  configurable: true,
  enumerable: true,
})
const myObj = Object.create(obj)
myObj.foo = 123
console.log(myObj, obj) // {} { foo: 2}
  1. 如果一个foo[[Prototype]]链的高层某处被找到,而且它是一个 setter,那么这个 setter 总是被调用。没有foo会被添加到(也就是遮蔽在)myObj上,这个foosetter 也不会被重定义。
Details
javascript
const obj = {
  set foo(value) {
    console.log('set foo:', value)
  },
}
const myObj = Object.create(obj)
myObj.foo = 123 // set foo: 123
console.log(myObj) // {}

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

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

NOTE

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

Details
javascript
const obj = {}
Object.defineProperty(obj, 'foo', {
  value: 2,
  writable: false,
  configurable: true,
  enumerable: true,
})

const myObj = Object.create(obj)

myObj.foo = 123
console.log(myObj, obj) // {} { foo: 2}

Object.defineProperty(myObj, 'foo', {
  value: 123,
  writable: true,
  configurable: true,
  enumerable: true,
})
console.log(myObj, obj) // {foo: 123} { foo: 2}

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

遮蔽甚至会以微妙的方式隐含发生,所以想要避免它必须小心。

javascript
const obj = { a: 2 }

const myObj = Object.create(obj)

console.log(obj.a) // 2
console.log(myObj.a) // 2

console.log(obj.hasOwnProperty('a')) // true
console.log(myObj.hasOwnProperty('a')) // false

myObj.a++ // 隐含遮蔽!

console.log(obj.a) // 2
console.log(myObj.a) // 3

console.log(myObj.hasOwnProperty('a')) // true

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

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

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

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

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

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

“类”函数

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

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

javascript
function Foo() {}

Foo.prototype // { constructor: ... }

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

不管如何称呼它,这个对象到底是什么?

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

javascript
function Foo() {}

const a = new Foo()
console.log(Object.getPrototypeOf(f) === Foo.prototype) // true

当通过调用new Foo创建a时,会发生的事情之一是,a得到一个内部[[Prototype]]链接,此链接链到Foo.prototype所指向的对象。

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

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

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

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

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

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

名称的意义何在?

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

links between objects

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

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

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

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

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

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

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

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

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

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

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

“构造器”(constructor)

javascript
function Foo() {}
const a = new Foo()

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

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

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

javascript
function Foo() {}
Foo.prototype.constructor === Foo // true

const a = new Foo()
a.constructor === Foo // true

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

NOTE

这实际上不是真的。a上没有.constructor属性,而a.constructor确实解析成了Foo函数,“constructor”并不像它看起来的那样实际意味着“被 XX 创建”。

哦,是的,另外... 根据 JS 世界中的惯例,“类”都以大写字母开头的单词命名,所以使用Foo而不是foo强烈地意味着我们打算让它成为一个“类”。

NOTE

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

构造器还是调用?

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

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

举个例子:

javascript
function NothingSpecial() {
  console.log("Don't mind me!")
}

const a = new NothingSpecial() // Don't mind me!

a // {}

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

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

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

机制

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

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

javascript
function Foo(name) {
  this.name = name
}
Foo.prototype.myName = function () {
  return this.name
}

const a = new Foo('a')
const b = new Foo('b')

a.myName() // a
b.myName() // b

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

  1. this.name = name:在每个对象(分别在ab上;)上添加了.name属性,和类的实例包装数据值很相似。

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

在上边代码中,有很强的倾向认为ab被创建时,Foo.prototype上的属性/函数被拷贝到了ab两个对象上。但是,这没有发生

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

于是,得益于它们被创建的方式,ab都最终拥有一个内部的[[Prototype]]链接链到Foo.prototype。当无法分别在ab中找到时,就会在Foo.prototype上找到。

复活“构造器”

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

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

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

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

javascript
function Foo() {}
Foo.prototype = {}

const a = new Foo()
a.constructor === Foo // false
a.constructor === Object // true

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

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

误解,消除

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

javascript
function Foo() {}
Foo.prototype = {}
Object.defineProperty(Foo.prototype, 'constructor', {
  enumerable: false,
  writable: true,
  configurable: true,
  value: Foo,
})

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

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

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

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

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

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

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

“(原型)继承”

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

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

links between objects

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

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

javascript
function Foo(name) {
  this.name = name
}
Foo.prototype.myName = function () {
  return this.name
}
function Bar(name, label) {
  Foo.call(this, name)
  this.label = label
}
// 创建一个新的`Bar.prototype` 链接到 `Foo.prototype`
Bar.prototype = Object.create(Foo.prototype)
// 但是,现在的`Bar.prototype.constructor`不存在了,
// 如果有依赖这个属性的习惯的话,它可以被手动“修复”。
Bar.prototype.myLabel = function () {
  return this.label
}
const a = new Bar('a', 'obj a')
a.myName() // a
a.myLabel() // obj a

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

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

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

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

javascript
// 不会如你期望的那样工作!
Bar.prototype = Foo.prototype

// 会如你期望工作,但会带有你可能不想要的副作用
Bar.prototype = new Foo()

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

javascript
// ES6 之前
// 扔掉默认既存的`Bar.prototype`(Bar实例的`constructor`指向`Foo`)
Bar.prototype = Object.create(Foo.prototype)
// ES6+
// 修改既存的`Bar.prototype`(Bar实例的`constructor`指向`Bar`)
Object.setPrototypeOf(Bar.prototype, Foo.prototype)

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

考察“类”关系

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

javascript
function Foo(){}
Foo.prototype.blah = ...
const a = new Foo()

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

javascript
a instanceof Foo // true

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

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

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

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

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

javascript
// 用来检查`o1`是否关联到(委托至)`o2`的帮助函数
function isRelatedTo(o1, o2) {
  function F() {}
  F.prototype = o2
  return o1 instanceof F
}
const a = {}
const b = Object.create(a)
isRelatedTo(b, a) // true

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

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

javascript
Foo.prototype.isPrototypeOf(a) // true

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

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

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

javascript
b.isPrototypeOf(a) // true

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

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

javascript
Object.getPrototypeOf(a)

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

javascript
Object.getPrototypeOf(a) === Foo.prototype // true

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

javascript
a.__proto__ === Foo.prototype // true

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

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

而且,.__proto__虽然看起来像一个属性,但实际上将它看作是一个 getter/setter 更合适。

大致地,我们可以这样描述.__proto__的实现:

javascript
Object.defineProperty(Object.prototype, '__proto__', {
  get() {
    return Object.getPrototypeOf(this)
  },
  set(o) {
    // Object.setPrototypeOf(obj, [[prototype]])
    Object.setPrototypeOf(this, o)
    return o
  },
})

所以,当我们访问a.__proto__(取得它的值)时,就好像调用a._proto__()(调用 getter 函数)一样。虽然 getter 函数存在于Object.prototype上,但这个函数调用将a用作它的this,所以它相当于在说Object.getPrototypeOf(a)

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

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

NOTE

在 ES6 中,关键字class将允许某些近似方法,对像Array这样的内建类型“定义子类”。

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

NOTE

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

对象链接

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

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

创建链接

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

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

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

javascript
var foo = {
  something() {
    console.log('Tell me something good...')
  },
}
var bar = Object.create(foo)
bar.something() // Tell me something good...

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

NOTE

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

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

填补Object.create()

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

javascript
if (!Object.create) {
  Object.create = function (o) {
    function F() {}
    F.prototype = o
    return new F()
  }
}

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

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

javascript
var obj = { a: 2 }
var obj2 = Object.create(obj, {
  b: {
    enumerable: false,
    writeable: true,
    configurable: false,
    value: 3,
  },
  c: {
    enumerable: true,
    writeable: false,
    configurable: false,
    value: 4,
  },
})
console.log(obj2.hasOwnProperty('a')) // false
console.log(obj2.hasOwnProperty('b')) // true
console.log(obj2.hasOwnProperty('c')) // true
console.log(obj2.a) // 2
console.log(obj2.b) // 3
console.log(obj2.c) // 4

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

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

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

javascript
function createAndLinkObject(o) {
  function F() {}
  F.prototype = o
  return new F()
}
var obj = { a: 2 }
var obj2 = createAndLinkObject(obj)
console.log(obj2.a) // 2

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

链接作为候补?

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

考虑下面的代码:

javascript
var obj = {
  cool() {
    console.log('cool!')
  },
}
var obj2 = Object.create(obj)
obj2.cool() // cool!

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

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

NOTE

在 ES6 中,引入了一个称为Proxy(代理)的高级功能,它可以提供某种“方法未找到”类型的行为。

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

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

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

javascript
var obj = {
  cool() {
    console.log('cool!')
  },
}
var obj2 = Object.create(obj)
obj2.doCool = function () {
  this.cool() // 内部委托! internal delegation!
}
obj2.doCool() // cool!

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

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

复习

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

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

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

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

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

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

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