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

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判断会得到以下结果:

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

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

2. prototype和__proto__

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

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

2.1 prototype

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

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

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

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语言类式继承的一种方式了。

你可能感兴趣的:(深入理解JavaScript原型:prototype,__proto__和constructor)