動態

詳情 返回 返回

JS繼承面試的時候怎麼説?答應我,不要再死記硬背了好嗎? - 動態 詳情

前言

JS繼承這塊,ES6已經有class很香的語法糖實現了,ES6之前那些實現繼承的方法真的又多又長,説句心裏話,能不學真的不想再學,但是沒辦法,面試還是要搞你呀,所以這兩天看回ES6之前的繼承,發現還是蠻有意思的。寫這篇文章也是對自己的一個梳理總結,也希望能幫助到大家弄懂繼承這塊,這樣就不需要再死記硬背八股文,面試自由發揮就好。
JS的繼承,核心就是靠原型鏈完成。如果大家對原型鏈還不是很清楚,可以先讀讀我寫的這篇關於原型鏈的文章——[關於原型鏈的問題,教你怎麼套用方法直接判斷,面試不再虛
](https://segmentfault.com/a/1190000041545743)。

文章蠻長,大家可以分成兩部分來看。原型鏈繼承、盜用構造函數繼承、組合繼承為一部分,原型式繼承、寄生式繼承、寄生式組合繼承為一部分。

為了讓大家更好的理解,後面的例子,我們都用:

  • Animal作為父類
  • Cat為子類
  • cat為子類Cat實例一,small_cat為子類Cat實例二

JS繼承最常見的六種方式

  • 原型鏈繼承
  • 盜用構造函數繼承
  • 組合繼承
  • 原型式繼承
  • 寄生式繼承
  • 寄生式組合繼承

原型鏈繼承

原理:為什麼叫原型鏈繼承,我們可以這樣記,因為核心就是我們會重寫某個構造函數的原型(prototype),使其指向父類的一個實例,以此讓它們的原型鏈不斷串聯起來,從而實現繼承。

將子類Cat.prototype指向父類Animal的一個實例(Cat.prototype = new Animal()),這樣我們就完成了一個原型鏈繼承。來看看具體例子:

// 定義一個父類
function Animal() {
  this.like = ['eat', 'drink', 'sleep'];
}

// 為父類的原型添加一個run方法
Animal.prototype.run = function() {
  console.log('跑步');
}

// 定義一個子類
function Cat() {
  this.name = 'limingcan';
}

// 核心:將Cat的原型指向父類Animal的一個實例
Cat.prototype = new Animal();

// 實例cat.constructor是來自Cat.prototype.constructor
// 不矯正的cat.constructor話,當前的cat.constructor指向的是Animal
// 因為Cat.prototype被重寫,constructor被指向了new Animal().__proto__.constructor,相當於Animal.prototype.constructor
Cat.prototype.constructor = Cat;

// 實例一個由子類 new 出來的對象
const cat = new Cat();

cat.run();

console.log(cat);

打印:
<img src="../md/js-inherit/pic_0.png" />

解析:

當我們執行Cat.prototype = new Animal();這句時,發生了什麼:

它把Cat.prototype整個重寫了,並將兩者通過原型鏈聯繫起來,從而實現繼承。因為我們將Cat.prototype指向了父類Animal的一個實例,我們暫時把這個實例叫做中介實例X,這個中介實例X自己也有一個__proto__,它又指向了Animal.prototype。所以當實例cat在自身找不到屬性方法時,它會去cat.__proto__(相當於Cat.prototype,但是Cat.prototype被重寫成了中介實例X,所以也是去中介實例X裏面找)找。如果中介實例X也找不到,就會去中介實例X.__proto__(相當於Animal.prototype)找。有值的話,則返回值;沒有值的話又會去Animal.prototype.__proto__(相當於Object.prototype)找。有值的話,則返回值;沒有值的話又會去Object.prototype.__proto__找,但是Object.prototype.__proto__返回null,原型鏈到頂,一條條原型鏈搜索完畢,都沒有,則返回undefined。所以這就是為什麼實例cat自身沒有like屬性跟run方法,但是還是可以訪問。上述的大致過程,我們可以這樣看:
<img src="../md/js-inherit/pic_1.png" />

這條鏈有點繞,所以這也是為什麼大家對原型鏈繼承總是那麼暈頭轉向的原因。建議讀的時候想一下這條鏈是什麼樣的,怎麼來的。讀到這裏的同學,如果感覺自己看的不是很懂,那暫時不用繼續往下看啦,説明原型鏈還沒有弄清楚,建議還是先把原型鏈弄清楚,這樣才好理解繼承。去搞懂

如果我們這時候給實例catlike屬性push一個值,看看下面例子:

// 定義一個父類
function Animal() {
  this.like = ['eat', 'drink', 'sleep'];
}

// 為父類的原型添加一個run方法
Animal.prototype.run = function() {
  console.log('跑步');
}

// 定義一個子類
function Cat() {
  this.name = 'limingcan';
}

// 核心:將Cat的原型指向父類Animal的一個實例
Cat.prototype = new Animal();

// 實例cat.constructor是來自Cat.prototype.constructor
// 不矯正的cat.constructor話,當前的cat.constructor指向的是Animal
// 因為Cat.prototype被重寫,constructor被指向了new Animal().__proto__.constructor,相當於Animal.prototype.constructor
Cat.prototype.constructor = Cat;

// 實例一個由子類 new 出來的對象
const cat = new Cat();

// 給like屬性push一個play值
cat.like.push('play');

// 實例第二個對象
const small_cat = new Cat();

console.log(cat.like);

console.log(small_cat.like);

console.log(cat.like === small_cat.like);

打印:
<img src="../md/js-inherit/pic_2.png" />

我們會發現,如果我們修改實例cat的屬性,並且該屬性是引用類型的話,後續實例化出來的對象,都會被影響到。因為catsmall_cat自身沒有like屬性,它們的like都繼承自Cat.prototype,指向的是的同一份地址。

如果想要兩個實例修改like互不影響,只能給他們自身增加一個like屬性(cat.like = ['eat', 'drink', 'sleep', 'play'];cat_small.like = ['food']。如果自身有屬性,是不會去prototype查找的,它們是兩個實例自己獨有的屬性,指向不同地址),但這樣就失去了繼承的意義了。

總結:

  • 優點:

    • 實現相對簡單
    • 子類實例可以直接訪問到父類實例或父類原型上的屬性方法
  • 缺點:

    • 父類所有的引用類型屬性都會被實例出來的對象共享,所以修改一個實例對象的引用類型屬性,會導致所有實例對象受到影響
    • 實例化時,不能傳參數

因此為了解決原型鏈繼承的缺點,又搞了個盜用構造函數繼承的方式。

盜用構造函數繼承

盜用構造函數繼承,也叫借用構造函數繼承,它可以解決原型鏈繼承帶來的缺點。

原理:在子類構造函數中,調用父類構造函數方法,但通過call或者apply方法改變了父類構造函數內this的指向,使得子類實例出來的對象,自身擁有來自父類構造函數的方法跟屬性,且分別獨立,互不影響。

來看看具體例子:

// 定義一個父類
function Animal(name) {
  this.name = name;
  this.like = ['eat', 'drink', 'sleep'];
  this.play = function() {
    console.log('到處玩');
  }
}

// 為父類的原型添加一個run方法
Animal.prototype.run = function() {
  console.log('跑步');
}

// 定義一個子類
function Cat(name, age) {
  Animal.call(this, name);
  this.age = age;
}

// 實例一個由子類 new 出來的對象
const cat = new Cat('limingcan', 27);

// 給實例cat的like屬性push一個toys值
cat.like.push('toys');

// 實例第二個對象
const small_cat = new Cat('mimi', 100);

console.log(cat);

console.log(small_cat);

console.log(cat.run);

console.log(small_cat.run);

打印:
<img src="../md/js-inherit/pic_3.png" />

從打印我們可以看出:

  1. 實例化子類Cat時,可以傳入參數
  2. 父類Animal裏的屬性方法,都被添加到實例cat跟實例small_cat自身裏了(因為子類Cat調用了call方法,某種程度來説繼承了父類Animal裏的屬性方法)
  3. 修改實例cat不會影響到實例small_cat(因為實例出來的對象,所有的屬性、方法都是添加到實例對象自身,而不是添加到實例對象的原型上,它們是完全獨立,指向的都是不同的地址)
  4. 打印run方法,輸出都是undefined,説明實例沒有繼承父類Animal原型上的方法(實例的原型鏈沒有跟父類Animal原型鏈打通,因此原型鏈上搜索不到run方法,可以跟原型鏈繼承對比想想)
  5. 子類的原型Cat.prototype與父類原型Animal.prototype沒有打通,因為Cat.prototype.__proto__直接指向了Object.prototype,如果打通了的話,應該是Cat.prototype.__proto__指向Animal.prototype,這也是為什麼實例cat沒有繼承父類run方法的原因,因為訪問不到。

總結:

  • 優點:

    • 實例化時,可以傳參
    • 子類通過callapply方法,將父類裏的所有屬性、方法複製到實例對象的自身,而不是共享原型鏈上同一個屬性,所以修改一個實例對象的引用類型屬性時,不會導致所有實例對象受到影響
  • 缺點:

    • 無法繼承父類原型上的屬性與方法

我們通過借用構造函數繼承的方法,解決了原型鏈繼承的缺點。但是又產生了一個新的問題——子類無法繼承父類原型(Animal.prototype)上的屬性與方法,如果我們把這兩種方式結合一下,會不會好點呢,於是有了組合繼承這個繼承方式。

組合繼承

組合繼承顧名思義就是,利用原型鏈繼承跟借用構造函數繼承相結合,而創造出來的一種新的繼承方式,是不是很好記。

原理:利用原型鏈繼承,實現實例對父類原型(Animal.protoytype)上的方法與屬性繼承;利用借用構造函數繼承,實現實例對父類構造函數(function Animal() {})裏方法與性的繼承,並且解決原型鏈繼承的缺陷。

來看看具體例子:

// 定義一個父類
function Animal(name, sex) {
  this.name = name;
  this.sex = sex;
  this.like = ['eat', 'drink', 'sleep'];
}

// 為父類的原型添加一個run方法
Animal.prototype.run = function() {
  console.log('跑步');
}

// 定義一個子類
function Cat(name, sex, age) {
  // 第一次調用Animal構造函數
  Animal.call(this, name, sex);
  this.age = age;
}

// 核心:將Cat的原型指向父類Animal的一個實例(第二次調用Animal構造函數)
Cat.prototype = new Animal();

// 實例cat.constructor是來自Cat.prototype.constructor
// 不矯正的cat.constructor話,當前的cat.constructor指向的是Animal
// 因為Cat.prototype被重寫,constructor被指向了new Animal().__proto__.constructor,相當於Animal.prototype.constructor
Cat.prototype.constructor = Cat;

// 實例一個由子類new 出來的對象
const cat = new Cat('limingcan', 'man', 27);
console.log(cat);

打印:
<img src="../md/js-inherit/pic_4.png" />

由上圖我們能得出總結:

  • 優點:

    • 利用原型鏈繼承,將實例cat、子類Cat、父類Animal三者的原型鏈串聯起來,讓實例對象繼承父類原型Animal.prototype的方法與屬性
    • 利用借用構造函數繼承,將父類構造函數function Animal() {}的屬性、方法添加到實例自身上,解決原型鏈繼承,實例修改引用類型屬性時對後續實例影響問題
    • 利用構造函數繼承,實例化對象時,可傳參
  • 缺點:

    • 兩次調用父類構造函數function Animal() {}(第一次在子類Cat構造函數內調用,第二次在new Animal()時候調用)
    • 實例自身擁有的屬性,子類Cat.prototype裏也會有,造成不必要的浪費(因為Cat.prototype被重寫為new Animal()了,new Animal()是父類的一個實例,也有namesexlike屬性)

看來組合繼承也不是最完美的繼承方式。我們先把組合繼承放一邊,先看看什麼是原型式繼承。

原型式繼承

原理:用於創建一個新對象,使用現有的對象來作為新創建對象的原型(prototype)。一般使用Object.create()方法實現,詳細用法可以看看這裏。

來看看具體例子:

// 定義一個父類(新建出來的對象的__proto__會指向它)
const Animal = {
  name: 'nobody',
  like: ['eat', 'drink', 'sleep'],
  run() {
    console.log('跑步');
  }
};

// 新建以Animal為原型的實例
const cat = Object.create(
  Animal,
  // 這裏定義的是實例自身的方法或屬性
  {
    name: {
      value: 'limingcan'
    }
  }
);

// 給實例cat屬性like添加一個play值
cat.like.push('play');

const small_cat = Object.create(
  Animal,
  // 這裏定義的是實例自身的方法或屬性
  {
    name: {
      value: 'mimi'
    }
  }
);

console.log(cat);
console.log(small_cat);
console.log(cat.__proto__ === Animal);

打印:
<img src="../md/js-inherit/pic_5.png" />

由上圖我們可以得出總結:

  • 優點:

    • 實現比原型鏈繼承更簡潔(不需要寫什麼構造函數了,也不需要寫子類Cat,直接父類繼承Animal
    • 子類實例可以訪問到父類的屬性方法
  • 缺點:

    • 父類所有的引用類型屬性都會被實例出來的對象共享,所以修改一個實例對象的引用類型屬性,會導致所有實例對象受到影響
    • 實例化時,不能傳參數

我們可以對比原型鏈繼承方式,其實這兩種方式差不多,所以它要跟原型鏈繼承存在一樣的缺點,但是實現起來比原型式繼承更加簡潔方便一些。如果我們只是想讓一個對象跟另一個對象保持類似,原型式繼承可能更加舒服,因為它不需要像原型鏈繼承那樣大費周章。接下來我們再看看另一種繼承方式——寄生式繼承。

寄生式繼承

原理:它其實就是對原型式繼承進行一個小封裝,增強了一下實例出來的對象

來看看具體例子:


// 定義一個父類(新建出來的對象的__proto__會指向它)
const Animal = {
  name: 'nobody',
  like: ['eat', 'drink', 'sleep'],
  run() {
    console.log('跑步');
  }
};

// 定義一個封裝Object.create()方法的函數
const createObj = (parentPropety, ownProperty) => {
  // 生成一個以parentPropety 為原型的對象obj
  // ownProperty 是新建出來的實例,擁有自身的屬性跟方法配置
  const obj = Object.create(parentPropety, ownProperty);

  // 增強功能
  obj.catwalk = function() {
    console.log('走貓步');
  };

  return obj;
}

// 新建以Animal為原型的實例一
const cat = createObj(Animal, {
  name: {
    value: 'limingcan'
  }
})

// 給實例cat屬性like添加一個play值
cat.like.push('play');

// 新建以Animal為原型的實例二
const small_cat = createObj(Animal, {
  name: {
    value: 'mimi'
  }
})

console.log(cat);
console.log(small_cat);
console.log(cat.__proto__ === Animal);

打印:
<img src="../md/js-inherit/pic_6.png" />

總結:

  • 優點:

    • 實現比原型鏈繼承更簡潔
    • 子類實例可以訪問到父類的屬性方法
  • 缺點:

    • 父類所有的引用類型屬性都會被實例出來的對象共享,所以修改一個實例對象的引用類型屬性,會導致所有實例對象受到影響
    • 實例化時,不能傳參數

寄生式繼承優缺點跟原型式繼承一樣,但最重要的是它提供了一個類似工廠的思想,是對原型式繼承的一個封裝。前面我們説到組合繼承還是會有一些缺陷,通過原型式繼承跟寄生式繼承,我們可以利用這兩個繼承的思想,來解決組合繼承的缺陷,它就是寄生組合式繼承。

寄生式組合繼承

原理:利用原型鏈繼承,實現實例對父類原型(Animal.prototype)方法與屬性的繼承;利用借用構造函數繼承,實現實例對父類構造函數(function Animal() {})裏方法與屬性的繼承,並且解決了組合繼承帶來的缺陷

前面我們説到,組合繼承會有以下兩個缺點:

  • 會兩次調用父類構造函數function Animal() {}。(第一次在子類構造函數內使用call或者apply方法時調用;第二次在Cat.prototype = new Animal()時候調用了)
  • 實例自身擁有的屬性,子類構造函數的prototype裏也會有,造成不必要的浪費(因為子類構造函數的protptype被重寫為父類的一個實例了,所以Cat.prototype也會擁有父類實例裏的屬性跟方法)

通過上面原型式繼承的方式,我們可以把原型鏈繼承裏,Cat.prototype = new Animal()這一步,用寄生式繼承的思想,用Object.create()方法實現並替換掉。來看看具體例子:

// 定義一個父類
function Animal(name, sex) {
  this.name = name;
  this.sex = sex;
  this.like = ['eat', 'drink', 'sleep'];
}

// 定義一個子類
function Cat(name, sex, age) {
  // 第一次調用Animal構造函數
  Animal.call(this, name, sex);
  this.age = age;
}

// 定義一個利用原型式繼承方式,跟寄生式繼承思想來實現寄生組合式繼承的方法
function inheritObj(parentClass, childClass) {
  // parentClass 為傳入的父類
  // childClass 為傳入的子類
  // finalProperty 為最後繼承的原型對象

  const finalProperty = Object.create(parentClass.prototype);

  finalProperty.constructor = childClass;

  childClass.prototype = finalProperty;
}

// 為父類的原型添加一個run方法
Animal.prototype.run = function() {
  console.log('跑步');
}

// 實現寄生組合繼承
inheritObj(Animal, Cat);

// 給子類的原型添加一個方法
Cat.prototype.catwalk = function() {
  console.log('走貓步');
}

// 實例一個由子類new 出來的對象
const cat = new Cat('limingcan', 'man', 27);

console.log(cat);

寄生式組合繼承打印:
<img src="../md/js-inherit/pic_7.png" />

組合繼承打印:
<img src="../md/js-inherit/pic_4.png" />

我們可以對比一下組合繼承那張圖會發現:

  • 實例cat自身該有的屬性都有
  • Cat.prototype也乾淨了,沒有把父類的屬性都複製一遍,只有自己添加的catwalk方法
  • Animal.prototype也十分乾淨,只有自己添加的run方法

這是基本我們最想要的結果,也是最理想的繼承方式。

解析:
<!-- (我們把parentClass稱作父類,把childClass稱作子類,把finalProperty稱作最後繼承的原型對象) -->
我們想想為什麼在組合繼承時,我們要Cat.prototype = new Animal()?核心是因為我們要打通實例cat、子類Cat、父類Animal三者的原型鏈,從而實現繼承。我們順着這個思路,解析一下上面inheritObj這個方法,短短三行,但是為什麼會發生那麼神奇的事:

  • const finalProperty = Object.create(parentClass.prototype):淺拷貝一份parentClass.prototype,並將其作為finalProperty對象的原型,即finalProperty.__proto__ === parentClass.prototype。此時finalProperty.constructor指向的是parentClass.prototype.constructor
  • finalProperty.constructor = childClass:寄生式繼承思想,增強對象。矯正finalProperty.constructor,讓其指向childClass
  • childClass.prototype = finalProperty:使得實例找不到方法屬性,會去childClass.prototypefinalProperty)裏找;再找不到會去finalProperty.__proto__(parentClass.prototype)裏找。打通了子類childClass與父類的parentClass原型鏈,實現了父子類的繼承。

inheritObj方法,其實質就是下面的實現,這樣可能可以更加直觀的看出繼承:

// 定義一個利用原型式繼承方式,跟寄生式繼承思想來實現寄生組合式繼承的方法
function inheritObj(parentClass, childClass) {
  // parentClass 為傳入的父類
  // childClass 為傳入的子類
  
  childClass.prototype.__proto__ = parentClass.prototype;

  childClass.prototype.constructor = childClass;
}

最後

終於寫完了!真的太累了!希望這篇文章讀完對大家有所幫助,面試的時候不虛。只要理解透了各個繼承方式的原理,各個繼承方式的優缺點真的沒有必要背,優缺點自己總結就好了呀,萬變不離其宗~
如果大家有什麼異同,歡迎評論交流;如果覺得這篇文章好的話,歡迎點贊分享,這篇文章真的花了我不少功夫。

user avatar haoqidewukong 頭像 kobe_fans_zxc 頭像 aqiongbei 頭像 chongdianqishi 頭像 leexiaohui1997 頭像 huichangkudelingdai 頭像 zero_dev 頭像 wmbuke 頭像 munergs 頭像 weidewei 頭像 kitty-38 頭像 DingyLand 頭像
點贊 75 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.