“(原型)继承”
我们已经看到了一些近似的“类”机制黑进 JavaScript 程序。但是如果我们没有一种近似的“继承”,JavaScript 的“类”将会更空洞。
实际上,我们已经看到了一个常被称为“原型继承”的机制如何工作:a
可以“继承自” Foo.prototype
,并因此可以访问 myName()
函数。但是我们传统的想法认为“继承”是两个“类”间的关系,而非“类”与“实例”的关系。
回想之前这幅图,它不仅展示了从对象(也就是“实例”)a1
到对象 Foo.prototype
的委托,而且从 Bar.prototype
到 Foo.prototype
,这酷似类继承的亲自概念。酷似,除了方向,箭头表示的是委托链接,而不是拷贝操作。
这里是一段典型的创建这样的链接的“原型风格”代码:
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;
};
var a = new Bar( "a", "obj a" );
a.myName(); // "a"
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
。所以,我们创建了一个 新 对象,链到我们希望的地方,并将原来的错误链接的对象扔掉。
注意: 这里一个常见的误解/困惑是,下面两种方法 也 能工作,但是他们不会如你期望的那样工作:
// 不会如你期望的那样工作!
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
:
// ES6 以前
// 扔掉默认既存的 `Bar.prototype`
Bar.prototype = Object.create( Foo.prototype );
// ES6+
// 修改既存的 `Bar.prototype`
Object.setPrototypeOf( Bar.prototype, Foo.prototype );
如果忽略 Object.create(..)
方式在性能上的轻微劣势(扔掉一个对象,然后被回收),其实它相对短一些而且可能比 ES6+ 的方式更易读。但两种方式可能都只是语法表面现象。
考察“类”关系
如果你有一个对象 a
并且希望找到它委托至哪个对象呢(如果有的话)?考察一个实例(一个 JS 对象)的继承血统(在 JS 中是委托链接),在传统的面向类环境中称为 自省(introspection)(或 反射(reflection))。
考虑下面的代码:
function Foo() {
// ...
}
Foo.prototype.blah = ...;
var a = new Foo();
那么我们如何自省 a
来找到它的“祖先”(委托链)呢?一种方式是接受“类”的困惑:
a instanceof Foo; // true
instanceof
操作符的左侧操作数接收一个普通对象,右侧操作数接收一个 函数。instanceof
回答的问题是:在 a
的整个 [[Prototype]]
链中,有没有出现那个被 Foo.prototype
所随便指向的对象?
不幸的是,这意味着如果你拥有可以用于测试的 函数(Foo
,和它带有的 .prototype
引用),你只能查询某些对象(a
)的“祖先”。如果你有两个任意的对象,比如 a
和 b
,而且你想调查是否 这些对象 通过 [[Prototype]]
链相互关联,单靠 instanceof
帮不上什么忙。
注意: 如果你使用内建的 .bind(..)
工具来制造一个硬绑定的函数(见第二章),这个被创建的函数将不会拥有 .prototype
属性。将 instanceof
与这样的函数一起使用时,将会透明地替换为创建这个硬绑定函数的 目标函数 的 .prototype
。
将硬绑定函数用于“构造器调用”十分罕见,但如果你这么做,它会表现得好像是 目标函数 被调用了,这意味着将 instanceof
与硬绑定函数一起使用也会参照原版函数。
下面这段代码展示了试图通过“类”的语义和 instanceof
来推导 两个对象 间的关系是多么荒谬:
// 用来检查 `o1` 是否关联到(委托至)`o2` 的帮助函数
function isRelatedTo(o1, o2) {
function F(){}
F.prototype = o2;
return o1 instanceof F;
}
var a = {};
var b = Object.create( a );
isRelatedTo( b, a ); // true
在 isRelatedTo(..)
内部,我们借用一个一次性的函数 F
,重新对它的 .prototype
赋值,使它随意地指向某个对象 o2
,之后问 o1
是否是 F
的“一个实例”。很明显,o1
实际上不是继承或遗传自 F
,甚至不是由 F
构建的,所以显而易见这种做法是愚蠢且让人困惑的。这个问题归根结底是将类的语义强加于 JavaScript 的尴尬,在这个例子中是由 instanceof
的间接语义揭露的。
第二种,也是更干净的方式,[[Prototype]]
反射:
Foo.prototype.isPrototypeOf( a ); // true
注意在这种情况下,我们并不真正关心(甚至 不需要)Foo
,我们仅需要一个 对象(在我们的例子中被随意标志为 Foo.prototype
)来与另一个 对象 测试。isPrototypeOf(..)
回答的问题是:在 a
的整个 [[Prototype]]
链中,Foo.prototype
出现过吗?
同样的问题,和完全同样的答案。但是在第二种方式中,我们实际上不需要间接地引用一个 .prototype
属性将被自动查询的 函数(Foo
)。
我们 只需要 两个 对象 来考察它们之间的关系。比如:
// 简单地:`b` 在 `c` 的 `[[Prototype]]` 链中出现过吗?
b.isPrototypeOf( c );
注意,这种方法根本不要求有一个函数(“类”)。它仅仅使用对象的直接引用 b
和 c
,来查询他们的关系。换句话说,我们上面的 isRelatedTo(..)
工具是内建在语言中的,它的名字叫 isPrototypeOf(..)
。
我们也可以直接取得一个对象的 [[Prototype]]
。在 ES5 中,这么做的标准方法是:
Object.getPrototypeOf( a );
而且你将注意到对象引用是我们期望的:
Object.getPrototypeOf( a ) === Foo.prototype; // true
大多数浏览器(不是全部!)还一种长期支持的,非标准方法可以访问内部的 [[Prototype]]
:
a.__proto__ === Foo.prototype; // true
这个奇怪的 .__proto__
(直到 ES6 才被标准化!)属性“魔法般地”取得一个对象内部的 [[Prototype]]
作为引用,如果你想要直接考察(甚至遍历:.__proto__.__proto__...
)[[Prototype]]
链,这个引用十分有用。
和我们早先看到的 .constructor
一样,.__proto__
实际上不存在于你考察的对象上(在我们的例子中是 a
)。事实上,它和其他的共通工具在一起(.toString()
, .isPrototypeOf(..)
, 等等),存在于(不可枚举地;见第二章)内建的 Object.prototype
上。
而且,.__proto__
虽然看起来像一个属性,但实际上将它看做是一个 getter/setter(见第三章)更合适。
大致地,我们可以这样描述 .__proto__
的实现(见第三章,对象属性的定义):
Object.defineProperty( Object.prototype, "__proto__", {
get: function() {
return Object.getPrototypeOf( this );
},
set: function(o) {
// ES6 的 setPrototypeOf(..)
Object.setPrototypeOf( this, o );
return o;
}
} );
所以,当我们访问 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”。