本篇全面分析下JavaScript的原型系统。
最标准的使用原型的代码如下图。这张图包括了对象和原型之间的引用关系。红色的链条就是原型链,按照规则foo会从其原型对象Foo.prototype里获得add属性,add是个函数对象。foo的属性toString和valueOf都继承自Foo.prototype,后者也没有实现这两个方法而是继承自Object.prototype。
1 原型
首先要明确原型是一个对象,目的是为了给新创建的对象快速设置属性。书中一般这么描述原型:"对象都从原型继承属性"。这句话听上去很好理解。但是其真实意思是:"每一个对象都通过引用(非拷贝)方式直接使用了原型的属性",这就是说原型里的属性如果发生了改变,那在对象里继承来的那个属性也会跟随一起改变!因为对象引用了原型的属性,而不是拷贝来的。下面的代码,我们给所有对象的原型Object.prototype设置了属性a,那么所有的对象就有了这个属性。当修改Object.prototype时,x.a依然会跟随改变!
Object.prototype.a = 1; var x = {}; x.a; // ==> 1 Object.prototype.a = 2; x.a; // ==> 2
有时我们会说对象foo的原型,但是foo本来是没有prototype这个属性的(__proto__后面会讲到),如果要引用到其原型对象必须要使用Foo.prototype。另外根据图上的剪头走向很容易得出这个等式 foo.constructor === Foo.prototype.constructor === Foo
2 继承属性的写入
如果向原型继承来的那个属性里写入值,会导致新建一个属性。原型里的属性是不变的。这是JavaScript的一个重要特性。对于从原型继承来的属性,读和写并不是对称的。当对象的新属性创建后,如果再次读取这个属性时,会按照变量查找规则,优先查找本对象的属性,于是刚创建的那个属性被返回了。下面的代码示例里x.a有了新的值2,但是Object.prototype还是1
Object.prototype.a = 1; var x = {}; x.a = 2; // ==> 1 console.log(Object.prototype.a); // ==> 1 console.log(x.a); // ==> 1
3 添加还是覆盖
在标准版代码中,我们通过为Foo.prototype添加了一个add属性达到对象共用函数的目的,常见的另一种写法是抛弃原来的原型而重新建立一个用直接量表示的对象,这个对象里包含要被继承的属性,像下面这样
Foo.prototype = { add: function (x, y) { return x + y; } }这样会产生一个不大不小的问题,那就是foo.constructor的值不正确,同时Foo.prototype.constructor的值也不正确。他们本来都应该等于Foo,但是上面的函数执行后,他们变成了Object。为什么呢?因为通过直接量创建的对象的原型都是Object.prototype,当然其constructor属性也就是Object了,原型的constructor属性会被foo继承,所以foo.constructor自然也就变成了Object.prorotype。
一个对象的constructor属性还是比较重要的,因为在JavaScript里有时依赖其constructor判定类型,所以有必要重新修正下constructor:
Foo.prototype = { add: function (x, y) { return x + y; } } Foo.prototype.constructor = Foo;看来还是添加属性的方法更简单切不易出错,推荐使用。
4 创建对象的方法
最常见的创建对象方法是通过new调用构造函数,此外还有一个方法就是使用Object.create(),将一个原型对象作为参数传递给create也可以创建一个继承了其属性的新对象。但是和使用new创建对象还有一点差别,那就是构造函数不会被调用。所以如果使用这种方法创建一个foo新对象,其foo.f是undefined的:
function Foo(z) { this.f = z; } Foo.prototype.add = function(x, y) { return x + y; } var foo = Object.create(Foo.prototype); console.log(foo.add); // ==> [Function] console.log(foo.f); // ==> undefined
那如果自己再补充调用一次构造函数,是不是就可以了呢?
Foo.call(foo, 7); console.log(foo.f); // ==> 7真的可以了!不过这样的调用方式没有任何方便性可言,所以还是推荐使用new更加方便。
创建一个连toString()和valueOf()这样的基础属性都没有的空对象可以使用:
var obj = Object.create(null);
另外,下面三行语句是等效的。
var obj = {}; var obj = new Object(); var obj = Object.create(Object.prototype);
5 __proto__属性
ECMAScript 5里并没有__proto__,所以犀牛书里对它没有任何介绍。__proto__是由Firefox最先引入的。由于各主流浏览器厂商甚至Nodejs都支持此属性(IE6~IE10除外,IE11才支持),而且它会变成ECMAScript 6规范的一部分,所以本篇文章也把它加入进来了。本来JavaScript的原型系统已经让人有点挠头了,但是系统还算清晰,因为普通自定义对象(内置对象除外)是没有直接属性能访问到自己原型对象的,加入__proto__虽然增加了访问原型的方便性,但是也导致了复杂性的提升。看到下面的结果你一定也在挠头吧:-)
Foo.__proto__ === Foo.prototype // ==> false foo.__proto__ === Foo.prototype // ==> true Object.__proto__ === Foo.__proto__ // ==>true
6 内置对象的创建
犀牛书(第六版P204~P205)说:"构造函数就是用来‘构造新对象’的,它必须通过关键字new调用如果将构造函数用做普通函数的话,往往不会正常工作"。其实对于自定义对象,这个结果是明确的,因为Foo函数没有写返回值所以会返回undefined
var foo = Foo(7); console.log(foo); // ==> undefined
但是对于内置对象的实验的结果很有意思,竟然正确的创建了一个Array对象,后来通过查询ECMAScript规范发现,不同的对象直接调用构造函数
其结果是不一致的,对于Array在ECMAScript 5(15.4.1节)是这么说的"Create and return a new Array object exactly as if the standard built-in constructor Array was used in anew expression
with the same arguments",而对于Date在ECMAScript5(15.9.2.1节)是这么说的"A String is created and returned as if by the expression (new Date()).toString()",对于Array()来说等效于"new Array()"而"Date()"只是返回一个代表当前时间的字符串, 这两个对象的行为不一样!
var a = new Array(); var b = Array(); var c = new Date(); var d = Date(); Object.prototype.toString.call(a); // ==>'[object Array]' Object.prototype.toString.call(b); // ==>'[object Array]' Object.prototype.toString.call(c); // ==>'[object Date]' Object.prototype.toString.call(d); // ==>'[object String]'
所以,建议还是不要单独调用构造函数了,因为返回的对象类型并没有统一的规则,非常容易出错。