記不住的繼承方式

夜曉宸發表於2019-03-04

都說程式設計師是這個世界上最懶的人, 能躺著絕不坐著, 全乾著複製黏貼的活.

‘什麼, 你說這套邏輯之前寫過?!?! 速速把程式碼呈上來!!!’.

最懶的人往往信奉著‘拿來主義’. 若只是簡單的複製黏貼, 就會顯得沒有逼格.

JavaScript 中, 重複用到的邏輯我們會用函式包裝起來, 在合適且需要的情況下, 呼叫該函式即可. 而 apply, call, new 等方法也拓寬了函式的使用場景.

除了這種借來的, 我們還有繼承來的. 這就是常說的原型繼承. 當物件本身沒有要查詢的屬性或方法時, 它會沿著原型鏈查詢, 找到了就會拿來使用. 這種`無`中生有的事, 不妨瞭解一下.

預備知識

  1. 預設情況下, 所有的原型物件都會自動獲得一個 constructor (建構函式)屬性, 這個屬性是一個指向 prototype 屬性所在函式的指標. 建構函式的原型 prototypeconstructor 的初始值是建構函式本身. 即,

    Function.prototype.constructor === Function // true
    複製程式碼

    由建構函式構造出來的例項本身沒有 constructor 屬性, 不過可以通過原型鏈繼承這個屬性.

    // 以下person的constructor屬性繼承自Person.prototype
    function Person() {}
    Person.prototype.constructor === Person // true
    let person = new Person();
    person.constructor === Person // true
    person.hasOwnProperty(`constructor`) === false  // true
    person_1.constructor === Person.prototype.constructor // true
    複製程式碼
  2. 簡單資料型別和複雜資料型別賦值傳參的區別.

    JavaScript 中變數不可能成為只想另一個變數的引用. 引用指向的是值. 複雜資料型別的引用指向的都是同一個值.它們相互之間沒有引用/指向關係. 一旦值發生變化, 指向該值的多個引用將共享這個變化.

  3. new, apply, call 的函式呼叫模式.

    三者的共同點都是都是指定呼叫函式的 this 值. 這使得同一個函式可以在不同的語境下正確執行. new 更為複雜一些. 可大致模擬為,

    function new(constructor, arguments) {
        let instance = Object.create(constructor.prototype) // 姑且稱之為 new 的特性一
        constructor.apply(instance, arguments)  // 姑且稱之為 new 的特性二
        return instance
    }
    複製程式碼

    很明顯, new 的操作中包涵了 apply, call 要做的事. 在此大膽猜測一下, 在實現繼承的過程中, 一旦同時出現 newapplycall, 就會有重複交集的可能, 這時就需要想想是否有可以改進的地方.

不著痕跡的拿來主義

`各單位請注意, 下面到我表演地時候了`

`上道具!`

function Animal(name) {
    this.name = name
}
Animal.prototype.species = `animal`
function Leo() {} // 我是頭小獅子
複製程式碼

想要無中生有, 那是不可能的?, 所以我們準備了模板 Animal. Animal 有的東西, Leo 也想擁有.

而且 Animal 能用地東西也同樣適用於 Leo.
所以, 我們期待 Leo 最終長成這個樣子.

function Leo(name) {
    this.name = name
}
Leo.prototype.species = `animal`
複製程式碼

`就長這副熊樣!? 這和簡單的複製黏貼有什麼區別!? 這和鹹魚又有什麼區別!? 說好的逼格呢!?`

觀察一下 Leo, Leo 建構函式內部邏輯和 Animal 建構函式的內部邏輯如出一轍. 既然都是一樣的, 為什麼不能借來用用呢? 改造一下,

function Animal(name) {
    this.name = name
}
Animal.prototype.species = `animal`

function Leo(name) {
    Animal.call(this, name)
}
複製程式碼

這種在建構函式內部借函式而不借助原型繼承的方式被稱之為 借用建構函式式繼承.

把屬性和方法放在建構函式內部的定義, 使得每個構造出來的例項都有自己的屬性和方法. 而對一些需要例項間共享的屬性或方法卻是沒轍.

當然了, 我們本來就沒打算止步於此. 建構函式內部可以靠借, 那原型上呢? 如何讓 Leo 的原型上能和 Animal 的原型保持一致呢?

`這不是廢話麼? 我除了會借, 我還會繼承啊, 原型繼承啊!!!`

關於原型鏈, 我們已經知道是怎麼一回事了(不知道的可參考從Function入手原型鏈).

原型繼承就是通過原型鏈實現了物件本身沒有的屬性訪問和方法呼叫. 利用這個特性, 我們可以在原型上做些手腳.

思路一: 可以使得 Leoprototype 直接指向 Animalprototype.

function Animal(name) {
    this.name = name
}
Animal.prototype.species = `animal`

function Leo(name) {
    Animal.call(this, name)
}
Leo.prototype = Animal.prototype
複製程式碼

這裡有一點需要注意的, Leo.prototype = Animal.prototype 這種寫法就等於完全覆寫了 Leo 的原型, Leo.prototype.constructor 將和 Animal.prototype.constructor 保持一致, 這會使得一些等式顯得詭異.

不信, 請看:

Leo.prototype.constructor === Animal.prototype.constructor === Animal
複製程式碼

針對這種情況, 我們往往會做一些修正:

// 接上例程式碼省略
Leo.prototype = Animal.prototype
Leo.prototype.constructor = Leo
複製程式碼

即使修正好了, 可是還有個大問題.

那就是, 如果想給 Leo 原型新增屬性或方法, 將會影響到 Animal, 進而會影響到所有 Animal 的例項. 畢竟它們的原型之間已經畫了等號.

// 接上例程式碼省略
let Dog = new Animal(`dog`)
Dog.sayName  // undefined
Leo.prototype.sayName = function() {
    console.log(this.name)
}
Dog.sayName()   //  dog
複製程式碼

`我只想偷個懶, 沒想過要搗亂啊?!!!`

為了消除這種影響, 我們需要一箇中間紐帶過渡. 還好我們知道 new 可以用來修改原型鏈.

思路二: Leoprototype 指向 Animal 的例項.

function Animal(name) {
    this.name = name
}
Animal.prototype.species = `animal`

function Leo(name) {
    Animal.call(this, name)
}
Leo.prototype = new Animal()
Leo.prototype.contructor = Leo
複製程式碼

這種在建構函式內部借函式同時又藉助原型繼承的方式被稱之為 組合繼承. Leo 換個角度其實長這樣:

function Leo(name) {
    this.name = name
}
Leo.prototype = {
    constructor: Leo,
    name: undefined,
    __proto__: Animal.prototype
}
複製程式碼

在這種繼承模式中, Leo 的例項可以有自己的屬性和方法, 例項之間又可以通過 prototype 來共享屬性和方法卻不會影響 Animal, 還可以通過 _proto_ 追溯到 Animal.prototype.

一切都很完美?. 不過還記得文章開始時所說的麼

在實現繼承的過程中, 一旦同時出現 newapplycall, 就會有重複交集的可能, 這時就需要想想是否有可以改進的地方.

Animal 被呼叫了兩次, 第一次是 Leo 建構函式內部作為一個普通函式被呼叫, 第二次是被作為建構函式構造一個例項充當 Leo 的原型.

Animal 內部定義的屬性和方法同時出現在 Leo 的原型和 Leo 的例項上. 例項上有的東西就不會再到原型上查詢. 反之, 例項上沒有的東西才會到原型上查詢. 顯然, 有多餘的存在.

`這不是最優解, 我要最好的! 下一個!`

思路三: 既然有重複, 那就去其一唄. 既然 newcallapply 厲害, 那就留著 new 吧.

function Animal(name) {
    this.name = name
}
Animal.prototype.species = `animal`

function Leo(name) {}
Leo.prototype = new Animal()
Leo.prototype.contructor = Leo
複製程式碼

這種在建構函式內部不借函式只借助原型繼承的方式被稱之為 原型鏈繼承.

經過這麼一折騰, 發現不好的地方有增無減. 例項沒了自己的屬性和方法了, 連 Animal 建構函式內部定義的屬性方法都可以在例項間共享了(思路二也存在這個問題), 而且引數也不給傳了.

`我要的不多, 能輕點折騰不, 心臟不好`

回到 思路二, 那就刪了 new 吧.

思路四: 接上 思路二, 刪了 new, 那隻能在原型上做調整了.

我們從一開始就只是希望 Leoprototype 指向 Animalprototype, 不多不少且不會出現 思路一 的壞影響.

既然不能直接在兩者之間畫等號, 就造一個過渡紐帶唄. 能夠關聯起原型鏈的不只有 new, Object.create() 也是可以的.

建立一個 _proto_ 指向 Animal.prototype 的物件充當 Leo 的原型不就解決問題了麼.

function Animal(name) {
    this.name = name
}
Animal.prototype.species = `animal`

function Leo(name) {
    Animal.call(this, name)
}
Leo.prototype = Object.create(Animal.prototype)
Leo.prototype.contructor = Leo
複製程式碼

這種在建構函式內部借函式同時又間接藉助原型繼承的方式被稱之為 寄生組合式繼承.

這種模式完美解決了 思路二 的弊端. 算是較為理想的繼承模式吧.

`確認過眼神, 你才我想要的!`

以上還是隻是建構函式間的繼承, 還有基於已存在物件的繼承, 譬如, 原型式繼承寄生式繼承等.

講真, 說了辣麼多, 我還真沒記住 借用建構函式式繼承, 組合繼承, 原型鏈繼承, 寄生組合式繼承, 原型式繼承, 寄生式繼承等.

`你沒記住這麼多模式, 那你都記住什麼了`

答曰: 要想很好得繼承, 一靠朋友, 二靠拼爹.

`這孩子是不是傻? 這都什麼年代了? 再說了, 就沒人告訴你你家裡有礦???`

思路五: ES6 引入了 Class(類)這個概念,通過 class 關鍵字,可以定義類, Class 實質上是 JavaScript 現有的基於原型的繼承的語法糖. Class 可以通過extends關鍵字實現繼承. 我們可以對 思路四 來個華麗變身.

class Animal {
    constructor(name) {
        this.name = name
    }
}
Animal.prototype.species = `animal`

class Leo extends Animal {
    constructor(name) {
        super(name)
    }
}
複製程式碼

經過這麼一處理後行為上和 思路四 基本沒什麼區別, constructor(){} 充當了之前的建構函式, super() 作為函式呼叫扮演著 Animal.call(this, name) 的角色(還可以表示父類). 最重要的是 Leo_proto_ 也指向了 Animal.

`礦多基因好, 嘖嘖嘖, 我都快要喜歡上我自己了?.`

相關文章