javascript之繼承

minghu發表於2018-11-26

前言:

看了<<JavaScript高階程式設計>>中的繼承篇,總結的非常好,能讓我明白為什麼現在開發中 JavaScript 的繼承大多都是這麼實現,並能一步步理解每一種繼承的實現方式及優缺點。最終通過了解每一種繼承方式的優缺點來實現一個比較完美且常用的 JavaScript 繼承模式。

正文

一、原型鏈繼承

先簡單回顧一下函式、原型、例項的關係:

  • 每一個函式都有一個原型物件(prototype)。
  • 原型物件都包含一個指向建構函式的指標(constructor)。
  • 例項都包含一個指向原型物件的內部指標(__proto__)。

看下面的程式碼:

    'use strict';
    
    //父類
    function SuperType() {
        this.superName = '我是父類';
    }
    
    SuperType.prototype.getSuperName = function() {
        return this.superName;
    }
    
    //子類
    function SubType() {
        this.subName = '我是子類';
    }
    
    //通過原型鏈繼承 
    SubType.prototype = new SuperType();
    
    SubType.prototype.getSubName = function() {
        return this.subName;
    }
    
    var instance = new SubType();
    console.log(instance.getSuperName()); //我是父類
    console.log(instance.getSubName()); //我是子類
    
複製程式碼

以上定義了兩個型別,SuperType 和 SubType,SubType 建構函式將 prototype 屬性指向了 SuperType 建構函式的例項,以此來實現繼承,因為 SuperType 的 prototype 屬性是 Object 的例項,所以:

    console.log(instance instanceof Object); //true
    console.log(instance instanceof SuperType); //true
    console.log(instance instanceof SubType); //true
    
    console.log(Object.prototype.isPrototypeOf(instance)); //true
    console.log(SuperType.prototype.isPrototypeOf(instance)); //true
    console.log(SubType.prototype.isPrototypeOf(instance)); //true
複製程式碼

關係圖如下所示:

javascript之繼承

由於此時 SubType 的 prototype 屬性指向的是 SuperType 的例項,所以會導致 SubType.prototype.constructor 指向 SuperType:

    SubType.prototype.constructor === SuperType; // true
複製程式碼

原型鏈繼承還會帶來兩個問題:

1、SubType(子類)的例項會共享 SuperType(父類)的引用型別屬性,看下面的程式碼:

    'use strict';
    
    function SuperType() {
        this.ids = [0, 1, 2];
    }
    
    function SubType() {};
    
    //通過原型鏈繼承
    SubType.prototype = new SuperType();
    
    var instance1 = new SubType();
    instance1.ids.push(3);
    console.log(instance1.ids); //[0, 1, 2, 3]
    
    var instance2 = new SubType();
    //因為ids屬性在 SubType 的 prototype屬性上,所以會被 SubType 所有例項共享
    console.log(instance2.ids); //[0, 1, 2, 3]
複製程式碼

2、在建立 SubType(子類)的時候,不能向 SuperType(父類傳參)。

二、借用建構函式

借用建構函式的思想比較簡單,在子類函式中呼叫父類函式,將父類函式的執行上下文環境指向子類函式的執行上下文環境,利用了函式的 call 和 apply 方法實現,這樣一來,就解決了原型鏈繼承的兩大問題。

看下面的程式碼:

    'use strict';
    
    function SuperType(name) {
        this.ids = [0, 1, 2];
        this.name = name;
    }
    
    function SubType(name, age) {
        SuperType.call(this, name);
        this.age = age;
    }
    
    var instance1 = new SubType('xiaoming', 10);
    instance1.ids.push(3);
    console.log(instance1.ids); // [0, 1, 2, 3]
    console.log(instance1.name); // xiaoming
    console.log(instance1.age); // 10
    
    var instance2 = new SubType('xiaohua', 10);
    console.log(instance2.ids); // [0, 1, 2]
    
複製程式碼

上面的程式碼可以看出,instance1 和 instance2 都是 SubType 的例項,instance1 對引用型別 ids 屬性進行了 push 操作,不會影響到 instance2 的 ids 屬性,因為此時的 ids 屬性已經掛在到了 例項身上,根據原型鏈的規則,當一個物件在自身沒找到一個屬性時,就會去原型上去找,如果在自身找到這個屬性,就不會往下找了,所以此時 instance1 和 instance2 對引用型別屬性 ids 的操作是不會互不影響的。另外,在繼承的時候也是可以傳遞引數的。這也就解決了原型鏈繼承的兩大問題。

如果借用建構函式,也存在一個問題,就是子類的例項共享父類方法的問題,如果方法全部定義在父類建構函式內部,那麼每次在例項化的時候就都需要建立一遍方法。因此函式複用就無從談起了。考慮到這個問題,借用建構函式一般也是很少單獨使用。

三、組合繼承

組合繼承是指將原型鏈繼承和借用建構函式組合到一起,通過原型鏈實現對原型屬性和方法的繼承,借用建構函式實現對例項屬性的繼承。在原型鏈上定義的方法實現了函式複用,又能保證每個例項有自己的屬性。

看下面的程式碼:

    'use strict';
    
    function SuperType(name) {
        this.ids = [0, 1, 2];
        this.name = name;
    }
    SuperType.prototype.getName = function() {
        return this.name;
    }
    
    function SubType(name, age) {
        SuperType.call(this, name);
        this.age = age;
    }
    SubType.prototype = new SuperType();
    SubType.prototype.getAge = function() {
        return this.age;
    }
    
    var instance1 = new SubType('xiaoming', 10);
    instance1.ids.push(3);
    console.log(instance1.ids); // [0, 1, 2, 3]
    console.log(instance1.getName()); // xiaoming
    console.log(instance1.getAge()); // 10
    
    var instance2 = new SubType('xiaohua', 10);
    console.log(instance2.ids); // [0, 1, 2]
    console.log(instance2.getName()); // xiaohua
    console.log(instance2.getAge()); // 10
    
複製程式碼

上面的程式碼可以看出,instance1 和 instance2 都是 SubType 的例項,他們共享了 SuperType 原型中的 getName 方法,繼承了 SuperType 例項的屬性,且互不影響,完美融合了原型鏈繼承和借用建構函式的優點。組合繼承也是JavaScript中常用的繼承模式。

組合繼承也有一個缺點,就是無論什麼情況下都會呼叫兩次父類的建構函式,一次是在子類建構函式內部,一次是在建立子類原型的時候,這個問題會在寄生組合模式中得到解決。

四、原型式繼承

原型式繼承並沒有使用嚴格意義上的建構函式,原理是藉助原型和基於已有的物件建立新物件。與 ECMAScript5 中新增的 Object.create() 方法在傳一個引數的情況下行為相同。

看下面的程式碼:

    'use strict';
    
    function createObj(obj) {
        function F() {};
        F.prototype = obj;
        return new F();
    }
    
    var person = {
        name: 'a',
        friends: ['0', '1', '2']
    }
    
    var person1 = createObj(person);
    person1.name = 'b';
    person1.friends.push('3');
    console.log(person1.name); // b
    console.log(person1.friends); // ['0', '1', '2', '3']
    
    var person2 = createObj(person);
    console.log(person2.name); // a
    console.log(person2.friends); // ['0', '1', '2', '3']
複製程式碼

上面程式碼的關係圖如下:

javascript之繼承

在沒必要建立建構函式,只想讓一個物件共享另一個物件屬性方法的時候,原型式繼承是完全可以勝任的。但是也有一個和原型鏈繼承一樣的缺點,就是包含引用型別值的屬性會被共享。就比如上面程式碼中的 friends 屬性,由於是引用型別,所以會被 person1 、person2所共享。

五、寄生式繼承

寄生式繼承與原型式繼承相似,也是基於某一個物件建立一個物件,然後建立一個僅用於封裝繼承過程的函式,該函式內部以某種方式來增強物件。

    'use strict';
    
    function createObj(obj) {
        var _obj = Object.create(obj);
        _obj.sayHi = function() {
            alert('Hi');
        }
        return _obj;
    }
複製程式碼

使用寄生式繼承來為物件新增函式,會由於不能做到函式複用而降低效率,即每次呼叫都會重新建立一遍方法,這一點與借用建構函式類似。在主要考慮物件而不是自定義型別和建構函式的情況下,寄生式繼承也是一種有用的模式。

六、寄生組合式繼承

組合繼承是JavaScript最常用的繼承模式,他的缺點在上面也有說過,會呼叫兩次父類的建構函式,這樣就會導致父類建構函式內部定義的屬性會被子類繼承兩次,一次會繼承在子類例項屬性上,另一次會繼承在子類原型上。寄生組合式繼承就可以很好的解決這個問題,其大致思路和組合繼承類似,只是在子類原型的繼承上有所不同,我們想要的就只是父類原型的一個副本而已,所以沒必要再一次呼叫父類的建構函式。

所以,先實現一個方法,為了得到父類原型的副本。程式碼如下:

    'use strict';
    
    function inheritPrototype(subType, superType) {
        // 獲取父類原型的副本
        var prototype = Object.create(superType.prototype);
        // 為副本新增 constructor 屬性,彌補因為重寫原型而失去預設的 constructor 屬性
        prototype.constructor = subType;
        // 將副本賦值給子型別的原型
        subType.prototype = prototype;
    }
複製程式碼

完整的示例程式碼如下:

    'use strit';
    
    function SuperType(name) {
        this.name = name;
        this.ids = [0, 1, 2];
    }
    SuperType.prototype.getName = function() {
        return this.name;
    }
    
    function SubType(name, age) {
        SuperType.call(this, name);
        this.age = age;
    }
    
    inheritPrototype(SubType, SuperType);
    
    SubType.prototype.getAge = function() {
        return this.age;
    }
    
    var instance = new SubType('xiaoming', 10);
    
    console.log(SubType.prototype.constructor === SubType); // true
    console.log(instance instanceof SubType); // true
    console.log(instance instanceof SuperType); // true
    console.log(SubType.prototype.isPrototypeOf(instance)); // true
    console.log(SuperType.prototype.isPrototypeOf(instance)); // true
複製程式碼

這個例子的高效率體現在例項化 SubType 的時候,只會呼叫一次 SuperType 建構函式。並且也避免了在 SubType.prototype 上建立不必要的屬性(即 SuperType 建構函式內部定義的屬性)。於此同時原型鏈保持不變,還能正常使用 instanceof 和 isPrototypeOf()。可以說是集寄生式繼承和組合式繼承的優點與一身,是實現基於型別繼承最有效的方法。

總結:

  • 原型鏈繼承:

    原理:將父類的例項直接賦值給子類建構函式的原型。
    缺點:1.子類的例項會共享父類建構函式內部定義的引用型別屬性
       2. 在建立子類的時候,無法向父類建構函式傳參。

  • 借用建構函式:

    原理:在子類函式中呼叫父類函式,將父類函式的執行上下文環境指向子類函式的執行上下文環境,利用了函式的 call 和 apply 方法實現。
    缺點:無法實現公用方法複用,即每次呼叫父類的建構函式都需要重新建立一遍方法函式。

  • 組合繼承:

    原理: 融合了原型鏈繼承與借用建構函式。
    缺點:每次例項化子類的時候,都需要呼叫兩次父類的建構函式,並且會將父類建構函式內部定義的屬性掛載到子類的例項和原型兩處,使子類的原型上產生了多餘的屬性。

  • 原型式繼承:

    原理:在不必定義建構函式的情況下實現繼承,實際上是執行對給定物件的淺複製。與Object.create() 在傳一個引數的情況下行為相同。 缺點:與原型鏈繼承類似,包含引用型別值的屬性會被共享。

  • 寄生式繼承:

    原理: 與原型式繼承類似,基於某個物件建立一個物件,然後為這個物件增加屬性和方法來增強物件。 缺點: 與借用建構函式類似,每次呼叫都會重新建立一遍方法函式。

  • 寄生組合式繼承:

    原理:融合了組合繼承與寄生式繼承,解決了組合繼承中的缺點。是實現基於型別繼承最有效的方式。

相關文章