5.1 [[Prototype]]
JavaScript 中的对象有一个特殊的 [[Prototype]] 内置属性,其实就是对于其他对象的引用。几乎所有的对象在创建时 [[Prototype]] 属性都会被赋予一个非空的值。
注意:很快我们就可以看到,对象的 [[Prototype]] 链接可以为空,虽然很少见。
思考下面的代码:
1 | var myObject = { |
[[Prototype]] 引用有什么用呢?在第 3 章中我们说过,当你试图引用对象的属性时会触发[[Get]] 操作,比如 myObject.a。对于默认的 [[Get]] 操作来说,第一步是检查对象本身是否有这个属性,如果有的话就使用它。
ES6 中的 Proxy 超出了本书的范围(但是在本系列之后的书中会介绍),但是要注意,如果包含 Proxy 的话,我们这里对 [[Get]] 和 [[Put]] 的讨论就不适用。
但是如果 a 不在 myObject 中,就需要使用对象的 [[Prototype]] 链了。
对于默认的 [[Get]] 操作来说,如果无法在对象本身找到需要的属性,就会继续访问对象的 [[Prototype]] 链:
1 | var anotherObject = { |
稍后我们会介绍 Object.create(..) 的原理,现在只需要知道它会创建一个对象并把这个对象的 [[Prototype]] 关联到指定的对象。
现在 myObject 对象的 [[Prototype]] 关联到了 anotherObject。显然 myObject.a 并不存在,
但是尽管如此,属性访问仍然成功地(在 anotherObject 中)找到了值 2。
但是,如果 anotherObject 中也找不到 a 并且 [[Prototype]] 链不为空的话,就会继续查找下去。
这个过程会持续到找到匹配的属性名或者查找完整条 [[Prototype]] 链。如果是后者的话,[[Get]] 操作的返回值是 undefined。
使用 for..in 遍历对象时原理和查找 [[Prototype]] 链类似,任何可以通过原型链访问到(并且是 enumerable,参见第 3 章)的属性都会被枚举。使用 in 操作符来检查属性在对象中是否存在时,同样会查找对象的整条原型链(无论属性是否可枚举):
1 | var anotherObject = { |
因此,当你通过各种语法进行属性查找时都会查找[[Prototype]] 链,直到找到属性或者
查找完整条原型链。
5.1.1 Object.prototype
但是到哪里是[[Prototype]] 的“尽头”呢?
所有普通的[[Prototype]] 链最终都会指向内置的Object.prototype。由于所有的“普通”(内置,不是特定主机的扩展)对象都“源于”(或者说把[[Prototype]] 链的顶端设置为)这个Object.prototype 对象,所以它包含JavaScript 中许多通用的功能。
5.1.2 属性设置和屏蔽
第3 章提到过,给一个对象设置属性并不仅仅是添加一个新属性或者修改已有的属性值。现在我们完整地讲解一下这个过程:
1 | myObject.foo = "bar"; |
如果myObject 对象中包含名为foo 的普通数据访问属性,这条赋值语句只会修改已有的属性值。
如果foo 不是直接存在于myObject 中,[[Prototype]] 链就会被遍历,类似[[Get]] 操作。如果原型链上找不到foo,foo 就会被直接添加到myObject 上。
然而,如果foo 存在于原型链上层,赋值语句myObject.foo = “bar” 的行为就会有些不同(而且可能很出人意料)。稍后我们会进行介绍。
如果属性名foo 既出现在myObject 中也出现在myObject 的[[Prototype]] 链上层, 那么就会发生屏蔽。myObject 中包含的foo 属性会屏蔽原型链上层的所有foo 属性,因为myObject.foo 总是会选择原型链中最底层的foo 属性。
屏蔽比我们想象中更加复杂。下面我们分析一下如果foo 不直接存在于myObject 中而是存在于原型链上层时myObject.foo = “bar” 会出现的三种情况:
- 如果在[[Prototype]] 链上层存在名为foo 的普通数据访问属性(参见第3 章)并且没有被标记为只读(writable:false),那就会直接在myObject 中添加一个名为foo 的新属性,它是屏蔽属性。
- 如果在[[Prototype]] 链上层存在foo,但是它被标记为只读(writable:false),那么无法修改已有属性或者在myObject 上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。
- 如果在[[Prototype]] 链上层存在foo 并且它是一个setter(参见第3 章),那就一定会调用这个setter。foo 不会被添加到(或者说屏蔽于)myObject,也不会重新定义foo 这个setter。
大多数开发者都认为如果向[[Prototype]] 链上层已经存在的属性([[Put]])赋值,就一定会触发屏蔽,但是如你所见,三种情况中只有一种(第一种)是这样的。
如果你希望在第二种和第三种情况下也屏蔽foo,那就不能使用 = 操作符来赋值,而是使用Object.defineProperty(..)(参见第3 章)来向myObject 添加foo。
5.2 “类”
现在你可能会很好奇:为什么一个对象需要关联到另一个对象?这样做有什么好处?这个问题非常好,但是在回答之前我们首先要理解[[Prototype]]“不是”什么。
第4 章中我们说过,JavaScript 和面向类的语言不同,它并没有类来作为对象的抽象模式或者说蓝图。JavaScript 中只有对象。
实际上,JavaScript 才是真正应该被称为“面向对象”的语言,因为它是少有的可以不通过类,直接创建对象的语言。
在JavaScript 中,类无法描述对象的行,(因为根本就不存在类!)对象直接定义自己的行为。再说一遍,JavaScript 中只有对象。
5.2.1 “类”函数
多年以来,JavaScript 中有一种奇怪的行为一直在被无耻地滥用,那就是模仿类。我们会仔细分析这种方法。
这种奇怪的“类似类”的行为利用了函数的一种特殊特性:所有的函数默认都会拥有一个名为prototype 的公有并且不可枚举(参见第3 章)的属性,它会指向另一个对象:
1 | function Foo() { |
这个对象通常被称为Foo 的原型,因为我们通过名为Foo.prototype 的属性引用来访问它。然而不幸的是,这个术语对我们造成了极大的误导,稍后我们就会看到。如果是我的话就会叫它“之前被称为Foo 的原型的那个对象”。好吧我是开玩笑的,你觉得“被贴上‘Foo点prototype’标签的对象”这个名字怎么样?
抛开名字不谈,这个对象到底是什么?
最直接的解释就是,这个对象是在调用new Foo()(参见第2 章)时创建的,最后会被(有点武断地)关联到这个“Foo 点prototype”对象上。
我们来验证一下:
1 | function Foo() { |
调用new Foo() 时会创建a(具体的4 个步骤参见第2 章),其中的一步就是给a 一个内部的[[Prototype]] 链接,关联到Foo.prototype 指向的那个对象。
暂停一下,仔细思考这条语句的含义:
在面向类的语言中,类可以被复制(或者说实例化)多次,就像用模具制作东西一样。我们在第4 章中看到过,之所以会这样是因为实例化(或者继承)一个类就意味着“把类的行为复制到物理对象中”,对于每一个新实例来说都会重复这个过程。
但是在JavaScript 中,并没有类似的复制机制。你不能创建一个类的多个实例,只能创建多个对象,它们[[Prototype]] 关联的是同一个对象。但是在默认情况下并不会进行复制,因此这些对象之间并不会完全失去联系,它们是互相关联的。
new Foo() 会生成一个新对象(我们称之为a),这个新对象的内部链接[[Prototype]] 关联的是Foo.prototype 对象。
最后我们得到了两个对象,它们之间互相关联,就是这样。我们并没有初始化一个类,实际上我们并没有从“类”中复制任何行为到一个对象中,只是让两个对象互相关联。
实际上,绝大多数JavaScript 开发者不知道的秘密是,new Foo() 这个函数调用实际上并没有直接创建关联,这个关联只是一个意外的副作用。new Foo() 只是间接完成了我们的目标:一个关联到其他对象的新对象。
那么有没有更直接的方法来做到这一点呢?当然!功臣就是Object.create(..),不过我们现在暂时不介绍它。
关于名称
在JavaScript 中,我们并不会将一个对象(“类”)复制到另一个对象(“实例”),只是将它们关联起来。从视觉角度来说,[[Prototype]] 机制如下图所示,箭头从右到左,从下到上:

这个机制通常被称为原型继承(稍后我们会分析具体代码),它常常被视为动态语言版本的类继承。这个名称主要是为了对应面向类的世界中“继承”的意义,但是违背(写作违背,读作推翻)了动态脚本中对应的语义。
5.2.2 “构造函数”
好了,回到之前的代码:
1 | function Foo() { |
到底是什么让我们认为Foo 是一个“类”呢?
其中一个原因是我们看到了关键字new,在面向类的语言中构造类实例时也会用到它。另一个原因是,看起来我们执行了类的构造函数方法,Foo() 的调用方式很像初始化类时类构造函数的调用方式。
除了令人迷惑的“构造函数”语义外,Foo.prototype 还有另一个绝招。思考下面的代码:
1 | function Foo() { |
Foo.prototype 默认(在代码中第一行声明时!)有一个公有并且不可枚举(参见第3 章)的属性.constructor,这个属性引用的是对象关联的函数(本例中是Foo)。此外,我们可以看到通过“构造函数”调用new Foo() 创建的对象也有一个.constructor 属性,指向“创建这个对象的函数”。
实际上a 本身并没有.constructor 属性。而且,虽然a.constructor 确实指向Foo 函数,但是这个属性并不是表示a 由Foo“构造”,稍后我们会解释。
哦耶,好吧……按照JavaScript 世界的惯例,“类”名首字母要大写,所以名字写作Foo 而非foo 似乎也提示它是一个“类”。显而易见,是吧?!
构造函数还是调用
上一段代码很容易让人认为Foo 是一个构造函数,因为我们使用new 来调用它并且看到它“构造”了一个对象。
实际上,Foo 和你程序中的其他函数没有任何区别。函数本身并不是构造函数,然而,当你在普通的函数调用前面加上new 关键字之后,就会把这个函数调用变成一个“构造函数调用”。实际上,new 会劫持所有普通函数并用构造对象的形式来调用它。
举例来说:
1 | function NothingSpecial() { |
NothingSpecial 只是一个普通的函数,但是使用new 调用时,它就会构造一个对象并赋值给a,这看起来像是new 的一个副作用(无论如何都会构造一个对象)。这个调用是一个构造函数调用,但是NothingSpecial 本身并不是一个构造函数。
换句话说,在JavaScript 中对于“构造函数”最准确的解释是,所有带new 的函数调用。
函数不是构造函数,但是当且仅当使用new 时,函数调用会变成“构造函数调用”。
5.2.3 技术
我们是不是已经介绍了JavaScript 中所有和“类”相关的问题了呢?
不是。JavaScript 开发者绞尽脑汁想要模仿类的行为:
1 | function Foo(name) { |
这段代码展示了另外两种“面向类”的技巧:
- this.name = name 给每个对象(也就是a 和b,参见第2 章中的this 绑定)都添加了.name 属性,有点像类实例封装的数据值。
- Foo.prototype.myName = … 可能个更有趣的技巧,它会给Foo.prototype 对象添加一个属性(函数)。现在,a.myName() 可以正常工作,但是你可能会觉得很惊讶,这是什么原理呢?
在这段代码中,看起来似乎创建a 和b 时会把Foo.prototype 对象复制到这两个对象中,然而事实并不是这样。
因此,在创建的过程中,a 和b 的内部[[Prototype]] 都会关联到Foo.prototype 上。当a和b 中无法找到myName 时,它会(通过委托,参见第6 章)在Foo.prototype 上找到。
一些随意的对象属性引用,比如a1.constructor,实际上是不被信任的,它们不一定会指向默认的函数引用。此外,很快我们就会看到,稍不留神a1.constructor 就可能会指向你意想不到的地方。
a1.constructor 是一个非常不可靠并且不安全的引用。通常来说要尽量避免使用这些引用。
5.3 (原型)继承
我们已经看过了许多JavaScript 程序中常用的模拟类行为的方法,但是如果没有“继承”机制的话,JavaScript 中的类就只是一个空架子。
实际上,我们已经了解了通常被称作原型继承的机制,a 可以“继承”Foo.prototype 并访问Foo.prototype 的myName() 函数。但是之前我们只把继承看作是类和类之间的关系,并没有把它看作是类和实例之间的关系:

还记得这张图吗,它不仅展示出对象(实例)a1 到Foo.prototype 的委托关系,还展示出Bar.prototype 到Foo.prototype 的委托关系,而后者和类继承很相似,只有箭头的方向不同。图中由下到上的箭头表明这是委托关联,不是复制操作。
下面这段代码使用的就是典型的“原型风格”
1 | function Foo(name) { |
这段代码的核心部分就是语句Bar.prototype = Object.create( Foo.prototype )。调用Object.create(..) 会凭空创建一个“新”对象并把新对象内部的[[Prototype]] 关联到你指定的对象(本例中是Foo.prototype)。
换句话说,这条语句的意思是:“创建一个新的Bar.prototype 对象并把它关联到Foo.prototype”。
声明function Bar() { .. } 时,和其他函数一样,Bar 会有一个.prototype 关联到默认的对象,但是这个对象并不是我们想要的Foo.prototype。因此我们创建了一个新对象并把它关联到我们希望的对象上,直接把原始的关联对象抛弃掉。
注意,下面这两种方式是常见的错误做法,实际上它们都存在一些问题:
1 | // 和你想要的机制不一样! |
Bar.prototype = Foo.prototype 并不会创建一个关联到Bar.prototype 的新对象,它只是让Bar.prototype 直接引用Foo.prototype 对象。因此当你执行类似Bar.prototype.myLabel = … 的赋值语句时会直接修改Foo.prototype 对象本身。显然这不是你想要的结果,否则你根本不需要Bar 对象,直接使用Foo 就可以了,这样代码也会更简单一些。
Bar.prototype = new Foo() 的确会创建一个关联到Bar.prototype 的新对象。但是它使用了Foo(..) 的“构造函数调用”,如果函数Foo 有一些副作用(比如写日志、修改状态、注册到其他对象、给this 添加数据属性,等等)的话,就会影响到Bar() 的“后代”,后果不堪设想。
因此,要创建一个合适的关联对象,我们必须使用Object.create(..) 而不是使用具有副作用的Foo(..)。这样做唯一的缺点就是需要创建一个新对象然后把旧对象抛弃掉,不能直接修改已有的默认对象。
如果能有一个标准并且可靠的方法来修改对象的[[Prototype]] 关联就好了。在ES6 之前,我们只能通过设置.proto 属性来实现,但是这个方法并不是标准并且无法兼容所有浏览器。ES6 添加了辅助函数Object.setPrototypeOf(..),可以用标准并且可靠的方法来修改关联。
我们来对比一下两种把Bar.prototype 关联到Foo.prototype 的方法:
1 | // ES6 之前需要抛弃默认的Bar.prototype |
如果忽略掉Object.create(..) 方法带来的轻微性能损失(抛弃的对象需要进行垃圾回收),它实际上比ES6 及其之后的方法更短而且可读性更高。不过无论如何,这是两种完全不同的语法。
检查“类”关系
假设有对象a,如何寻找对象a 委托的对象(如果存在的话)呢?在传统的面向类环境中,检查一个实例(JavaScript 中的对象)的继承祖先(JavaScript 中的委托关联)通常被称为内省(或者反射)。
思考下面的代码:
1 | function Foo() { |
我们如何通过内省找出a 的“祖先”(委托关联)呢?第一种方法是站在“类”的角度来判断:
1 | a instanceof Foo; // true |
instanceof 操作符的左操作数是一个普通的对象,右操作数是一个函数。instanceof 回答的问题是:在a 的整条[[Prototype]] 链中是否有指向Foo.prototype 的对象?
可惜,这个方法只能处理对象(a)和函数(带.prototype 引用的Foo)之间的关系。如果你想判断两个对象(比如a 和b)之间是否通过[[Prototype]] 链关联,只用instanceof无法实现。
我们也可以直接获取一个对象的[[Prototype]] 链。在ES5 中,标准的方法是:
1 | Object.getPrototypeOf( a ); |
可以验证一下,这个对象引用是否和我们想的一样:
1 | Object.getPrototypeOf( a ) === Foo.prototype; // true |
绝大多数(不是所有!)浏览器也支持一种非标准的方法来访问内部[[Prototype]] 属性:
1 | a.__proto__ === Foo.prototype; // true |
这个奇怪的.proto( 在ES6 之前并不是标准!) 属性“ 神奇地” 引用了内部的[[Prototype]] 对象,如果你想直接查找(甚至可以通过.proto.ptoto… 来遍历)原型链的话,这个方法非常有用。
和我们之前说过的.constructor 一样,.proto 实际上并不存在于你正在使用的对象中(本例中是a)。实际上,它和其他的常用函数(.toString()、.isPrototypeOf(..),等等)一样,存在于内置的Object.prototype 中。(它们是不可枚举的,参见第2 章。)
此外,.proto 看起来很像一个属性,但是实际上它更像一个getter/setter(参见第3章)。
.proto 的实现大致上是这样的(对象属性的定义参见第3 章):
1 | Object.defineProperty( Object.prototype, "__proto__", { |
因此,访问(获取值)a.proto 时,实际上是调用了a.proto()(调用getter 函数)。虽然getter 函数存在于Object.prototype 对象中,但是它的this 指向对象a(this的绑定规则参见第2 章),所以和Object.getPrototypeOf( a ) 结果相同。
.proto 是可设置属性,之前的代码中使用ES6 的Object.setPrototypeOf(..) 进行设置。然而,通常来说你不需要修改已有对象的[[Prototype]]。
我们只有在一些特殊情况下(我们前面讨论过)需要设置函数默认.prototype 对象的[[Prototype]],让它引用其他对象(除了Object.prototype)。这样可以避免使用全新的对象替换默认对象。此外,最好把[[Prototype]] 对象关联看作是只读特性,从而增加代码的可读性。
5.4 对象关联
现在我们知道了,[[Prototype]] 机制就是存在于对象中的一个内部链接,它会引用其他对象。
通常来说,这个链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在[[Prototype]] 关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。
5.4.1 创建关联
我们已经明白了为什么JavaScript 的[[Prototype]] 机制和类不一样,也明白了它如何建立对象间的关联。
那[[Prototype]] 机制的意义是什么呢?为什么JavaScript 开发者费这么大的力气(模拟类)在代码中创建这些关联呢?
还记得吗,本章前面曾经说过Object.create(..) 是一个大英雄,现在是时候来弄明白为什么了:
1 | var foo = { |
Object.create(..) 会创建一个新对象(bar)并把它关联到我们指定的对象(foo),这样我们就可以充分发挥[[Prototype]] 机制的威力(委托)并且避免不必要的麻烦(比如使用new 的构造函数调用会生成.prototype 和.constructor 引用)。
Object.create(null) 会创建一个拥有空( 或者说null)[[Prototype]]链接的对象,这个对象无法进行委托。由于这个对象没有原型链,所以instanceof 操作符(之前解释过)无法进行判断,因此总是会返回false。这些特殊的空[[Prototype]] 对象通常被称作“字典”,它们完全不会受到原型链的干扰,因此非常适合用来存储数据。
我们并不需要类来创建两个对象之间的关系,只需要通过委托来关联对象就足够了。而Object.create(..) 不包含任何“类的诡计”,所以它可以完美地创建我们想要的关联关系。
Object.create()的polyfill代码Object.create(..) 是在ES5 中新增的函数,所以在ES5 之前的环境中(比如旧IE)如果要支持这个功能的话就需要使用一段简单的polyfill 代码, 它部分实现了Object.create(..) 的功能:
1 | if (!Object.create) { |
这段polyfill 代码使用了一个一次性函数F,我们通过改写它的.prototype 属性使其指向想要关联的对象,然后再使用new F() 来构造一个新对象进行关联。
由于Object.create(..c) 可以被模拟,因此这个函数被应用得非常广泛。标准ES5 中内置的Object.create(..) 函数还提供了一系列附加功能,但是ES5 之前的版本不支持这些功能。通常来说,这些功能的应用范围要小得多,但是出于完整性考虑,我们还是介绍一下:
1 | var anotherObject = { |
Object.create(..) 的第二个参数指定了需要添加到新对象中的属性名以及这些属性的属性描述符(参见第3 章)。因为ES5 之前的版本无法模拟属性操作符,所以polyfill 代码无法实现这个附加功能。
通常来说并不会使用Object.create(..) 的附加功能,所以对于大多数开发者来说,上面那段polyfill 代码就足够了。
5.4.2 关联关系是备用
看起来对象之间的关联关系是处理“缺失”属性或者方法时的一种备用选项。这个说法有点道理,但是我认为这并不是[[Prototype]] 的本质。
思考下面的代码:
1 | var anotherObject = { |
由于存在[[Prototype]] 机制,这段代码可以正常工作。但是如果你这样写只是为了让myObject 在无法处理属性或者方法时可以使用备用的anotherObject,那么你的软件就会变得有点“神奇”,而且很难理解和维护。
这并不是说任何情况下都不应该选择备用这种设计模式,但是这在JavaScript 中并不是很常见。所以如果你使用的是这种模式,那或许应当退后一步并重新思考一下这种模式是否合适。
在ES6 中有一个被称为“代理”(Proxy)的高端功能,它实现的就是“方法无法找到”时的行为。
千万不要忽略这个微妙但是非常重要的区别。
当你给开发者设计软件时,假设要调用myObject.cool(),如果myObject 中不存在cool()时这条语句也可以正常工作的话,那你的API 设计就会变得很“神奇”,对于未来维护你软件的开发者来说这可能不太好理解。
但是你可以让你的API 设计不那么“神奇”,同时仍然能发挥[[Prototype]] 关联的威力:
1 | var anotherObject = { |
这里我们调用的myObject.doCool() 是实际存在于myObject 中的,这可以让我们的API 设计更加清晰(不那么“神奇”)。从内部来说,我们的实现遵循的是委托设计模式(参见第6 章),通过[[Prototype]] 委托到anotherObject.cool()。
换句话说,内部委托比起直接委托可以让API 接口设计更加清晰。下一章我们会详细解释委托。
5.5 小结
如果要访问对象中并不存在的一个属性,[[Get]] 操作(参见第3 章)就会查找对象内部[[Prototype]] 关联的对象。这个关联关系实际上定义了一条“原型链”(有点像嵌套的作用域链),在查找属性时会对它进行遍历。
所有普通对象都有内置的Object.prototype,指向原型链的顶端(比如说全局作用域),如果在原型链中找不到指定的属性就会停止。toString()、valueOf() 和其他一些通用的功能都存在于Object.prototype 对象上,因此语言中所有的对象都可以使用它们。
关联两个对象最常用的方法是使用new 关键词进行函数调用,在调用的4 个步骤(第2章)中会创建一个关联其他对象的新对象。
使用new 调用函数时会把新对象的.prototype 属性关联到“其他对象”。带new 的函数调用通常被称为“构造函数调用”,尽管它们实际上和传统面向类语言中的类构造函数不一样。
虽然这些JavaScript 机制和传统面向类语言中的“类初始化”和“类继承”很相似,但是JavaScript 中的机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的[[Prototype]] 链关联的。
出于各种原因,以“继承”结尾的术语(包括“原型继承”)和其他面向对象的术语都无法帮助你理解JavaScript 的真实机制(不仅仅是限制我们的思维模式)。
相比之下,“委托”是一个更合适的术语,因为对象之间的关系不是复制而是委托。
完~