天天看點

JS基礎-全方面掌握繼承

前言

上篇文章詳細解析了原型、原型鍊的相關知識點,這篇文章講的是和原型鍊有密切關聯的繼承,它是前端基礎中很重要的一個知識點,它對于代碼複用來說非常有用,本篇将詳細解析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);
           

原型鍊子類執行個體

JS基礎-全方面掌握繼承

原型鍊繼承擷取父類的屬性和方法

  1. fatherFn

    通過this聲明的屬性/方法都會綁定在

    new

    期間建立的新對象上。
  2. 新對象的原型是

    father.prototype

    ,通過原型鍊的屬性查找到

    father.prototype

    的屬性和方法。

了解

new

做了什麼:

new在本文出現多次,new也是JS基礎中很重要的一塊内容,很多知識點會涉及到new,不太了解的要多看幾遍。
  1. 建立一個全新的對象。
  2. 這個新對象的原型(

    __proto__

    )指向函數的

    prototype

    對象。
  3. 執行函數,函數的this會綁定在新建立的對象上。
  4. 如果函數沒有傳回其他對象(包括數組、函數、日期對象等),那麼會自動傳回這個新對象。
  5. 傳回的那個對象為構造函數的執行個體。

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

傳回其他對象會導緻擷取不到構造函數的執行個體,很容易是以引起意外的問題!

我們知道了

fatherFn

this

prototype

的屬性/方法都跟

new

期間建立的新對象有關系。

如果在父類中傳回了其他對象(

new

的第四點),其他對象沒有父類的

this

prototype

,是以導緻原型鍊繼承失敗。

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

fatherFn

function fatherFn() {
  this.some = '父類的this屬性';
  console.log('new fatherFn 期間生成的對象', this)
  return [ '數組對象', '函數對象', '日期對象', '正則對象', '等等等', '都不會傳回new期間建立的新對象' ]
}
           
JS基礎-全方面掌握繼承

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)
           

借用構造函數繼承的子類執行個體

JS基礎-全方面掌握繼承

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

聲明類,組織參數等,隻是輔助的上下文代碼,核心是借用構造函數使用

call

一經調用

call/apply

它們就會立即執行函數,并在函數執行時改變函數的

this

指向

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

)将

this

prototype

聲明的屬性/方法繼承至子類的

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)
           

組合繼承的子類執行個體

JS基礎-全方面掌握繼承

從圖中可以看到

fatherFn

通過

this

聲明的屬性/方法,在子類執行個體的屬性上,和其原型上都複制了一份,原因在代碼中也有注釋:

  1. 原型鍊繼承: 父類通過

    this

    prototype

    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)
           
JS基礎-全方面掌握繼承

原型式繼承優缺點:

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

  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基礎-全方面掌握繼承

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

寄生組合式繼承是最成熟的繼承方法, 也是現在最常用的繼承方法,衆多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()方法。

擴充:

為什麼要修正construct指向?

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

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

construct的作用

MDN的定義:傳回建立執行個體對象的

Object

構造函數的引用。

即傳回執行個體對象的構造函數的引用,例如:

let instance = new sonFn()
instance.constructor // sonFn函數
           

construct

的應用場景:

當我們隻有執行個體對象沒有構造函數的引用時:

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

這個時候就可以通過執行個體對象的

constructor

屬性來得到構造函數的引用:

let instance = new sonFn() // 執行個體化子類
export instance;
// 多輪導入+導出,導緻sonFn追蹤非常麻煩,或者不想在檔案中再引入sonFn
let  fn = instance.construct
// 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常用八種繼承方案

GitHub:https://github.com/OBKoro1,

wx:OBkoro1,

郵箱:[email protected]

繼續閱讀