“(原型)继承”

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

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

“(原型)继承” - 图1

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

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

  1. function Foo(name) {
  2. this.name = name;
  3. }
  4. Foo.prototype.myName = function() {
  5. return this.name;
  6. };
  7. function Bar(name,label) {
  8. Foo.call( this, name );
  9. this.label = label;
  10. }
  11. // 这里,我们创建一个新的 `Bar.prototype` 链接链到 `Foo.prototype`
  12. Bar.prototype = Object.create( Foo.prototype );
  13. // 注意!现在 `Bar.prototype.constructor` 不存在了,
  14. // 如果你有依赖这个属性的习惯的话,它可以被手动“修复”。
  15. Bar.prototype.myLabel = function() {
  16. return this.label;
  17. };
  18. var a = new Bar( "a", "obj a" );
  19. a.myName(); // "a"
  20. a.myLabel(); // "obj a"

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

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

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

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

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

  1. // 不会如你期望的那样工作!
  2. Bar.prototype = Foo.prototype;
  3. // 会如你期望的那样工作
  4. // 但会带有你可能不想要的副作用 :(
  5. 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

  1. // ES6 以前
  2. // 扔掉默认既存的 `Bar.prototype`
  3. Bar.prototype = Object.create( Foo.prototype );
  4. // ES6+
  5. // 修改既存的 `Bar.prototype`
  6. Object.setPrototypeOf( Bar.prototype, Foo.prototype );

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

考察“类”关系

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

考虑下面的代码:

  1. function Foo() {
  2. // ...
  3. }
  4. Foo.prototype.blah = ...;
  5. var a = new Foo();

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

  1. a instanceof Foo; // true

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

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

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

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

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

  1. // 用来检查 `o1` 是否关联到(委托至)`o2` 的帮助函数
  2. function isRelatedTo(o1, o2) {
  3. function F(){}
  4. F.prototype = o2;
  5. return o1 instanceof F;
  6. }
  7. var a = {};
  8. var b = Object.create( a );
  9. isRelatedTo( b, a ); // true

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

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

  1. Foo.prototype.isPrototypeOf( a ); // true

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

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

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

  1. // 简单地:`b` 在 `c` 的 `[[Prototype]]` 链中出现过吗?
  2. b.isPrototypeOf( c );

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

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

  1. Object.getPrototypeOf( a );

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

  1. Object.getPrototypeOf( a ) === Foo.prototype; // true

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

  1. a.__proto__ === Foo.prototype; // true

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

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

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

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

  1. Object.defineProperty( Object.prototype, "__proto__", {
  2. get: function() {
  3. return Object.getPrototypeOf( this );
  4. },
  5. set: function(o) {
  6. // ES6 的 setPrototypeOf(..)
  7. Object.setPrototypeOf( this, o );
  8. return o;
  9. }
  10. } );

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

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

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

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

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

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