JS基礎-完美掌握繼承知識點

OBKoro1發表於2019-09-23

前言

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

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

JS基礎-函式、物件和原型、原型鏈的關係

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

何為繼承?

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

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

PS:或者是多個類

JS實現繼承的原理

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

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

原型鏈繼承(new):

function fatherFn() {
  this.some = '父類的this屬性';
}
fatherFn.prototype.fatherFnSome =  '父類原型物件的屬性或者方法';
// 子類
function sonFn() {
  this.obkoro1 = '子類的this屬性';
}
// 核心步驟:重寫子類的原型物件
sonFn.prototype = new fatherFn(); // 將fatherFn的例項賦值給sonFn的prototype
sonFn.prototype.sonFnSome = '子類原型物件的屬性或者方法' // 子類的屬性/方法宣告在後面,避免被覆蓋
// 例項化子類
const sonFnInstance = new sonFn();
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

function fatherFn() {
  this.some = '父類的this屬性';
  console.log('new fatherFn 期間生成的物件', this)
  return [ '陣列物件', '函式物件', '日期物件', '正則物件', '等等等', '都不會返回new期間建立的新物件' ]
}
複製程式碼

原型鏈繼承返回其他物件,將導致原型鏈繼承失敗

PS: 本文中構造呼叫函式都不能返回其他函式,下文不再提及該點。

不要使用物件字面量的形式建立原型方法:

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

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

sonFn.prototype = new fatherFn();
// 子類的prototype被清空後 重新賦值, 導致上一行程式碼失效
sonFn.prototype = {
    sonFnSome: '子類原型物件的屬性',
    one: function() {},
    two: function() {},
    three: function() {}
}
複製程式碼

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

function test() {}
test.prototype = {
    ...
}
複製程式碼

原型鏈繼承的缺點

  1. 父類使用this宣告的屬性被所有例項共享

    原因是:例項化的父類(sonFn.prototype = new fatherFn())是一次性賦值到子類例項的原型(sonFn.prototype)上,它會將父類通過this宣告的屬性也在賦值到sonFn.prototype上。

值得一提的是:很多部落格中說,引用型別的屬性被所有例項共享,通常會用陣列來舉例,實際上陣列以及其他父類通過this宣告的屬性也只是通過原型鏈查詢去獲取子類例項的原型(sonFn.prototype)上的值。

  1. 建立子類例項時,無法向父類建構函式傳參,不夠靈活。

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

sonFn.prototype = new fatherFn()
複製程式碼

借用建構函式繼承(call)

 function fatherFn(...arr) {
  this.some = '父類的this屬性';
  this.params = arr // 父類的引數
}
fatherFn.prototype.fatherFnSome = '父類原型物件的屬性或者方法';
function sonFn(fatherParams, ...sonParams) {
  fatherFn.call(this, ...fatherParams); // 核心步驟: 將fatherFn的this指向sonFn的this物件上
  this.obkoro1 = '子類的this屬性';
  this.sonParams = sonParams; // 子類的引數
}
sonFn.prototype.sonFnSome = '子類原型物件的屬性或者方法'
let fatherParamsArr = ['父類的引數1', '父類的引數2']
let sonParamsArr = ['子類的引數1', '子類的引數2']
const sonFnInstance = new sonFn(fatherParamsArr, ...sonParamsArr); // 例項化子類
console.log('借用建構函式子類例項', sonFnInstance)
複製程式碼

借用建構函式繼承的子類例項

借用建構函式繼承的子類例項

借用建構函式繼承做了什麼?

宣告類,組織引數等,只是輔助的上下文程式碼,核心是借用建構函式使用call做了什麼:

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

fatherFn.call(this, ...fatherParams); 
複製程式碼
  1. 在子類中使用call呼叫父類,fatherFn將會被立即執行,並且將fatherFn函式的this指向sonFnthis
  2. 因為函式執行了,所以fatherFn使用this宣告的函式都會被宣告到sonFnthis物件下。
  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宣告屬性和方法至子類例項的屬性上。

function fatherFn(...arr) {
  this.some = '父類的this屬性';
  this.params = arr // 父類的引數
}
fatherFn.prototype.fatherFnSome = '父類原型物件的屬性或者方法';
function sonFn() {
  fatherFn.call(this, '借用構造繼承', '第二次呼叫'); // 借用構造繼承: 繼承父類通過this宣告屬性和方法至子類例項的屬性上
  this.obkoro1 = '子類的this屬性';
}
sonFn.prototype = new fatherFn('原型鏈繼承', '第一次呼叫'); // 原型鏈繼承: 將`this`和`prototype`宣告的屬性/方法繼承至子類的`prototype`上
sonFn.prototype.sonFnSome = '子類原型物件的屬性或者方法'
const sonFnInstance = new sonFn();
console.log('組合繼承子類例項', sonFnInstance)
複製程式碼

組合繼承的子類例項

組合繼承的子類例項

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

  1. 原型鏈繼承: 父類通過thisprototype宣告的屬性/方法繼承至子類的prototype上。
  2. 借用構造繼承: 父類通過this宣告屬性和方法繼承至子類例項的屬性上。

組合繼承的優缺點

優點:

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

  1. 父類通過this宣告屬性/方法被子類例項共享的問題(原型鏈繼承的問題) 每次例項化子類將重新初始化父類通過this宣告的屬性,例項根據原型鏈查詢規則,每次都會
  2. 父類通過prototype宣告的屬性/方法無法繼承的問題(借用建構函式的問題)。

缺點:

  1. 兩次呼叫父類函式(new fatherFn()fatherFn.call(this)),造成一定的效能損耗。
  2. 因呼叫兩次父類,導致父類通過this宣告的屬性/方法,生成兩份的問題。
  3. 原型鏈上下文丟失:子類和父類通過prototype宣告的屬性/方法都存在於子類的prototype上

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

繼承物件原型-Object.create()實現

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

function cloneObject(obj){
  function F(){}
  F.prototype = obj; // 將被繼承的物件作為空函式的prototype
  return new F(); // 返回new期間建立的新物件,此物件的原型為被繼承的物件, 通過原型鏈查詢可以拿到被繼承物件的屬性
}
複製程式碼

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

例子:

let oldObj = { p: 1 };
let newObj = cloneObject(oldObj)
oldObj.p = 2
console.log('oldObj newObj', oldObj, newObj)
複製程式碼

原型式繼承

原型式繼承優缺點:

優點: 相容性好,最簡單的物件繼承。

缺點:

  1. 因為舊物件(oldObj)是例項物件(newObj)的原型,多個例項共享被繼承物件的屬性,存在篡改的可能。
  2. 無法傳參

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

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

function createAnother(original){
  var clone = cloneObject(original); // 繼承一個物件 返回新函式
  // do something 以某種方式來增強物件
  clone.some = function(){}; // 方法
  clone.obkoro1 = '封裝繼承過程'; // 屬性
  return clone; // 返回這個物件
}
複製程式碼

使用場景:專門為物件來做某種固定方式的增強。

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

寄生組合式繼承原理:

  1. 使用借用建構函式(call)來繼承父類this宣告的屬性/方法
  2. 通過寄生式封裝函式設定父類prototype為子類prototype的原型來繼承父類的prototype宣告的屬性/方法
function fatherFn(...arr) {
  this.some = '父類的this屬性';
  this.params = arr // 父類的引數
}
fatherFn.prototype.fatherFnSome = '父類原型物件的屬性或者方法';
function sonFn() {
  fatherFn.call(this, '借用構造繼承'); // 核心1 借用構造繼承: 繼承父類通過this宣告屬性和方法至子類例項的屬性上
  this.obkoro1 = '子類的this屬性';
}
// 核心2 寄生式繼承:封裝了son.prototype物件原型式繼承father.prototype的過程,並且增強了傳入的物件。
function inheritPrototype(son, father) {
  const fatherFnPrototype = Object.create(father.prototype); // 原型式繼承:淺拷貝father.prototype物件 father.prototype為新物件的原型
  son.prototype = fatherFnPrototype; // 設定father.prototype為son.prototype的原型
  son.prototype.constructor = son; // 修正constructor 指向
}
inheritPrototype(sonFn, fatherFn)
sonFn.prototype.sonFnSome = '子類原型物件的屬性或者方法'
const sonFnInstance = new sonFn();
console.log('寄生組合式繼承子類例項', sonFnInstance)
複製程式碼

寄生組合式繼承子類例項

寄生組合式繼承子類例項

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

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

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

  1. 只呼叫一次父類fatherFn建構函式。

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

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

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

ES6 extends繼承:

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

ES6 extends核心程式碼:

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

// 寄生式繼承 封裝繼承過程
function _inherits(son, father) {
  // 原型式繼承: 設定father.prototype為son.prototype的原型 用於繼承father.prototype的屬性/方法
  son.prototype = Object.create(father && father.prototype);
  son.prototype.constructor = son; // 修正constructor 指向
  // 將父類設定為子類的原型 用於繼承父類的靜態屬性/方法(father.some)
  if (father) {
    Object.setPrototypeOf
      ? Object.setPrototypeOf(son, father)
      : son.__proto__ = father;
  }
}
複製程式碼

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

ES5繼承與ES6繼承的區別:

本段摘自阮一峰-es6入門文件

  • ES5的繼承實質上是先建立子類的例項物件,再將父類的方法新增到this上

  • ES6的繼承是先建立父類的例項物件this,再用子類的建構函式修改this

    因為子類沒有自己的this物件,所以必須先呼叫父類的super()方法。

擴充套件:

為什麼要修正constructor指向?

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

son.prototype.constructor = son; // 修正constructor 指向
複製程式碼

constructor的作用

MDN的定義:返回建立例項物件的Object建構函式的引用

即返回例項物件的建構函式的引用,例如:

let instance = new sonFn()
instance.constructor // sonFn函式
複製程式碼

constructor的應用場景:

當我們只有例項物件沒有建構函式的引用時

某些場景下,我們對例項物件經過多輪匯入匯出,我們不知道例項是從哪個函式中構造出來或者追蹤例項的建構函式,較為艱難。

這個時候就可以通過例項物件的constructor屬性來得到建構函式的引用:

let instance = new sonFn() // 例項化子類
export instance;
// 多輪匯入+匯出,導致sonFn追蹤非常麻煩,或者不想在檔案中再引入sonFn
let  fn = instance.constructor
// do something: new fn() / fn.prototype / fn.length / fn.arguments等等
複製程式碼

保持constructor指向的一致性:

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

小結

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

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

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

覺得我的部落格對你有幫助的話,就給我點個Star吧!

前端進階積累公眾號GitHub、wx:OBkoro1、郵箱:obkoro1@foxmail.com

以上2019/9/22

作者:OBKoro1

參考資料:

JS高階程式設計(紅寶書)6.3繼承

JavaScript常用八種繼承方案

相關文章