深刻理解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語言類式繼承的一種方式了。

相關文章
相關標籤/搜索