JavaScript的原型和原型链的前世今生(二)

林光

3.1、原型对象

在上一篇文章中我们讲到的prototype属性,这个属性指向一个对象,而这个对象的用途是包含可以由特定类型所有实例共享的属性和方法,在标准中我们称此对象为原型对象。原型对象会在创建一个新函数的时候根据一组特定的规则来生成。

既然有prototype这个属性,为什么上一篇文章中浏览器所截图的却都是__proto__?根据ECMA-262第五版中的介绍,实例内部的指针明确称为[[Prototype]],虽然没有标准的方式访问该指针,但Firefox、Safari、Chrome在每个对象上都支持一个属性__proto__,所以你所见到的__proto__其实是浏览器自己实现的一个访问的接口,而不是标准设定的。但其实按照浏览器的设计来理解其实也更好的。

说了这么多,问个问题:__proto__是谁与谁的连接?自己可以好好琢磨哈。

虽然无法访问到[[Prototype]],但是可以使用isPrototypeof()方法来确定对象之间是否存在关系,也可以使用getPrototypeof()来获取[[Prototype]]的值。

既然原型对象的属性在实例化的对象中也可以访问到,那么有什么办法来判断访问的属性是在实例化的对象中还是在原型对象中呢?答案便是isOwnProperty()。

for-in循环将会遍历所有能够通过对象访问、可枚举的属性,无论该属性位于实例中还是原型中。在《JavaScript高级程序设计(第三版)》的6.2节有这么一段话,个人觉得有点多余:

屏蔽了原型中不可枚举属性(即将[[Enumerble]]标记为false的属性)的实例属性也会在for-in循环中返回。 按照作者的理解是如果你在实例属性中定义了一个与原型中同样的名字的属性,并且在原型中该属性是不可枚举的,那么for-in依然会返回该属性。这个其实是很明显的命题,因为for-in在实例中找到该属性,并且该属性是可枚举的(除非你手动设置其为不可枚举)。

一个个枚举所有可枚举的属性比较麻烦,好在ES5提供了Object.keys()方法来获取所有可枚举的属性。如果想获取所有实例属性,可以使用Object.getOwnPropertyNames()

之前说过原型模式也是有缺点,其最大的缺点便是其共享的特性,随便修改原型对象中的任何一个属性,都会影响到它所实例化的所有对象,这样造成了不能出现“求同存异”的现象。因此我们会更多地使用下面的一种方法。

3.2、组合使用构造函数模式和原型模式

组合使用当然是将所有共享的属性放在原型对象中,所有独特的属性放在构造函数中,这样的话可以实现真正的“求同存异”了。比如:

function Animal(){  
   this.name = name;
   this.type = type;
   this.say = function(){
       console.log('I am a ' + this.type); 
   }
}

Animal.prototype = {  
   constructor: Animal;
   feetCount: 0;
   run: function(){
       console.log('I can run');
   }
}

var dog = new Animal('WangWang', 'dog');  

注意:

为什么这里的Animal.prototype要重新赋值constructor?

童鞋们结合上一篇文章可以思考一下!!

那么我们是否可以考虑将上面的代码再优化一下,减少代码量?这时可以使用动态原型模式

function Animal(){  
   this.name = name;
   this.type = type;
   this.say = function(){
       console.log('I am a ' + this.type); 
   }

   if (typeof this.run != 'function'){
      Animal.prototype.feetCount = 0;
      Animal.prototype.run = function(){
         console.log('I can run');
      }
   }
}

var dog = new Animal('WangWang', 'dog');  

注意:

为什么这里初始化原型的时候不能使用上面例子中的对象字面量的形式?

童鞋们也可以自己思考一下!!

在《JavaScript高级程序设计(第三版)》还介绍了两种模式来创建对象:寄生(parasitic)构造函数模式稳妥(Durable)构造函数模式,细节可以参考书本。

4、原型链

想必讲到这里,你应该已经能够猜到原型链的实现原理了。ES5使用原型链来作为实现继承的主要方法(由于函数没有签名。在ES5中无法实现接口继承)。其基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。我们完善一下上一节的图片3:

可以看出,通过[[prototype]]属性将实例、原型对象、原型对象的原型对象串联起来了,这个串联便是原型链。

同样原型链也是存在两个问题: + 原型中包含引用类型值的问题(也就是刚才3.1节说的问题) + 在创建子类型实例时,不能向超类型的构造函数中传递参数

因此常用的解决方案有下面几个:

4.2、组合继承

组合继承(combination inheritance)有时候也叫作伪经典继承。该方案结合了借用构造函数(参考4.2.1小节)和原型链的。看下面的例子:

function Species(name, type){  
   this.name = name;
   this.type = type;
}

Species.prototype.run = function(){  
    console.log('I can run !');
}

function Animal(name, type, age){  
   Species.call(this, name, type);

   this.age = 0;
}

Animal.prototype = new Species;  
Animal.prototype.constructor = Animal;  
Animal.prototype.reportAge = function(){  
   console.log('my age is ' + this.age);
}

var dog = new Animal('WangWang', 'dog', 11);  
dog.run();  
dog.reportAge();  

该段代码的原型链图如下:

4.2.1、借用构造函数

借用构造函数(constructor stealing)(有时候也叫作伪造对象或经典继承),实现原理很简单,那就是在子类型构造函数的内部调用超类型构造函数。比如:

function Species(){  
   this.colors= ['red', 'green'];
}

function Animal(type, name){  
   Species.call(this);
   this.type = type;
   this.name = name;
}

var dog = new Animal('dog', 'WangWang');  
dog.colors.push('black');

var cat = new Animal('cat', 'MiMi');  
cat.colors.push('yellow');  

因为使用call函数的方法,让实例化Species超类的时候this指针指向了实例化的子类,相当于colors成了子类Animal的属性,因此每个实例操作的colors都是自己的私有属性。如下图:

因为使用call方法,我们还可以传递参数给超类:

function Species(feet){  
   this.colors= ['red', 'green'];
   this.feet = feet
}

function Animal(type, name, feet){  
   Species.call(this, feet);
   this.type = type;
   this.name = name;
}

这种方法导致的问题也就是构造函数的通病--那就是方法无法复用,每个实例都有自己一个实例的方法,所以一般采用上面的方式来使用。

4.3、其他方案

除了组合继承,还有原型式继承、寄生式继承、寄生组合式继承等三种方法,后面的这三种方法用到的地方不多,所以不做介绍,细节可以参考《JavaScript高级程序设计(第三版)》。

5、总结

通过这篇文章,从最初最简单的对象创建到后面的构造函数模式、原型模式创建对象,可以看出这门语言的强大生机,在不断地优化中变得越来越有意思。我们从这些演变中也掌握了对象的创建方法,顺便学习了原型以及原型链那些比较晦涩的概念。深究一个概念往往便是深究其演变的历史,所以要知未来,历史不可忘也!!

6、参考

[1] 《JavaScript高级程序设计(第三版)》第6章

[2] MDN

[3] ES5标准