天天看點

【面試題】JS 常見的 6 種繼承方式(常見)

【面試題】JS 常見的 6 種繼承方式(常見)

繼承概念的探究

說到繼承的概念,首先要說一個經典的例子。

先定義一個類(Class)叫汽車,汽車的屬性包括顔色、輪胎、品牌、速度、排氣量等,由汽車這個類可以派生出“轎車”和“貨車”兩個類,那麼可以在汽車的基礎屬性上,為轎車添加一個後備廂、給貨車添加一個大貨箱。這樣轎車和貨車就是不一樣的,但是二者都屬于汽車這個類,這樣從這個例子中就能詳細說明汽車、轎車以及卡車之間的繼承關系。

繼承可以使得子類别具有父類的各種方法和屬性,比如上面的例子中“轎車” 和 “貨車” 分别繼承了汽車的屬性,而不需要再次在“轎車”中定義汽車已經有的屬性。在“轎車”繼承“汽車”的同時,也可以重新定義汽車的某些屬性,并重寫或覆寫某些屬性和方法,使其獲得與“汽車”這個父類不同的屬性和方法。

JS 實作繼承的幾種方式

1、原生鍊繼承

原型鍊繼承是比較常見的繼承方式之一,其中涉及的構造函數、原型和執行個體,三者之間存在着一定的關系,即每一個構造函數都有一個原型對象,原型對象又包含一個指向構造函數的指針,而執行個體則包含一個原型對象的指針。

function Parent1() {

    this.name = 'parent1';

    this.play = [1, 2, 3]

  }

  function Child1() {

    this.type = 'child2';

  }

  Child1.prototype = new Parent1();

  console.log(new Child1());
複制代碼      

原型鍊繼承是比較常見的繼承方式之一,其中涉及的構造函數、原型和執行個體,三者之間存在着一定的關系,即每一個構造函數都有一個原型對象,原型對象又包含一個指向構造函數的指針,而執行個體則包含一個原型對象的指針。雖然父類的方法和屬性都能夠通路,但其實有一個潛在的問題,明明我隻改變了 s1 的 play 屬性,為什麼 s2 也跟着變了呢?原因很簡單,因為兩個執行個體使用的是同一個原型對象。它們的記憶體空間是共享的,當一個發生變化的時候,另外一個也随之進行了變化,這就是使用原型鍊繼承方式的一個缺點。

var s1 = new Child1();

  var s2 = new Child2();

  s1.play.push(4);

  console.log(s1.play, s2.play);
複制代碼      

2、構造函數繼承

function Parent1(){

    this.name = 'parent1';

  }



  Parent1.prototype.getName = function () {

    return this.name;

  }



  function Child1(){

    Parent1.call(this);

    this.type = 'child1'

  }



  let child = new Child1();

  console.log(child);  // 沒問題

  console.log(child.getName());  // 會報錯
複制代碼      

可以看到最後列印的 child 在控制台顯示,除了 Child1 的屬性 type 之外,也繼承了 Parent1 的屬性 name。這樣寫的時候子類雖然能夠拿到父類的屬性值,解決了第一種繼承方式的弊端。

問題:父類原型對象中一旦存在父類之前自己定義的方法,那麼子類将無法繼承這些方法。

3、組合繼承

這種方式結合了前兩種繼承方式的優缺點,結合起來的繼承。

function Parent3 () {

    this.name = 'parent3';

    this.play = [1, 2, 3];

  }



  Parent3.prototype.getName = function () {

    return this.name;

  }

  function Child3() {

    // 第二次調用 Parent3()

    Parent3.call(this);

    this.type = 'child3';

  }



  // 第一次調用 Parent3()

  Child3.prototype = new Parent3();

  // 手動挂上構造器,指向自己的構造函數

  Child3.prototype.constructor = Child3;

  var s3 = new Child3();

  var s4 = new Child3();

  s3.play.push(4);

  console.log(s3.play, s4.play);  // 不互相影響

  console.log(s3.getName()); // 正常輸出'parent3'

  console.log(s4.getName()); // 正常輸出'parent3'
複制代碼      

問題:通過注釋我們可以看到 Parent3 執行了兩次,第一次是改變Child3 的 prototype 的時候,第二次是通過 call 方法調用 Parent3 的時候,那麼 Parent3 多構造一次就多進行了一次性能開銷,這是我們不願看到的。

4、原型式繼承

ES5 裡面的 Object.create 方法,這個方法接收兩個參數:一是用作新對象原型的對象、二是為新對象定義額外屬性的對象(可選參數)。

let parent4 = {

    name: "parent4",

    friends: ["p1", "p2", "p3"],

    getName: function() {

      return this.name;

    }

  };



  let person4 = Object.create(parent4);

  person4.name = "tom";

  person4.friends.push("jerry");



  let person5 = Object.create(parent4);

  person5.friends.push("lucy");



  console.log(person4.name);//tom

  console.log(person4.name === person4.getName());//true

  console.log(person5.name);//parent4

  console.log(person4.friends);//["p1","p2","p3","jerry","lucy"]

  console.log(person5.friends);//["p1","p2","p3","jerry","lucy"]
複制代碼      

通過 Object.create 這個方法可以實作普通對象的繼承,不僅僅能繼承屬性,同樣也可以繼承 getName 的方法。

第一個結果“tom”,比較容易了解,person4 繼承了 parent4 的 name 屬性,但是在這個基礎上又進行了自定義。

第二個是繼承過來的 getName 方法檢查自己的 name 是否和屬性裡面的值一樣,答案是 true。

第三個結果“parent4”也比較容易了解,person5 繼承了 parent4 的 name 屬性,沒有進行覆寫,是以輸出父對象的屬性。

最後兩個輸出結果是一樣的,其實 Object.create 方法是可以為一些對象實作淺拷貝的。

問題:多個執行個體的引用類型屬性指向相同的記憶體,存在篡改的可能。

5、寄生繼承

使用原型式繼承可以獲得一份目标對象的淺拷貝,然後利用這個淺拷貝的能力再進行增強,添加一些方法,這樣的繼承方式就叫作寄生式繼承。

雖然其優缺點和原型式繼承一樣,但是對于普通對象的繼承方式來說,寄生式繼承相比于原型式繼承,還是在父類基礎上添加了更多的方法。

let parent5 = {

    name: "parent5",

    friends: ["p1", "p2", "p3"],

    getName: function() {

      return this.name;

    }

  };



  function clone(original) {

    let clone = Object.create(original);

    clone.getFriends = function() {

      return this.friends;

    };

    return clone;

  }



  let person5 = clone(parent5);



  console.log(person5.getName());

  console.log(person5.getFriends());
複制代碼      

 person5 是通過寄生式繼承生成的執行個體,它不僅僅有 getName 的方法,而且可以看到它最後也擁有了 getFriends 的方法。person5 通過 clone 的方法,增加了 getFriends 的方法,進而使 person5 這個普通對象在繼承過程中又增加了一個方法,這樣的繼承方式就是寄生式繼承。

問題:優缺點和原型式繼承一樣,但是對于普通對象的繼承方式來說,寄生式繼承相比于原型式繼承,還是在父類基礎上添加了更多的方法。

6、寄生組合式繼承

結合第四種中提及的繼承方式,解決普通對象的繼承問題的 Object.create 方法,我們在前面這幾種繼承方式的優缺點基礎上進行改造,得出了寄生組合式的繼承方式,這也是所有繼承方式裡面相對最優的繼承方式

function clone (parent, child) {

    // 這裡改用 Object.create 就可以減少組合繼承中多進行一次構造的過程

    child.prototype = Object.create(parent.prototype);

    child.prototype.constructor = child;

  }



  function Parent6() {

    this.name = 'parent6';

    this.play = [1, 2, 3];

  }

   Parent6.prototype.getName = function () {

    return this.name;

  }

  function Child6() {

    Parent6.call(this);

    this.friends = 'child5';

  }



  clone(Parent6, Child6);



  Child6.prototype.getFriends = function () {

    return this.friends;

  }



  let person6 = new Child6();

  console.log(person6);

  console.log(person6.getName());

  console.log(person6.getFriends());
複制代碼      

寄生組合式繼承方式,基本可以解決前幾種繼承方式的缺點,較好地實作了繼承想要的結果,同時也減少了構造次數,減少了性能的開銷。

總結:

(1)第一種是以原型鍊的方式來實作繼承,但是這種實作方式存在 的缺點是,在包含有引用類型的資料時,會被所有的執行個體對象所共享, 容易造成修改的混亂。還有就是在建立子類型的時候不能向超類型傳 遞參數。

(2)第二種方式是使用借用構造函數的方式,這種方式是通過在子 類型的函數中調用超類型的構造函數來實作的,這一種方法解決了不 能向超類型傳遞參數的缺點,但是它存在的一個問題就是無法實作函 數方法的複用,并且超類型原型定義的方法子類型也沒有辦法通路到。

(3)第三種方式是組合繼承,組合繼承是将原型鍊和借用構造函數 組合起來使用的一種方式。通過借用構造函數的方式來實作類型的屬 性的繼承,通過将子類型的原型設定為超類型的執行個體來實作方法的繼 承。這種方式解決了上面的兩種模式單獨使用時的問題,但是由于我 們是以超類型的執行個體來作為子類型的原型,是以調用了兩次超類的構 造函數,造成了子類型的原型中多了很多不必要的屬性

(4)第四種方式是原型式繼承,原型式繼承的主要思路就是基于已 有的對象來建立新的對象,實作的原理是,向函數中傳入一個對象, 然後傳回一個以這個對象為原型的對象。這種繼承的思路主要不是為 了實作創造一種新的類型,隻是對某個對象實作一種簡單繼承,ES5 中定義的 Object.create() 方法就是原型式繼承的實作。缺點與原 型鍊方式相同。

(5)第五種方式是寄生式繼承,寄生式繼承的思路是建立一個用于 封裝繼承過程的函數,通過傳入一個對象,然後複制一個對象的副本, 然後對象進行擴充,最後傳回這個對象。這個擴充的過程就可以了解 是一種繼承。這種繼承的優點就是對一個簡單對象實作繼承,如果這 個對象不是自定義類型時。缺點是沒有辦法實作函數的複用。

(6)第六種方式是寄生式組合繼承,組合繼承的缺點就是使用超類 型的執行個體做為子類型的原型,導緻添加了不必要的原型屬性。寄生式 組合繼承的方式是使用超類型的原型的副本來作為子類型的原型,這 樣就避免了建立不必要的屬性。 

上面的繼承看見就想吐,而且還有人拷問這些東西,很煩!還好ES6出了class和extends ,我們可以通過class創造類,使用extends實作類的繼承。

class Person {

  constructor(name) {

    this.name = name

  }

  // 原型方法

  // 即 Person.prototype.getName = function() { }

  // 下面可以簡寫為 getName() {...}

  getName = function () {

    console.log('Person:', this.name)

  }

}

class Gamer extends Person {

  constructor(name, age) {

    // 子類中存在構造函數,則需要在使用“this”之前首先調用 super()。

    super(name)

    this.age = age

  }

}

const asuna = new Gamer('Asuna', 20)

asuna.getName() // 成功通路到父類的方法
複制代碼      

因為浏覽器的相容性問題,如果遇到不支援 ES6 的浏覽器,那麼就得利用 babel 這個編譯工具,将 ES6 的代碼編譯成 ES5,讓一些不支援新文法的浏覽器也能運作。

function _possibleConstructorReturn (self, call) { 

        // ...

        return call && (typeof call === 'object' || typeof call === 'function') ? call : self; 

}

function _inherits (subClass, superClass) { 

    // 這裡可以看到

    subClass.prototype = Object.create(superClass && superClass.prototype, { 

        constructor: { 

            value: subClass, 

            enumerable: false, 

            writable: true, 

            configurable: true 

        } 

    }); 

    if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; 

}



var Parent = function Parent () {

    // 驗證是否是 Parent 構造出來的 this

    _classCallCheck(this, Parent);

};

var Child = (function (_Parent) {

    _inherits(Child, _Parent);

    function Child () {

        _classCallCheck(this, Child);

        return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments));

}

    return Child;

}(Parent));
複制代碼      

ES5/ES6 的繼承除了寫法以外還有什麼差別?

1. ES5 的繼承實質上是先建立子類的執行個體對象,然後再将父類的方法添加 到 this 上(Parent.apply(this))

2. ES6 的繼承機制完全不同,實質上是先建立父類的執行個體對象 this(是以必 須先調用父類的 super()法),然後再用子類的構造函數修改 this。

3. ES5 的繼承時通過原型或構造函數機制來實作。

4. ES6 通過 class 關鍵字定義類,裡面有構造方法,類之間通過 extends 關 鍵字實作繼承。

5. 子類必須在 constructor 方法中調用 super 方法,否則建立執行個體報錯。因為子類沒有自己的 this 對象,而是繼承了父類的 this 對象,然後對其進行加工。 如果不調用 super 方法,子類得不到 this 對象。

6. 注意 super 關鍵字指代父類的執行個體,即父類的 this 對象。

繼續閱讀