原文:A Plain English Guide to JavaScript Prototypes
当初我刚学JavaScript的对象模型的时候,我完全被吓到了,而且觉得这个东西很不靠谱。因为这是我第一次接触基于原型的编程语言,所以我被JavaScript的原型特性弄得晕头转向。JavaScript对于原型的实现有其自己独特的一面,就是添加了函数构造器(function constructor),不过这并没能简化学习过程。我敢打赌你们中的大多数人也有类似经历。
但是当我用得多了之后,我不仅理解了JavaScript的对象模型,我也开始喜欢上其中的某些部分了。多亏了JavaScript,它令我发现了原型语言的优雅与灵活之处。我现在可以说非常痴迷于原型语言,因为在对象模型的实现方面,它比基于类的编程语言更加简单和灵活。
JavaScript里的原型(prototype)
大多数教程在解释JavaScript的对象的时候,往往是从构造函数(constructor functions)入手,我觉得这是错误的做法,因为这样做过早引入了太过于复杂的概念,而令JavaScript显得太难于理解。我们不如把这个问题先放在一边,先从原型的一些基础开始讲起。
原型链(prototype chain) ————也称为原型继承(prototype inheritance)
JavaScript的每个对象都有一个原型(prototype)。当一个信息到达一个对象时,JavaScript首先会在这个对象自身上面寻找这个属性,如果没有找到,那么这个单元信息会被发送到这个对象的原型,而这个原型本身又是另一个对象,所以上面的操作被再次执行,这个过程一直这样持续下去直到找到为止。这跟基于类的编程语言里的单继承是一样的。
你想要原型继承链有多长,它就可以有多长,完全由你决定。可是最好不要弄太长,不然你的代码会很难理解,也不好维护。
__proto__对象
理解JavaScript的原型链的最简单的方法莫过于通过__proto__属性。不过很遗憾的是__proto__本身并不在JavaScript的国际标准以内,至少在ES6之前都不是。所以在你的产品代码里不要使用它,但是不管怎么说作为研究学习用途来说,它可以帮助你很容易地理解原型。
// 先创建一个alien对象
>>> var alien = {
kind: 'alien'
}
// 再创建一个person对象
>>> var person = {
kind: 'person'
}
// 创建一个叫“zack”的对象
>>> var zack = {};
// 把alien赋值给zack的原型
>>> zack.__proto__ = alien;
// zack现在与alien关联了起来,它“继承”了alien的所有属性
>>> console.log(zack.kind);
alien
// 现在把zack的原型改为person对象
>>> zack.__proto__ = person;
// 那么现在zack与person关联了起来
>>> console.log(zack.kind);
person
现在你看到了,__proto__的用法非常简单明了。虽然不能在产品代码里直接使用它,不过上面这个例子已经很好地解释了JavaScript的对象模型的最基础的部分。
你可以通过下面的代码来验证一个对象是否是另一个对象的原型:
>>> console.log(person.isPrototypeOf(zack));
true
动态原型查找
你可以随时在一个对象的原型上面添加属性,而原型链查找总能按照你所预计的那样找到这个属性。
>>> var person = {}
>>> var zack = {}
>>> zack.__proto__ = person;
// 在这个时候zack上面还没有kind属性
>>> console.log(zack.kind);
undefined
// 现在给person添加kind属性
>>> person.kind = 'person';
// 现在,在zack上面能找到kind属性了,因为它在person上面找到了“kind”
>>> console.log(zack.kind);
person
在对象上面直接对属性进行添加/更新的操作并不会影响到该对象的原型
如果你在一个对象上面直接更新其原型已经有的一个属性,会怎样?
>>> var person = {
kind: 'person'
}
>>> var zack = {}
>>> zack.__proto__ = person;
>>> zack.kind = 'zack';
>>> console.log(zack.kind);
zack
// zack现在有了自己的“kind”属性
>>> console.log(person.kind);
person
// person并没有被修改
注意,现在“kind”属性同时存在于person和zack上面。
Object.create
之前解释过,__proto__还不是一个被广泛支持的方法。所以目前来说,下一个最简单的操作原型属性的做法是通过Object.create()。它被纳入ES5标准里,就算是那些只支持更古老标准的浏览器和引擎,也可以通过es5-shim来升级到支持这个东东。
>>> var person = {
kind: 'person'
}
// 创建一个新对象,它的prototype是person
>>> var zack = Object.create(person);
>>> console.log(zack.kind);
person
你甚至可以传给Object.create一个对象,来指定你想要在新创建的对象上面增添的属性。
>>> var zack = Object.create(person, {age: {value: 13} });
>>> console.log(zack.age);
13
是的,这个方法有点绕弯子,可是没有别的办法,你可以参见这里。
按照上面介绍的方法创建对象后,有一点需要注意,age属性被添加到zack对象上: >>> console.log(zack.age); 13 可是,在Firebug(2.0.6版本)里输出zack对象,并不会有age的信息: >>> console.log(zack); Object { kind="person"} 如果显式地修改age,也不会改变这个情况: >>> zack.age = 27; >>> console.log(zack); Object { kind="person"} 或者添加另一个属性给zack: >>> zack.height=0; >>> console.log(zack); Object { height=0, kind="person"} |
可是在Chrome的Developer Tool里面,却是正常的: > console.log(zack); Object {age: 13,kind: "person"} |
Object.getPrototype
你可以通过Object.getPrototypeOf来获得一个对象的原型。
>>> var zack = Object.create(person);
>>> Object.getPrototypeOf(zack);
Object { kind="person"}
注意,没有类似Object.setPrototype这样的东西。
构造函数(Constructor Function)
在JavaScript里,构造函数始终是用来构造原型链的主要方法。构造函数的广为盛行主要是因为它的本意就是用来创建新类型,同时它也是创建类型唯一的方法。还有另外一个重要的考量就是,很多JavaScript引擎都对构造函数做了特别的优化。
不幸的是构造函数也很令人头痛,在我看来它是多数新手觉得JavaScript难于掌握的主要原因。不过它确实也是这个语言的很重要一个环节,我们不得不深入理解。
作为构造器的函数
在JavaScript里,一般来说你会这样创建一个函数的实例:
>>> function Foo(){}
>>> var foo = new Foo();
// 现在foo是Foo的一个实例
>>> console.log(foo instanceof Foo);
true
本质上讲,当跟new关键字一起使用时,函数的行为类似工厂,就是说创建新对象。而这些新创建的对象会跟函数的原型关联起来,迟一些再回来细说。所以在JavaScript的语境里,我们称这个对象为这个函数的一个实例。
“this”被隐式赋值
当使用new的时候,JavaScript会“悄悄地”创建一个叫“this”的引用,来指向新创建的对象。在函数执行的最后,它也会悄悄地返回这个引用。
当我们这样做的时候:
function Foo() {
this.kind = 'foo';
}
var foo = new Foo();
foo.kind; //=> "foo"
实际在后面发生的事情更像是这样:
function Foo() {
var this = {}; // 当然,这样使用this是无效的,只是为了演示
this.__proto__ = Foo.prototype;
this.kind = 'foo';
return this;
}
但是要记住,只有在你使用new的时候,“this”才会指向新创建的对象,否则在函数里使用“this”将会是指向一个全局变量(译者注:其实就是window对象)。
函数原型属性(function prototype)
JavaScript里面的每一个函数都有一个特别的属性,叫:“prototype”。
>>> function Foo(){
}
>>> Foo.prototype;
Foo {}
它的名字就有些令人费解,更匪夷所思的是,其实这个“prototype”属性并不是函数的真正的原型(__proto__)。
>>> Foo.__proto__ === Foo.prototype;
false
因为人们会用“原型”(prototype)这个词来称呼不同的东西,所以这样一来也就产生了非常多的误会和混乱。我觉得为了避免混淆,当我们想表达函数的“prototype”属性时,应该称之为“函数原型属性”(the function prototype),而不要仅仅称其为“原型”(prototype)。
函数的prototype属性指向着一个对象,而当用new创建新对象时,新对象的prototype属性就被赋值为这个对象——也就是函数的prototype属性指向的东东。很迷惑,是吧?用个例子来解释或许会好些:
>>> function Person(name) {
this.name = name;
}
// 记住,person函数有一个叫prototype的属性,它其实是个对象,我们可以在这个prototype对象上曾加属性
>>> Person.prototype.kind = 'person';
// 我们用new创建一个新对象
>>> var zack = new Person('Zack');
// 新对象的原型其实指向person.prototype
>>> zack.__proto__ == Person.prototype;
true
// 在新创建的对象上,我们可以访问Person.prototype定义过的属性
>>> zack.kind;
"person"
注意isPrototypeOf()方法与instanceof操作符在用法上的区别,尤其是他们的操作对象: >>> Person.prototype.isPrototypeOf(zack); true >>> Person.isPrototypeOf(zack); false >>> zack instanceof Person; true >>> zack instanceof Person.prototype; TypeError: invalid 'instanceof' operand Person.prototype |
基本上,JavaScript的对象模型的所有重点都在这里了。彻悟__proto__与function.prototype之间的密切关系会给你带来无穷无尽的乐趣和满足,不过也可能相反。