JS基礎-全方位掌握繼承


前言

上篇文章詳細解析了原型、原型鏈的相關知識點,這篇文章講的是和原型鏈有密切關聯的繼承,它是前端基礎中很重要的一個知識點,它對於代碼複用來說非常有用,本篇將詳細解析JS中的各種繼承方式和優缺點進行,希望看完本篇文章能夠對繼承以及相關概念理解的更爲透徹。

本篇文章需要先理解原型、原型鏈以及 call的相關知識:

JS基礎-函數、對象和原型、原型鏈的關係

js基礎-面試官想知道你有多理解call,apply,bind?

何爲繼承?

維基百科:繼承可以使得子類具有父類別的各種屬性和方法,而不需要再次編寫相同的代碼。

繼承是一個類從另一個類獲取方法和屬性的過程

PS:或者是多個類

JS實現繼承的原理

記住這個概念,你會發現JS中的繼承都是在實現這個目的,差異是它們的實現方式不同。

複製父類的屬性和方法來重寫子類原型對象

原型鏈繼承(new):

  
    
  
  
  
  1. function fatherFn() {

  2. this.some = '父類的this屬性';

  3. }

  4. fatherFn.prototype.fatherFnSome = '父類原型對象的屬性或者方法';

  5. // 子類

  6. function sonFn() {

  7. this.obkoro1 = '子類的this屬性';

  8. }

  9. // 核心步驟:重寫子類的原型對象

  10. sonFn.prototype = new fatherFn(); // 將fatherFn的實例賦值給sonFn的prototype

  11. sonFn.prototype.sonFnSome = '子類原型對象的屬性或者方法' // 子類的屬性/方法聲明在後面,避免被覆蓋

  12. // 實例化子類

  13. const sonFnInstance = new sonFn();

  14. console.log('子類的實例:', sonFnInstance);

原型鏈子類實例

原型鏈繼承獲取父類的屬性和方法

  1. fatherFn通過this聲明的屬性/方法都會綁定在 new期間創建的新對象上。

  2. 新對象的原型是 father.prototype,通過原型鏈的屬性查找到 father.prototype的屬性和方法。

理解 new做了什麼:

new在本文出現多次,new也是JS基礎中很重要的一塊內容,很多知識點會涉及到new,不太理解的要多看幾遍。

  1. 創建一個全新的對象。

  2. 這個新對象的原型( __proto__)指向函數的 prototype對象。

  3. 執行函數,函數的this會綁定在新創建的對象上。

  4. 如果函數沒有返回其他對象(包括數組、函數、日期對象等),那麼會自動返回這個新對象。

  5. 返回的那個對象爲構造函數的實例。

構造調用函數返回其他對象

返回其他對象會導致獲取不到構造函數的實例,很容易因此引起意外的問題

我們知道了 fatherFnthisprototype的屬性/方法都跟 new期間創建的新對象有關係

如果在父類中返回了其他對象( new的第四點),其他對象沒有父類的 thisprototype,因此導致原型鏈繼承失敗

我們來測試一下,修改原型鏈繼承中的父類 fatherFn

  
    
  
  
  
  1. function fatherFn() {

  2. this.some = '父類的this屬性';

  3. console.log('new fatherFn 期間生成的對象', this)

  4. return [ '數組對象', '函數對象', '日期對象', '正則對象', '等等等', '都不會返回new期間創建的新對象' ]

  5. }

PS:本文中構造調用函數都不能返回其他函數,下文不再提及該點。

不要使用對象字面量的形式創建原型方法:

這種方式很容易在不經意間,清除/覆蓋了原型對象原有的屬性/方法,不該爲了稍微簡便一點,而使用這種寫法。

有些人在需要在原型對象上創建多個屬性和方法,會使用對象字面量的形式來創建:

  
    
  
  
  
  1. sonFn.prototype = new fatherFn();

  2. // 子類的prototype被清空後 重新賦值, 導致上一行代碼失效

  3. sonFn.prototype = {

  4. sonFnSome: '子類原型對象的屬性',

  5. one: function() {},

  6. two: function() {},

  7. three: function() {}

  8. }

還有一種常見的做法,該方式會導致函數原型對象的屬性 constructor丟失:

  
    
  
  
  
  1. function test() {}

  2. test.prototype = {

  3. ...

  4. }

原型鏈繼承的缺點

  1. 父類使用 this聲明的屬性被所有實例共享

    原因是:實例化的父類( sonFn.prototype=newfatherFn())是一次性賦值到子類實例的原型( sonFn.prototype)上,它會將父類通過 this聲明的屬性也在賦值到 sonFn.prototype上。

值得一提的是:很多博客中說,引用類型的屬性被所有實例共享,通常會用數組來舉例,實際上數組以及其他父類通過 this聲明的屬性也只是通過原型鏈查找去獲取子類實例的原型( sonFn.prototype)上的值。

  1. 創建子類實例時,無法向父類構造函數傳參,不夠靈活。

這種模式父類的屬性、方法一開始就是定義好的,無法向父類傳參,不夠靈活。

  
    
  
  
  
  1. sonFn.prototype = new fatherFn()

借用構造函數繼承(call)

  
    
  
  
  
  1. function fatherFn(...arr) {

  2. this.some = '父類的this屬性';

  3. this.params = arr // 父類的參數

  4. }

  5. fatherFn.prototype.fatherFnSome = '父類原型對象的屬性或者方法';

  6. function sonFn(fatherParams, ...sonParams) {

  7. fatherFn.call(this, ...fatherParams); // 核心步驟: 將fatherFn的this指向sonFn的this對象上

  8. this.obkoro1 = '子類的this屬性';

  9. this.sonParams = sonParams; // 子類的參數

  10. }

  11. sonFn.prototype.sonFnSome = '子類原型對象的屬性或者方法'

  12. let fatherParamsArr = ['父類的參數1', '父類的參數2']

  13. let sonParamsArr = ['子類的參數1', '子類的參數2']

  14. const sonFnInstance = new sonFn(fatherParamsArr, ...sonParamsArr); // 實例化子類

  15. console.log('借用構造函數子類實例', sonFnInstance)

借用構造函數繼承的子類實例

借用構造函數繼承做了什麼?

聲明類,組織參數等,只是輔助的上下文代碼,核心是借用構造函數使用 call做了什麼:

一經調用 call/apply它們就會立即執行函數,並在函數執行時改變函數的 this指向

  
    
  
  
  
  1. fatherFn.call(this, ...fatherParams);

  1. 在子類中使用 call調用父類, fatherFn將會被立即執行,並且將 fatherFn函數的this指向 sonFn的 this

  2. 因爲函數執行了,所以 fatherFn使用this聲明的函數都會被聲明到 sonFn的 this對象下。

  3. 實例化子類,this將指向 new期間創建的新對象,返回該新對象。

  4. 對 fatherFn.prototype沒有任何操作,無法繼承。

該對象的屬性爲:子類和父類聲明的 this屬性/方法,它的原型是

PS: 關於call/apply/bind的更多細節,推薦查看我的博客:js基礎-面試官想知道你有多理解call,apply,bind?[不看後悔系列]

借用構造函數繼承的優缺點

優點:

  1. 可以向父類傳遞參數

  2. 解決了原型鏈繼承中:父類屬性使用 this聲明的屬性會在所有實例共享的問題。

缺點:

  1. 只能繼承父類通過 this聲明的屬性/方法,不能繼承父類 prototype上的屬性/方法。

  2. 父類方法無法複用:因爲無法繼承父類的 prototype,所以每次子類實例化都要執行父類函數,重新聲明父類 this裏所定義的方法,因此方法無法複用。

組合繼承(call+new)

原理:使用原型鏈繼承( new)將 thisprototype聲明的屬性/方法繼承至子類的 prototype上,使用借用構造函數來繼承父類通過 this聲明屬性和方法至子類實例的屬性上。

  
    
  
  
  
  1. function fatherFn(...arr) {

  2. this.some = '父類的this屬性';

  3. this.params = arr // 父類的參數

  4. }

  5. fatherFn.prototype.fatherFnSome = '父類原型對象的屬性或者方法';

  6. function sonFn() {

  7. fatherFn.call(this, '借用構造繼承', '第二次調用'); // 借用構造繼承: 繼承父類通過this聲明屬性和方法至子類實例的屬性上

  8. this.obkoro1 = '子類的this屬性';

  9. }

  10. sonFn.prototype = new fatherFn('原型鏈繼承', '第一次調用'); // 原型鏈繼承: 將`this`和`prototype`聲明的屬性/方法繼承至子類的`prototype`上

  11. sonFn.prototype.sonFnSome = '子類原型對象的屬性或者方法'

  12. const sonFnInstance = new sonFn();

  13. console.log('組合繼承子類實例', sonFnInstance)

組合繼承的子類實例

從圖中可以看到 fatherFn通過 this聲明的屬性/方法,在子類實例的屬性上,和其原型上都複製了一份,原因在代碼中也有註釋:

  1. 原型鏈繼承: 父類通過 this和 prototype聲明的屬性/方法繼承至子類的 prototype上。

  2. 借用構造繼承: 父類通過this聲明屬性和方法繼承至子類實例的屬性上。

組合繼承的優缺點

優點:

完整繼承(又不是不能用),解決了:

  1. 父類通過 this聲明屬性/方法被子類實例共享的問題(原型鏈繼承的問題) 每次實例化子類將重新初始化父類通過 this聲明的屬性,實例根據原型鏈查找規則,每次都會

  2. 父類通過 prototype聲明的屬性/方法無法繼承的問題(借用構造函數的問題)。

缺點:

  1. 兩次調用父類函數( newfatherFn()和 fatherFn.call(this)),造成一定的性能損耗。

  2. 因調用兩次父類,導致父類通過 this聲明的屬性/方法,生成兩份的問題。

  3. 原型鏈上下文丟失:子類和父類通過prototype聲明的屬性/方法都存在於子類的prototype上

原型式繼承( Object.create())

繼承對象原型-Object.create()實現

以下是 Object.create()的模擬實現,使用 Object.create()可以達成同樣的效果,基本上現在都是使用 Object.create()來做對象的原型繼承。

  
    
  
  
  
  1. function cloneObject(obj){

  2. function F(){}

  3. F.prototype = obj; // 將被繼承的對象作爲空函數的prototype

  4. return new F(); // 返回new期間創建的新對象,此對象的原型爲被繼承的對象, 通過原型鏈查找可以拿到被繼承對象的屬性

  5. }

PS:上面 Object.create()實現原理可以記一下,有些公司可能會讓你講一下它的實現原理。

例子:

  
    
  
  
  
  1. let oldObj = { p: 1 };

  2. let newObj = cloneObject(oldObj)

  3. oldObj.p = 2

  4. console.log('oldObj newObj', oldObj, newObj)

原型式繼承優缺點:

優點:兼容性好,最簡單的對象繼承。

缺點:

  1. 因爲舊對象( oldObj)是實例對象( newObj)的原型,多個實例共享被繼承對象的屬性,存在篡改的可能。

  2. 無法傳參

寄生式繼承(封裝繼承過程)

創建一個僅用於封裝繼承過程的函數,該函數在內部以某種方式來增強對象,最後返回對象。

  
    
  
  
  
  1. function createAnother(original){

  2. var clone = cloneObject(original); // 繼承一個對象 返回新函數

  3. // do something 以某種方式來增強對象

  4. clone.some = function(){}; // 方法

  5. clone.obkoro1 = '封裝繼承過程'; // 屬性

  6. return clone; // 返回這個對象

  7. }

使用場景:專門爲對象來做某種固定方式的增強。

寄生組合式繼承(call+寄生式封裝)

寄生組合式繼承原理:

  1. 使用借用構造函數( call)來繼承父類this聲明的屬性/方法

  2. 通過寄生式封裝函數設置父類prototype爲子類prototype的原型來繼承父類的prototype聲明的屬性/方法

  
    
  
  
  
  1. function fatherFn(...arr) {

  2. this.some = '父類的this屬性';

  3. this.params = arr // 父類的參數

  4. }

  5. fatherFn.prototype.fatherFnSome = '父類原型對象的屬性或者方法';

  6. function sonFn() {

  7. fatherFn.call(this, '借用構造繼承'); // 核心1 借用構造繼承: 繼承父類通過this聲明屬性和方法至子類實例的屬性上

  8. this.obkoro1 = '子類的this屬性';

  9. }

  10. // 核心2 寄生式繼承:封裝了son.prototype對象原型式繼承father.prototype的過程,並且增強了傳入的對象。

  11. function inheritPrototype(son, father) {

  12. const fatherFnPrototype = Object.create(father.prototype); // 原型式繼承:淺拷貝father.prototype對象 father.prototype爲新對象的原型

  13. son.prototype = fatherFnPrototype; // 設置father.prototype爲son.prototype的原型

  14. son.prototype.constructor = son; // 修正constructor 指向

  15. }

  16. inheritPrototype(sonFn, fatherFn)

  17. sonFn.prototype.sonFnSome = '子類原型對象的屬性或者方法'

  18. const sonFnInstance = new sonFn();

  19. console.log('寄生組合式繼承子類實例', sonFnInstance)

寄生組合式繼承子類實例

寄生組合式繼承是最成熟的繼承方法:

寄生組合式繼承是最成熟的繼承方法, 也是現在最常用的繼承方法,衆多JS庫採用的繼承方案也是它。

寄生組合式繼承相對於組合繼承有如下優點:

  1. 只調用一次父類 fatherFn構造函數。

  2. 避免在子類prototype上創建不必要多餘的屬性。

  3. 使用原型式繼承父類的prototype,保持了原型鏈上下文不變。

    子類的prototype只有子類通過prototype聲明的屬性/方法和父類prototype上的屬性/方法涇渭分明。

ES6 extends繼承:

ES6繼承的原理跟寄生組合式繼承是一樣的。

ES6 extends核心代碼:

這段代碼是通過babel在線編譯)成es5, 用於子類prototype原型式繼承父類 prototype的屬性/方法。

  
    
  
  
  
  1. // 寄生式繼承 封裝繼承過程

  2. function _inherits(son, father) {

  3. // 原型式繼承: 設置father.prototype爲son.prototype的原型 用於繼承father.prototype的屬性/方法

  4. son.prototype = Object.create(father && father.prototype);

  5. son.prototype.constructor = son; // 修正constructor 指向

  6. // 將父類設置爲子類的原型 用於繼承父類的靜態屬性/方法(father.some)

  7. if (father) {

  8. Object.setPrototypeOf

  9. ? Object.setPrototypeOf(son, father)

  10. : son.__proto__ = father;

  11. }

  12. }

另外子類是通過借用構造函數繼承( call)來繼承父類通過 this聲明的屬性/方法,也跟寄生組合式繼承一樣。

ES5繼承與ES6繼承的區別:

本段摘自阮一峯-es6入門文檔

  • ES5的繼承實質上是先創建子類的實例對象,再將父類的方法添加到this上

  • ES6的繼承是先創建父類的實例對象this,再用子類的構造函數修改this

    因爲子類沒有自己的this對象,所以必須先調用父類的super()方法。

擴展:

爲什麼要修正construct指向?

在寄生組合式繼承中有一段如下一段修正constructor 指向的代碼,很多人對於它的作用以及爲什麼要修正它不太清楚。

  
    
  
  
  
  1. son.prototype.constructor = son; // 修正constructor 指向

construct的作用

MDN的定義:返回創建實例對象的 Object構造函數的引用

即返回實例對象的構造函數的引用,例如:

  
    
  
  
  
  1. let instance = new sonFn()

  2. instance.constructor // sonFn函數

construct的應用場景:

當我們只有實例對象沒有構造函數的引用時

某些場景下,我們對實例對象經過多輪導入導出,我們不知道實例是從哪個函數中構造出來或者追蹤實例的構造函數,較爲艱難。

這個時候就可以通過實例對象的 constructor屬性來得到構造函數的引用:

  
    
  
  
  
  1. let instance = new sonFn() // 實例化子類

  2. export instance;

  3. // 多輪導入+導出,導致sonFn追蹤非常麻煩,或者不想在文件中再引入sonFn

  4. let fn = instance.construct

  5. // do something:new fn() / fn.prototype / fn.length / fn.arguments等等

保持 construct指向的一致性:

因此每次重寫函數的prototype都應該修正一下 construct的指向,以保持讀取 construct行爲的一致性。

小結

繼承也是前端的高頻面試題,瞭解本文中繼承方法的優缺點,有助於更深刻的理解JS繼承機制。除了組合繼承和寄生式繼承都是由其他方法組合而成的,分塊理解會對它們理解的更深刻。

建議多看幾遍本文,建個 html文件試試文中的例子,兩相結合更佳!

對prototype還不是很理解的同學,可以再看看:JS基礎-函數、對象和原型、原型鏈的關係

覺得我的博客對你有幫助的話,就給我點個Star吧!

前端進階積累、公衆號、GitHub、wx:OBkoro1、郵箱:[email protected]

以上2019/9/22

作者:OBKoro1

參考資料:

JS高級程序設計(紅寶書)6.3繼承

JavaScript常用八種繼承方案

本文分享自微信公衆號 - OBKoro1前端進階積累(gh_8af2fb8e54a9)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章