1. 程式人生 > >帶你理解【JavaScript】中的繼承機制

帶你理解【JavaScript】中的繼承機制

# 前文 總所周知,繼承是所有OO語言中都擁有的一個共性。在JavaScript中,它的繼承機制與其他OO語言有著很大的不同,儘管ES6為我們提供了像面向物件繼承一樣的語法糖,但是其底層依然是建構函式,所以理解繼承的底層原理非常重要,所以今天讓我們來探討一下JavaScript中的繼承機制。 # 原型與原型鏈 要理解繼承,必須理解JavaScript中的原型與原型鏈,我在之前的上一篇文章對原型進行了深入的探討,有興趣的小夥伴可以看看~ **[《理解原型與原型鏈》](https://juejin.im/post/5eabeeedf265da7bda413a5d)** # 繼承 在JavaScript中,有六種主要常見的繼承方式,下面我會對每一種繼承方式進行分析並總結它們的**優缺點**。 ## 1.原型鏈繼承 #### 原型鏈繼承的概念 > 在JavaScript中,實現繼承主要是依靠原型鏈來實現的。其基本思想是是利用原型讓一個引用型別繼承另一個引用型別的屬性和方法。 讓我們簡單回顧一下建構函式、原型和例項的關係:每個建構函式都有一個原型物件**prototype**,原型物件都包含一個指向建構函式的指標**constructor**,而例項都包含一個指向原型物件的內部指標**\_\_proto\_\_** 假如我們讓**原型物件**等於另一個型別的**例項**,結果會怎麼樣呢?讓我們來看下面這段程式碼。 ```javascript function Father() { this.name = 'zhang'; } Father.prototype.sayName = function() { console.log(this.name); } function Son() { this.age = 18; } // 繼承了Father Son.prototype = new Father(); Son.prototype.sayAge = function() { console.log(this.age); } const xiaoming = new Son(); console.log(xiaoming.sayName()) // 'zhang' ``` 以上程式碼,**Son**繼承了**Father**,而繼承是通過建立**Father的例項**,並將**Son.prototype**指向new出來的**Father例項**。實現的本質是重寫了原型物件,待之是一個新型別的例項,也就是說,原來存在於**Father**建構函式中的所有屬性和方法,現在也存在於**Son.prototype**中。 通過上圖可知,我們沒有使用**Son**預設提供的原型,而是給它換了一個新原型,這個原型就是**Father的例項**,其內部還有一個指標,指向**Father**的原型。由於**Son的原型**被重寫了,所以**xiaoming**這個例項的**constructor**屬性現在指向的是**Father**。一句話總結就是**Son**繼承了**Father**,而**Father**繼承**Object**,當呼叫**xiaoming.toString()**方法時,實際上是呼叫**Object.prototype**中的**toString**方法。 > 注意:給子類原型新增方法的程式碼一定要放到替換原型的語句之後 還有一點需要提醒各位小夥伴們,在使用原型鏈繼承時,千萬不能使用**物件字面量**建立原型方法,因為這樣做會重寫原型鏈,來看下面這段程式碼。 ```javascript function Father() { this.name = 'zhang'; } Father.prototype.sayName = function() { console.log(this.name); } function Son() { this.age = 18; } // 繼承了Father Son.prototype = new Father(); Son.prototype = { sayAge: function() { console.log(this.age) } } const xiaoming = new Son(); console.log(xiaoming.sayName()) // '報錯' ``` 使用物件字面量建立原型方法,會切斷**Father**與**Son**之間的繼承關係哦~ #### 原型鏈繼承的優點 子型別的例項物件擁有超型別的全部屬性和方法。 #### 原型鏈繼承的缺點 我在上面的那篇文章提到過,**包含引用型別值的原型屬性會被所有例項共享**。在通過原型實現繼承時,**原型**實際上會變成**另一個型別的例項**,**原先的例項屬性**也就順理成章地變成了**現在的原型屬性**了。 ```javascript function Father() { this.cars = ['賓士', '寶馬', '蘭博基尼']; } Father.prototype.sayName = function() { console.log(this.name); } function Son() { this.age = 18; } // 繼承了Father Son.prototype = new Father(); const xiaoming = new Son(); xiaoming.cars.push('五菱巨集光'); console.log(xiaoming.cars); //'賓士, 寶馬, 蘭博基尼, 五菱巨集光' const xiaohong = new Son(); console.log(xiaohong.cars); //'賓士, 寶馬, 蘭博基尼, 五菱巨集光' ``` 可以從上述程式碼中發現,當**Father**中的屬性是引用型別的時候,當然**Father的每個例項**都會有各自的陣列**cars屬性**。當**Son**繼承**Father**之後,**Son.prototype**就變成了**Father的一個例項**,結果就是**xiaoming**和**xiaohong**兩個例項物件**共享**一個**cars屬性**,這是在繼承中我們不希望出現的。 第二個問題是建立**Son的例項**時,不能向**Father的建構函式**中傳遞引數,也就是說,沒有辦法在不影響所有**物件例項**的情況下,給**超型別的建構函式**傳遞引數。 接下來我要將的第二種繼承方式是**建構函式繼承**,它可以解決包含引用型別值所帶來的問題。 ## 2.建構函式繼承 #### 建構函式繼承的概念 > 實現建構函式繼承的基本思想相當簡單,即在子型別建構函式的內部呼叫超型別建構函式。 讓我們來看下面這段程式碼: ```javascript function Father() { this.cars = ['賓士', '寶馬', '蘭博基尼']; } function Son() { // 繼承Father Father.call(this); } const xiaoming = new Son(); xiaoming.cars.push('五菱巨集光'); console.log(xiaoming.cars); //'賓士, 寶馬, 蘭博基尼, 五菱巨集光' const xiaohong = new Son(); console.log(xiaohong.cars); //'賓士, 寶馬, 蘭博基尼' ``` 通過使用`call()`方法(或`apply()`方法),在建立**xiaoming例項**的同時,呼叫了**Father建構函式**,這樣一來,就會在**Son的例項物件**上執行**Father建構函式**所定義的所有物件初始化程式碼,因此**xiaoming**和**xiaohong**就具有屬於自己的**cars**屬性了。 建構函式繼承還有一個優點是可以**給超型別建構函式傳參**,讓我們來看下面這段程式碼。 ```javascript function Father(name) { this.name = name; } function Son(name, age) { Father(this, name); this.age = age; } const xiaoming = new Son('小明', 19); console.log(xiaoming.name); //'小明' console.log(xiaoming.age); //19 ``` 我們建立了**xiaoming**例項並傳遞兩個引數**name**和**age**,**name**引數通過呼叫**Father**建構函式傳遞引數給了**Father**建構函式中的**name**,因此**xiaoming**例項擁有**name**和**age**兩個例項屬性。 #### 建構函式繼承的優點 可以在子型別建構函式中向超型別建構函式傳參;子型別建構函式建立的物件都擁有各自的屬性和方法(引用型別) #### 建構函式繼承的缺點 很明顯,方法都在建構函式中定義的話,函式複用就無從談起了,因此建構函式繼承很少單獨使用。接下來介紹的這種繼承方式,通過**原型鏈**和**建構函式**結合實現的繼承,叫做**組合繼承**。 ## 3.組合繼承 #### 組合繼承的概念 > **組合繼承**的基本思路是使用**原型鏈**實現對原型屬性和方法的繼承,而通過**借用建構函式**來實現對例項屬性的繼承。 使用組合繼承的優點是即通過在原型上定義方法實現了函式複用,又能夠保證每個例項都有它自己的屬性,來看下面這段程式碼。 ```javascript function Father(name) { this.name = name; this.cars = ['賓士', '寶馬', '蘭博基尼']; } Father.prototype.sayName = function() { console.log(this.name); } function Son(name, age) { // 繼承屬性 Father.call(this, name); //第二次呼叫Father() this.age = age; } // 繼承方法 Son.prototype = new Father(); //第一次呼叫Father() Son.prototype.constructor = Son; Son.prototype.sayAge = function() { console.log(this.age); } const xiaoming = new Son('xiaoming', 18); xiaoming.cars.push('五菱巨集光'); console.log(xiaoming.cars); //'賓士, 寶馬, 蘭博基尼, 五菱巨集光' xiaoming.sayName(); //'xiaoming' xiaoming.sayAge(); //18 const xiaohong = new Son('xiaohong', 20); console.log(xiaohong.cars); //'賓士, 寶馬, 蘭博基尼' xiaohong.sayName(); //'xiaohong' xiaohong.sayAge(); //20 console.log(xiaoming instanceof Son) //true console.log(xiaoming instanceof Father) //true console.log(xiaoming instanceof Object) //true ``` #### 組合繼承的優點 組合繼承避免了**原型鏈繼承**和**建構函式繼承**的缺陷,融合它們的優點,成為JavaScript中最常用的繼承模式。 #### 組合繼承的缺點 組合繼承最大的問題就是無論什麼情況下,都會呼叫兩次超型別建構函式。一次是在**建立子型別原型**的時候,另一次是在**子型別建構函式內部**。 ## 4.原型式繼承 #### 原型式繼承的概念 > 原型式繼承的就是藉助**原型**可以基於**已有的物件**建立**新物件**。 我們來看下面這段程式碼。 ```javascript function object(o) { function F() {} F.prototype = o; return new F(); } const person = { name: 'zhangsan', cars: ['賓士', '寶馬', '蘭博基尼'] } const anotherPerson = object(person); anotherPerson.name = 'lisi'; anotherPerson.cars.push('五菱巨集光'); console.log(anotherPerson.name); //'lisi' console.log(anotherPerson.cars); //'賓士, 寶馬, 蘭博基尼, 五菱巨集光' const yetAnotherPerson = object(person); yetAnotherPerson.name = 'wangwu'; console.log(yetAnotherPerson.name); //'wangwu' console.log(yetAnotherPerson.cars); //'賓士, 寶馬, 蘭博基尼, 五菱巨集光' ``` `object()`實際上是對物件的一次淺複製,實現原型式繼承的前提是要求你必須有一個物件可以作為另一個物件的基礎。 **ES5**新增了`Object.create()`方法,這個方法規範化了原型式繼承。這個方法我在這裡不多介紹,感興趣的小夥伴可以參考MDN的說明文件[Object.create()](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/create) #### 原型式繼承優點 如果只想讓一個物件與另外一個物件保持類似的情況下,原型式繼承可以完全勝任。 #### 原型式繼承缺點 原型式繼承的缺點相信各位小夥伴們已經看出來了,包含引用型別值的屬性始終都會共享相應的值,就像使用原型鏈繼承一樣。 ## 5.寄生式繼承 #### 寄生式繼承的概念 > 寄生式(parasitic)繼承是與原型式繼承緊密相關的一種思路,即建立一個僅用於封裝繼承過程的函式,該函式在內部以某種方式來增強物件,最後再返回物件。 廢話不多說,讓我們來看下面這段程式碼。 ```javascript function createAnother(original) { const clone = Object.create(original); clone.sayHi = function() { console.log('hi'); } return clone; } const person = { name: 'zhangsan', cars: ['賓士', '寶馬', '蘭博基尼'] } const anotherPerson = createAnother(person); anotherPerson.sayHi(); //'hi' const yetAnotherPerson = createAnother(person); yetAnotherPerson.sayHi(); //'hi' console.log(anotherPerson.sayHi == yetAnotherPerson.sayHi) //false ``` 這個例子中,封裝了一個`createAnother`的函式,這個函式接收一個引數,也就是將要作為新物件的基礎物件,我們可以看到,**anotherPerson**和**yetAnotherPerson**兩個物件擁有各自的sayHi方法。 在主要考慮物件而不是自定義型別和建構函式的情況下,寄生式繼承也是一種有用的模式。 #### 寄生式繼承優點 繼承的物件都擁有各自的屬性和方法(引用型別)。 #### 寄生式繼承缺點 使用寄生式繼承來為物件新增函式,會由於不能做到函式複用而降低效率,這一點與建構函式繼承模式類似。 # 6.寄生組合式繼承 #### 寄生組合繼承的概念 > 所謂寄生組合式繼承,就是通過建構函式來繼承屬性,通過原型鏈的混成形式來繼承方法。其背後的基本思路是:不必為了指定子型別的原型而呼叫超型別的建構函式,我們所需的無非就是超型別原型的一個副本而已。 本質上,就是使用**寄生式繼承**來繼承**超型別的原型**,然後再將結果指定給**子型別的原型**。讓我們來看下面這段程式碼。 ```javascript function inheritPrototype(Son, Father) { const prototype = Object.create(Father.prototype); prototype.constructor = Son; Son.prototype = prototype; } function Father(name) { this.name = name; this.cars = ['賓士', '寶馬', '蘭博基尼']; } Father.prototype.sayName = function() { console.log(this.name); } function Son(name, age) { Father.call(this, name); //呼叫Father this.age = age; } inheritPrototype(Son, Father); Son.prototype.sayAge = function() { console.log(this.age); } ``` 這個例子的高效率體現在它只調用了一次**Father**建構函式,並且因此避免在**Son.prototype**上面建立不必要、多餘的屬性。 #### 寄生組合式繼承優點 寄生組合式繼承只調用了一次超型別建構函式,是被開發人員普遍認為是引用型別最理想的繼承正規化。 #### 寄生組合式繼承無缺點 # 總結 前端的學習之路還有很長很長,這篇文章只不過是冰山一角,希望前端cc寫的這篇文章能給小夥伴們帶來新的知識拓展,願前端cc與各位前端小夥伴們在前端生涯中一起共同成長,