JavaScript原型鏈以及ES3、ES5、ES6實現繼承的不同方式

孫群發表於2017-03-15

本文首發於GitHub,《JavaScript原型鏈以及ES3、ES5、ES6實現繼承的不同方式》,歡迎關注我的GitHub。

原型

執行程式碼var o = new Object();,此時o物件內部會儲存一個指標,這個指標指向了Object.prototype,當執行o.toString()等方法(或訪問其他屬性)時,o會首先檢視自身有沒有該方法或屬性,如果沒有的話就沿著內部儲存的指標找到Object.prototype物件,然後檢視Object.prototype物件是否有對應名稱的方法或屬性,如果有就呼叫Object.prototype的方法或屬性。我們把這個指標叫做o物件的原型,你可以把它看做是Java類繼承中的super關鍵字。

ES3規範中定義了Object.prototype.isPrototypeOf()方法,該方法可以判斷某個物件是不是另一個物件的原型。Object.prototype.isPrototypeOf(o)返回true值可以確定Object.prototype就是o物件的原型。在ES3規範中,不能直接讀取o物件的原型,也就是o物件的原型看不見摸不著的。ES5.1規範定義了Object.getPrototypeOf()方法,通過該方法可以獲取物件的原型。我們可以通過Object.getPrototypeOf(o) === Object.prototype再次驗證Object.prototype就是o物件的原型。ES6規範更加直接,為物件新增了一個__proto__屬性,通過這個屬性就可以獲得物件的原型,所以在支援__proto__的瀏覽器中,o.__proto__ === Object.prototype也會返回true。

當我們執行var x = new X();時,瀏覽器會執行x.__proto__ = X.prototype會將例項化物件的原型設定為對應的類的prototype物件,這一點很重要

原型鏈

我們執行如下程式碼:

function Person(){};
var p = new Person();
`p.__proto__`指向了`Person.prototype`,`Person.prototype`的原型是`Person.prototype.__proto__`,其指向了`Object.prototype`,`Object.prototype.__proto__`為null。 通過`__proto__`向上追蹤形成了如下的鏈式結構:
p -> Person.prototype -> Object.prototype -> null
**這一原型的鏈式結構就叫做原型鏈。Object.prototype的原型是null,也就是說Object.prototype沒有原型。** JavaScript 物件有一個指向一個原型物件的鏈。當試圖訪問一個物件的屬性時,它不僅僅在該物件上搜尋,還會搜尋該物件的原型,以及該物件的原型的原型,依此層層向上搜尋,直到找到一個名字匹配的屬性或到達原型鏈的末尾。 JavaScript中的繼承是通過原型實現的,雖然在ES6中引入了`class`關鍵字,但是它只是原型的語法糖,JavaScript繼承仍然是基於原型實現的。 ## ES3實現繼承 在JavaScript中,所謂的類就是函式,函式就是類。一般情況下,我們在函式的prototype上面定義方法,因為這樣所有類的例項都可以公用這些方法;在函式內部(建構函式)中初始化屬性,這樣所有類的例項的屬性都是相互隔離的。 我們定義ClassA和ClassB兩個類,想讓ClassB繼承自ClassA。 ClassA程式碼如下所示:
function ClassA(name, age) {
    this.name = name;
    this.age = age;
}

ClassA.prototype.sayName = function () {
    console.log(this.name);
};

ClassA.prototype.sayAge = function () {
    console.log(this.age);
};
ClassA建構函式內部定義了`name`和`age`兩個屬性,並且在其原型上定義了`sayName`和`sayAage`兩個方法。 ClassB如下所示:
function ClassB(name, age, job) {
    ClassA.apply(this, [name, age]);
    this.job = job;
}
ClassB新增了`job`屬性,我們在其建構函式中執行`ClassA.apply(this, [name, age]);`,相當於在Java類的建構函式中通過`super()`呼叫父類的建構函式以初始化相關屬性。 此時我們可以通過`var b = new ClassB(“sunqun”, 28, “developer”);`進行例項化,並可以訪問`b.name`、`b.age`、`b.job`三個屬性,但此時b還不能訪問ClassA中定義的`sayName`和`sayAage`兩個方法。 然後我們新增程式碼`ClassB.prototype = ClassA.prototype;`,此時ClassB的程式碼如下所示:
function ClassB(name, age, job) {
    ClassA.apply(this, [name, age]);
    this.job = job;
}
//新增
ClassB.prototype = ClassA.prototype;
當執行`var b = new ClassB(“sunqun”, 28, “developer”);`時,b.__proto__指向的是ClassB.prototype,由於通過新增的程式碼已經將`ClassB.prototype`指向了`ClassA.prototype`,所以此時b.__proto__指向了`ClassA.prototype`。這樣當執行`b.sayName()`時,會執行`b.__proto__.sayName()`,即最終執行了`ClassA.prototype.sayName()`,這樣ClassB的例項就能呼叫ClassA中方法了。 此時我們想為ClassB新增加一個`sayJob`方法用於輸出`job`屬性的值,如下所示:
function ClassB(name, age, job) {
    ClassA.apply(this, [name, age]);
    this.job = job;
}
ClassB.prototype = ClassA.prototype;
//新增
ClassB.prototype.sayJob = function(){
    console.log(this.job);
};
此時問題出現了,我們為`ClassB.prototype`新增`sayJob`方法時,其實修改了`ClassA.prototype`,這樣會導致ClassA所有的例項也都有了`sayJob`方法,這顯然不是我們期望的。 為了解決這個問題,我們再次修改ClassB的程式碼,如下所示:
function ClassB(name, age, job) {
    ClassA.apply(this, [name, age]);
    this.job = job;
}
// ClassB.prototype = ClassA.prototype;
//修改
ClassB.prototype = new ClassA();
ClassB.prototype.constructor = ClassB;
ClassB.prototype.sayJob = function(){
    console.log(this.job);
};
我們通過執行`ClassB.prototype = new ClassA();`將ClassA例項化的物件作為ClassB的prototype,這樣ClassB仍然能夠使用ClassA中定義的方法,但是`ClassB.prototype`已經和`ClassA.prototype`完全隔離了。我們的目的達到了,我們可以隨意向`ClassB.prototype`新增我們想要的方法了。有個細節需要注意,`ClassB.prototype = new ClassA();`會導致`ClassB.prototype.constructor`指向ClassA的例項化物件,為此我們通過`ClassB.prototype.constructor = ClassB;`解決這個問題。 一切貌似完美的解決了,但是這種實現還是存在隱患。我們在執行`ClassB.prototype = new ClassA();`的時候,給ClassA傳遞的是空引數,但是ClassA的建構函式預設引數是有值的,可能會在建構函式中對傳入的引數進行各種處理,傳遞空引數很有可能導致報錯(當然本示例中的ClassA不會)。於是我們再次修改ClassB的程式碼如下所示:
function ClassB(name, age, job) {
    ClassA.apply(this, [name, age]);
    this.job = job;
}
//修改
function ClassMiddle() {

}
ClassMiddle.prototype = ClassA.prototype;
ClassB.prototype = new ClassMiddle();
ClassB.prototype.constructor = ClassB;
ClassB.prototype.sayJob = function () {
    console.log(this.job);
};
這次我們引入了一個不需要形參的函式`ClassMiddle`作為ClassB和ClassA之間的中間橋樑。 1. `ClassMiddle.prototype = ClassA.prototype;`: 將`ClassMiddle.prototype`指向`ClassA.prototype`,這樣ClassMiddle可以訪問ClassA中定義的方法。 2. `ClassB.prototype = new ClassMiddle();`: 將ClassMiddle的例項化物件賦值給ClassB.prototype,這樣就相當於執行了`ClassB.prototype.__proto__ = ClassMiddle.prototype;`,所以ClassB就能使用ClassMiddle中定義的方法,又因為`ClassMiddle.prototype`指向了`ClassA.prototype`,所以`ClassB.prototype.__proto__`也指向了`ClassA.prototype`,這樣ClassB能使用ClassA中定義的方法。 以上思路的精妙之處在於ClassMiddle是無參的,它起到了ClassB和ClassA之間的中間橋樑的作用。 現在我們為ClassA新增一些靜態屬性和方法,ClassA新增如下程式碼:
...

//為ClassA新增靜態屬性
ClassA.staticValue = "static value";

//為ClassA新增靜態方法
ClassA.getStaticValue = function() {
    return ClassA.staticValue;
};
ClassA.setStaticValue = function(value) {
    ClassA.staticValue = value;
};
靜態屬性和方法不屬於某一個例項,而是屬於類本身。ClassA.prototype上面定義的方法是例項方法,不是靜態的。靜態屬性和方法是直接新增在ClassA上的。 為了使ClassB也能繼承ClassA的靜態屬性和方法,我們需要為ClassB新增如下程式碼:
...

//ClassB繼承ClassA的靜態屬性和方法
for (var p in ClassA) {
    if (ClassA.hasOwnProperty(p)) {
        ClassB[p] = ClassA[p];
    }
}
我們最終可以將上述繼承程式碼的公共部分抽離成一個extendsClass方法,如下所示:
function extendsClass(Child, Father) {
    //繼承父類prototype中定義的例項屬性和方法
    function ClassMiddle() {

    }
    ClassMiddle.prototype = Father.prototype;
    Child.prototype = new ClassMiddle();
    Child.prototype.constructor = Child;

    //繼承父類的靜態屬性和方法
    for (var p in Father) {
        if (Father.hasOwnProperty(p)) {
            Child[p] = Father[p];
        }
    }
}
我們只需要執行`extendsClass(ClassB, ClassA);`就可以完成大部分繼承的邏輯。 最終ClassA的完整程式碼如下所示:
function ClassA(name, age) {
    this.name = name;
    this.age = age;
}

ClassA.prototype.sayName = function() {
    console.log(this.name);
};

ClassA.prototype.sayAge = function() {
    console.log(this.age);
};

ClassA.staticValue = "static value";

ClassA.getStaticValue = function() {
    return ClassA.staticValue;
};

ClassA.setStaticValue = function(value) {
    ClassA.staticValue = value;
};
ClassB的完整程式碼如下所示:
function ClassB(name, age, job) {
    ClassA.apply(this, [name, age]);
    this.job = job;
}

extendsClass(ClassB, ClassA);

ClassB.prototype.sayJob = function() {
    console.log(this.job);
};

我們可以在控制檯中進行一下簡單測試:

這裡寫圖片描述

ES5實現繼承

ES5.1規範中新增了Object.create()方法,該方法會傳入一個物件,然後會返回一個物件,返回的物件的原型指向傳入的物件,比如執行程式碼var output = Object.create(input),相當於執行程式碼output.__proto__ = input;,output的原型是input。我們可以簡化之前的程式碼,不再需要ClassMiddle,只需要執行ClassB.prototype = Object.create(ClassA.prototype);即可,相當於執行程式碼ClassB.prototype.__proto__ = ClassA.prototype;

而且ES5.1中新增了Object.keys()方法用以獲取物件自身的屬性陣列,我們可以用該方法簡化繼承父類靜態屬性和方法的過程。

根據以上兩點,我們修改extendsClass方法如下所示:

function extendsClass(Child, Father) {
    //繼承父類prototype中定義的例項屬性和方法
    Child.prototype = Object.create(Father.prototype);
    Child.prototype.constructor = Child;

    //繼承父類的靜態屬性和方法
    Object.keys(Father).forEach(function(key) {
        Child[key] = Father[key];
    });
}

ClassA和ClassB的程式碼無需變化。

ES6實現繼承

我們之前提到,ES6規範定義了Object.prototype.proto屬性,該屬性既可讀又可寫,通過__proto__屬性我們可以直接指定物件的原型。於是在ES6中我們將extendsClass修改為如下所示:

function extendsClass(Child, Father) {
    //繼承父類prototype中定義的例項屬性和方法
    Child.prototype.__proto__ = Father.prototype;//暴力直接,利用__proto__屬性設定物件的原型

    //繼承父類的靜態屬性和方法
    Child.__proto__ = Father;
}

直接修改物件的__proto__屬性值不是最佳選擇,ES6規範中還定義了Object.setPrototypeOf()方法,通過執行Object.setPrototypeOf(b, a)會將a物件作為b物件的原型,即相當於執行了b.__proto__ = a;。為此我們利用該方法再次精簡我們的extendsClass方法,如下所示:

function extendsClass(Child, Father) {
    //繼承父類prototype中定義的例項屬性和方法
    Object.setPrototypeOf(Child.prototype, Father.prototype);

    //繼承父類的靜態屬性和方法
    Object.setPrototypeOf(Child, Father);
}
  1. Object.setPrototypeOf(Child.prototype, Father.prototype);相當於執行程式碼Child.prototype.__proto__ = Father.prototype;,使得Child能夠繼承Father中的例項屬性和方法。

  2. Object.setPrototypeOf(Child, Father);相當於執行程式碼Child.__proto__ = Father;,使得Child能夠繼承Father中的靜態屬性和方法。

ES6中引入了class關鍵字,可以用class直接定義類,通過extends關鍵字實現類的繼承,還可以通過static關鍵字定義類的靜態方法。

我們用class等關鍵字重新實現ClassA和ClassB的程式碼,如下所示:

class ClassA{
    constructor(name, age){
        this.name = name;
        this.age = age;
    }

    sayName(){
        console.log(this.name);
    }

    sayAge(){
        console.log(this.age);
    }

    static getStaticValue(){
        return ClassA.staticValue;
    }

    static setStaticValue(value){
        ClassA.staticValue = value;
    }
}

ClassA.staticValue = "static value";

class ClassB extends ClassA{
    constructor(name, age, job){
        super(name, age);
        this.job = job;
    }

    sayJob(){
        console.log(this.job);
    }
}

ES6中不能通過static定義類的靜態屬性,我們可以直接通過ClassA.staticValue = "static value";定義類的靜態屬性。

需要注意的是,class關鍵字只是原型的語法糖,JavaScript繼承仍然是基於原型實現的。

並不是所有的瀏覽器都支援class關鍵字,在生產環境中,我們可以編寫ES6的程式碼,然後用Babel或TypeScript將其編譯為ES5等主流瀏覽器支援的語法格式。

總結

  1. 執行var x = new X();時,瀏覽器會執行x.__proto__ = X.prototype,會將例項化物件的原型設定為對應的類的prototype物件。

  2. 實現類繼承的關鍵是Child.prototype.__proto__ = Father.prototype;,這樣會將Father.prototype作為Child.prototype的原型。Object.prototype.__proto__屬性是在ES6規範中所引入的,為了在ES3和ES5中需要通過各種方式模擬實現對Object.prototype.__proto__進行賦值。

  3. 通過執行Child.__proto__ = Father;可以實現繼承父類的靜態屬性和方法。

參考

[1] MDN, ES3 Object.prototype.isPrototypeOf()

[2] MDN, ES5.1 Object.create()

[3] MDN, ES5.1 Object.getPrototypeOf()

[4] MDN, ES6 Object.setPrototypeOf()

[5] MDN, ES6 Object.prototype__proto__

[6] MDN, ES6 Classes

相關文章