前言
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);
打印:
解析:
當我們執行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方法,但是還是可以訪問。上述的大致過程,我們可以這樣看:
這條鏈有點繞,所以這也是為什麼大家對原型鏈繼承總是那麼暈頭轉向的原因。建議讀的時候想一下這條鏈是什麼樣的,怎麼來的。讀到這裏的同學,如果感覺自己看的不是很懂,那暫時不用繼續往下看啦,説明原型鏈還沒有弄清楚,建議還是先把原型鏈弄清楚,這樣才好理解繼承。去搞懂
如果我們這時候給實例cat的like屬性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);
打印:
我們會發現,如果我們修改實例cat的屬性,並且該屬性是引用類型的話,後續實例化出來的對象,都會被影響到。因為cat跟small_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);
打印:
從打印我們可以看出:
- 實例化子類
Cat時,可以傳入參數 - 父類
Animal裏的屬性方法,都被添加到實例cat跟實例small_cat的自身裏了(因為子類Cat調用了call方法,某種程度來説繼承了父類Animal裏的屬性方法) - 修改實例
cat不會影響到實例small_cat(因為實例出來的對象,所有的屬性、方法都是添加到實例對象自身,而不是添加到實例對象的原型上,它們是完全獨立,指向的都是不同的地址) - 打印
run方法,輸出都是undefined,説明實例沒有繼承父類Animal原型上的方法(實例的原型鏈沒有跟父類Animal原型鏈打通,因此原型鏈上搜索不到run方法,可以跟原型鏈繼承對比想想) - 子類的原型
Cat.prototype與父類原型Animal.prototype沒有打通,因為Cat.prototype.__proto__直接指向了Object.prototype,如果打通了的話,應該是Cat.prototype.__proto__指向Animal.prototype,這也是為什麼實例cat沒有繼承父類run方法的原因,因為訪問不到。
總結:
-
優點:
- 實例化時,可以傳參
- 子類通過
call或apply方法,將父類裏的所有屬性、方法複製到實例對象的自身,而不是共享原型鏈上同一個屬性,所以修改一個實例對象的引用類型屬性時,不會導致所有實例對象受到影響
-
缺點:
- 無法繼承父類原型上的屬性與方法
我們通過借用構造函數繼承的方法,解決了原型鏈繼承的缺點。但是又產生了一個新的問題——子類無法繼承父類原型(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);
打印:
由上圖我們能得出總結:
-
優點:
- 利用原型鏈繼承,將實例
cat、子類Cat、父類Animal三者的原型鏈串聯起來,讓實例對象繼承父類原型Animal.prototype的方法與屬性 - 利用借用構造函數繼承,將父類構造函數
function Animal() {}的屬性、方法添加到實例自身上,解決原型鏈繼承,實例修改引用類型屬性時對後續實例影響問題 - 利用構造函數繼承,實例化對象時,可傳參
- 利用原型鏈繼承,將實例
-
缺點:
- 兩次調用父類構造函數
function Animal() {}(第一次在子類Cat構造函數內調用,第二次在new Animal()時候調用) - 實例自身擁有的屬性,子類
Cat.prototype裏也會有,造成不必要的浪費(因為Cat.prototype被重寫為new Animal()了,new Animal()是父類的一個實例,也有name、sex、like屬性)
- 兩次調用父類構造函數
看來組合繼承也不是最完美的繼承方式。我們先把組合繼承放一邊,先看看什麼是原型式繼承。
原型式繼承
原理:用於創建一個新對象,使用現有的對象來作為新創建對象的原型(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);
打印:
由上圖我們可以得出總結:
-
優點:
- 實現比原型鏈繼承更簡潔(不需要寫什麼構造函數了,也不需要寫子類
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);
打印:
總結:
-
優點:
- 實現比原型鏈繼承更簡潔
- 子類實例可以訪問到父類的屬性方法
-
缺點:
- 父類所有的引用類型屬性都會被實例出來的對象共享,所以修改一個實例對象的引用類型屬性,會導致所有實例對象受到影響
- 實例化時,不能傳參數
寄生式繼承優缺點跟原型式繼承一樣,但最重要的是它提供了一個類似工廠的思想,是對原型式繼承的一個封裝。前面我們説到組合繼承還是會有一些缺陷,通過原型式繼承跟寄生式繼承,我們可以利用這兩個繼承的思想,來解決組合繼承的缺陷,它就是寄生組合式繼承。
寄生式組合繼承
原理:利用原型鏈繼承,實現實例對父類原型(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);
寄生式組合繼承打印:
組合繼承打印:
我們可以對比一下組合繼承那張圖會發現:
- 實例
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.constructorfinalProperty.constructor = childClass:寄生式繼承思想,增強對象。矯正finalProperty.constructor,讓其指向childClasschildClass.prototype = finalProperty:使得實例找不到方法屬性,會去childClass.prototype(finalProperty)裏找;再找不到會去finalProperty.__proto__(parentClass.prototype)裏找。打通了子類childClass與父類的parentClass原型鏈,實現了父子類的繼承。
inheritObj方法,其實質就是下面的實現,這樣可能可以更加直觀的看出繼承:
// 定義一個利用原型式繼承方式,跟寄生式繼承思想來實現寄生組合式繼承的方法
function inheritObj(parentClass, childClass) {
// parentClass 為傳入的父類
// childClass 為傳入的子類
childClass.prototype.__proto__ = parentClass.prototype;
childClass.prototype.constructor = childClass;
}
最後
終於寫完了!真的太累了!希望這篇文章讀完對大家有所幫助,面試的時候不虛。只要理解透了各個繼承方式的原理,各個繼承方式的優缺點真的沒有必要背,優缺點自己總結就好了呀,萬變不離其宗~
如果大家有什麼異同,歡迎評論交流;如果覺得這篇文章好的話,歡迎點贊分享,這篇文章真的花了我不少功夫。