深刻理解JavaScript原型:prototype,__proto__和constructor

JavaScript语言的原型是前端开发者必须掌握的要点之一,但在使用原型时每每只关注了语法,其深层的原理并未理解透彻。本文结合笔者开发工做中遇到的问题详细讲解JavaScript原型的几个关键概念,若有错误,欢迎指正。javascript

1. JavaScript原型继承

提到JavaScript原型,用处最多的场景即是实现继承。然而在实现继承时总有一些细节处理不到位,引发一些看起来莫名其妙的问题。好比使用下述代码:前端

function Animal(){}
Animal.prototype = {};

function Cat(){}
Cat.prototype = new Animal();

var cat_1 = new Cat();

上述代码首先定义了Animal类的构造函数,随后改变了其原型指向。Cat类将其原型指向Animal类的一个实例对象。以上写法能够知足大部分简单需求,好比建立一个Cat类的实例对象cat_1,此时若是使用instanceof判断会获得如下结果:java

cat_1 instanceof Cat; // true
cat_1 instanceof Animal; // true

以上的实现方式有什么不妥之处呢?这个问题先不解答,咱们首先讲解如下原型的几个关键属性:prototype,__proto__和constructor。理解了它们以后,再进一步完善上述代码。面试

2. prototype和__proto__

许多初学者容易混淆prototype和__proto__。简单来讲:prototype属性是能够做为构造函数的函数对象才具有的属性,__proto__属性是任何对象(除了null)都具有的属性,二者的指向都是其所属类的原型对象,也就是下文提到的内部属性[[Prototype]]app

JavaScript语言中并无严格意义上的类,本文中提到的类能够理解为一个抽象的概念,原型对象能够理解为类暴露出来的接口。函数

2.1 prototype

首先解释一下为何说只有能够做为构造函数的函数对象才具有prototype属性。这种说法是为了区分ES6中新增的箭头函数,箭头函数不能做为构造函数使用,没有prototype属性。某种程度上讲,箭头函数的引入加强了构造函数的语义化。this

熟悉其余OO语言的开发者对于构造函数的概念并不陌生,以Java为例,不论一个类的构造函数被显式或者隐式定义,在建立实例时都会调用构造函数。因此,以功能来说,构造函数是“用来构造新对象的函数”;以语义来说,构造函数是类的公共标识,或者叫作外在表现。好比前文例子中的构造函数Animal(),它的函数名即是其所属类Animal的类名。prototype

构造函数的prototype指向其所属类的原型对象,一个类的原型对象初始值是与类同名的,好比:code

function Animal(){}

Console.log(Animal.prototype);

输出结果为:对象

Animal{
 constructor: function Animal(),
__proto__: Object
}

在输出结果中能够看到,Animal类的原型对象有两个属性:constructor和__proto__。constructor属性即是构造函数Animal()。__proto__属性指向的是Animal类的父类原型对象。

2.2 __proto__

上一节提到的prototype属性是构造函数特有的属性,指向其归属类的原型对象。__proto__属性除了null之外的对象都具有的一个属性,其指向与构造函数的prototype相同。

并不是全部JavaScript引擎都支持__proto__属性的访问和修改,经过修改__proto__改变原型并非一种兼容性方案。最新的ES6规范中,__proto__被规范为一个存储器属性。它的getter方法为Object.getPrototypeOf(),这个方法在ES5中就已经有了;setter方法为Object.setPrototypeOf()。使用这两个方法获取和修改一个对象的原型其实是操做内部隐藏属性[[Prototype]],下文将详细讲解这个属性。

3. constructor

3.1 构造函数是什么?

前文提到,构造函数是一个类的外在表现,声明一个构造函数实际上就声明了一个类。基于这条准则,再回顾一下文章最初实现继承的例子,咱们能够发现如下问题:

  1. 在修改Animal类的prototype时,直接使用赋值操做符将其prototype指向一个空对象,此时Animal类的构造函数是什么?
  2. Cat类继承Animal类时,只是将Cat类的prototype指向一个Animal类的实例,此时Cat类的构造函数是什么?

咱们能够用代码验证上面两个问题:

Console.log(Animal.prototype.constructor);

Console.log(Cat.prototype.constructor);

输出结果为:

function Object() { [native code] };

function Object() { [native code] };

二者的构造函数都是function Object() { [native code] };。为何会获得这样的结果?

在改变Animal和Cat的原型时,使用赋值操做符直接将一个空对象赋值给二者的prototype,constructor属性同时也被这个空对象的constructor属性覆盖了,也就是function Object() { [native code] };

这是不少开发者容易忽略和不解的一个细节,在使用赋值操做符改变一个类的原型时,要注意同时将其原型的constructor属性指向自己,也就是:

Animal.prototype.constructor = Animal;

Cat.prototype.constructor = Cat;

笔者曾在面试一位应聘者的时候提出这个细节,应聘者说了一句“知道有这么回事,但一直没弄明白原理,因此平时工做中也不是很在乎”。网上也有不少博客中提到“修改constructor是为了保证语义上的一致性”,这是不许确的。下面经过具体实例讲解为什么要保证constructor指向的正确性。

3.2 instanceof

咱们一般使用instanceof判断一个对象是不是一个类的实例。可是instanceof并不能获得准确的结果。首先要明白instanceof的工做机制,好比如下代码:

obj instanceod Obj;

使用instanceof判断obj是否为Obj的实例时,并非判断obj继承自Obj,而是判断obj是否继承自Obj.prototype。这是一个很容易忽略的细节,不注意区分的话很容易出现问题。请思考如下代码:

function Father(){}

function ChildA(){}
function ChildB(){}

var father = new Father();

ChildA.prototype = father;
ChildB.prototype = father;

var childA = new ChildA();
var childB = new ChildB();

Conosle.log(childA instanceof ChildA); //true
Conosle.log(childA instanceof ChildB); //true
Conosle.log(childB instanceof ChildA); //true
Conosle.log(childB instanceof ChildB); //true
Conosle.log(childA instanceof Father); //true
Conosle.log(childA instanceof Father); //true

上述代码将派生类ChildA和ChildB的prototype指向同一个Father类的实例,而后分别建立两个实例childA和childB。可是在判断继承关系时发现,获得的结果使人困惑,为何(childA instanceof ChildB返回true呢?

这个问题根据上文提到的instanceof的工做原理很容易解答,派生类ChildA和ChildB的prototype是同一个对象,使用instanceof判断各自实例继承归属时,获得的结果天然是相同的。

明白了instanceof的工做原理后,咱们研究一下JavaScript实现继承的另外一种方式,以下:

function Animal(){}
Animal.prototype = {};

function Cat(){}
Cat.prototype = Object.create(Animal.prototype);

function Dog(){}
Dog.prototype = Object.create(Animal.prototype);

有些书籍将上述方式成为寄生式继承,笔者强烈建议不要使用!

根据instanceof工做原理,咱们能够预估到如下结果:

var cat = new Cat();
var dog = new Dog();

Console.log(cat instanceof Cat); //true
Console.log(cat instanceof Dog); //true
Console.log(dog instanceof Dog); //true
Console.log(dog instanceof Cat); //true

这样,instanceof判断继承关系便没有任何意义了。

如今,咱们明白了instanceof的缺陷,那么跟constructor有什么关系呢?

3.3 使用constructor判断继承关系

如上文所述,在某些场景下instanceof并不能正确验证继承关系。使用constructor属性能够必定程度上弥补instanceof的不足。仍然使用上一个例子,添加如下验证代码:

Console.log( cat.constructor === Cat); //false
Console.log( cat.constructor === Dog); //false
Console.log( cat.constructor === Animal); //false
Console.log( cat.constructor === Object); //true

可能你会疑惑,结果也是不正确的啊?别急,前文提到,在实现原型继承时要保证constructor指向的正确性。基于这条原则,咱们修改代码以下:

function Animal(){}
Animal.prototype = {};
Animal.prototype.constructor = Animal;

function Cat(){}
Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;

function Dog(){}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

而后再分别使用instanceof和constructor的方法判断继承关系以下:

// instanceof
Console.log(cat instanceof Cat); //true
Console.log(cat instanceof Dog); //true
Console.log(dog instanceof Dog); //true
Console.log(dog instanceof Cat); //true
Console.log(cat instanceof Animal); //true
Console.log(dog instanceof Animal); //true

//constrcutor
Console.log( cat.constructor === Cat); //true
Console.log( cat.constructor === Dog); //false
Console.log( dog.constructor === Dog); //true
Console.log( dog.constructor === Cat); //false
Console.log( cat.constructor === Animal); //false
Console.log( cat.constructor === Object); //false

可见,修正后的代码使用constructor能够正确判断继承关系,instanceof仍然没有改善。

3.4 小结

经过以上的论述咱们知道了实现继承时保证constructor指向正确的必要性,以及判断继承关系时和constructor和instanceof各自的工做原理及不足。有如下结论:

  1. 实现原型继承时请务必保证constructor指向的正确性;
  2. instanceof能够判断递归向上的继承关系,可是并不能应对所有场景;
  3. constructor能够判断直属的继承关系,可是并不能判断递归向上的连续继承关系;
  4. 具体使用场景应综合使用instanceof和constructor,互补互缺;
  5. 不建议使用寄生式继承。

4. 原型究竟是什么?

JavaScript的诞生只用了10天,可是须要10年甚至更久的时间去完善。JavaScript语言是基于原型的,那么原型究竟是什么呢?

ES6新增了内部属性[[Prototype]],对象的原型便储存在这个属性内,上文提到的各类对原型的操做本质上都是对[[Prototype]]的操做。

JavaScript并无类的概念,即便ES6规范了class关键字,本质上仍然是基于原型的。类能够做为一个抽象的概念,是为了便于理解构造函数和原型。原型能够理解为类暴露出来的一个接口或者属性。前文提到,建立了构造函数即是建立了同名类,随后在改变一个对象的原型时,只是改变了类的这个属性,而构造函数是类的静态成员,保持不变。

另外,在修改对象原型时,不建议使用直接赋值的方式。咱们应该遵照一个原则:扩展利于赋值。

5. 改善后的代码

长篇大论的一通,咱们能够基于上述的基本原则改善文章最初的例子。以下:

function Animal(){}
Animal.prototype.getName = function(){};

function Cat(){
 Animal.apply(this,arguments);
}
Cat.prototype = Object.create(Animal.prototype,{
 constructor: Cat
});

var cat_1 = new Cat();

结合其余OO语言的继承方式和JavaScript原型理解上述代码:

  1. 扩展Animal原型而不是赋值修改;
  2. 保证派生类构造函数向上递归调用;
  3. 使用Object.create()方法而不是寄生式继承;
  4. 保证constructor指向的正确性。

有些书籍把以上方式称为组合式继承,能够说是最接近传统OO语言类式继承的一种方式了。

相关文章
相关标签/搜索