深入JavaScript繼承原理

ULIVZ發表於2019-03-04

本文首發於個人 Github,歡迎 issue / fxxk。

ES6class語法糖你是否已經用得是否爐火純青呢?那如果迴歸到ES5呢?本文,將繼續上一篇 《萬物皆空之 JavaScript 原型》 篇尾提出的疑問如何用 JavaScript 實現類的繼承 來展開闡述:

通過本文,你將學到:

  1. 如何用JavaScript模擬類中的私有變數;
  2. 瞭解常見的幾種JavaScript繼承方法,原理及其優缺點;
  3. 實現一個較為fancyJavaScript繼承方法。

此外,如果你完全明白了文末的終極版繼承,你也就懂了這兩篇所要講的核心知識,同時,也能說明你擁有不錯的JavaScript基礎。

我們來回顧一下ES6 / TypeScript / ES5類的寫法以作對比。首先,我們建立一個GithubUser類,它擁有一個login方法,和一個靜態方法getPublicServices, 用於獲取public的方法列表:

class GithubUser {
    static getPublicServices() {
        return ['login']
    }
    constructor(username, password) {
        this.username = username
        this.password = password
    }
    login() {
        console.log(this.username + '要登入Github,密碼是' + this.password)
    }
}
複製程式碼

實際上,ES6這個類的寫法有一個弊病,實際上,密碼password應該是Github使用者一個私有變數,接下來,我們用TypeScript重寫一下:

class GithubUser {
    static getPublicServices() {
        return ['login']
    }
    public username: string
    private password: string
    constructor(username, password) {
        this.username = username
        this.password = password
    }
    public login(): void {
        console.log(this.username + '要登入Github,密碼是' + this.password)
    }
}
複製程式碼

如此一來,password就只能在類的內部訪問了。好了,問題來了,如果結合原型講解那一文的知識,來用ES5實現這個類呢?just show you my code:

function GithubUser(username, password) {
    // private屬性
    let _password = password 
    // public屬性
    this.username = username 
    // public方法
    GithubUser.prototype.login = function () {
        console.log(this.username + '要登入Github,密碼是' + _password)
    }
}
// 靜態方法
GithubUser.getPublicServices = function () {
    return ['login']
}
複製程式碼

值得注意的是,我們一般都會把共有方法放在類的原型上,而不會採用this.login = function() {}這種寫法。因為只有這樣,才能讓多個例項引用同一個共有方法,從而避免重複建立方法的浪費。

是不是很直觀!留下2個疑問:

  1. 如何實現private方法呢?
  2. 能否實現protected屬性/方法呢?

繼承

用掘金的使用者都應該知道,我們可以選擇直接使用 Github 登入,那麼,結合上一節,我們如果建立了一個 JuejinUser 來繼承 GithubUser,那麼 JuejinUser 及其例項就可以呼叫 Githublogin 方法了。首先,先寫出這個簡單 JuejinUser 類:

function JuejinUser(username, password) {
    // TODO need implementation
    this.articles = 3 // 文章數量
    JuejinUser.prototype.readArticle = function () {
        console.log('Read article')
    }
}
複製程式碼

由於ES6/TS的繼承太過直觀,本節將忽略。首先概述一下本文將要講解的幾種繼承方法:

看起來很多,我們一一論述。

類式繼承

因為我們已經得知:

若通過new Parent()建立了Child,則 Child.__proto__ = Parent.prototype,而原型鏈則是順著__proto__依次向上查詢。因此,可以通過修改子類的原型為父類的例項來實現繼承。

第一直覺的實現如下:

function GithubUser(username, password) {
    let _password = password 
    this.username = username 
    GithubUser.prototype.login = function () {
        console.log(this.username + '要登入Github,密碼是' + _password)
    }
}

function JuejinUser(username, password) {
    this.articles = 3 // 文章數量
    JuejinUser.prototype = new GithubUser(username, password)
    JuejinUser.prototype.readArticle = function () {
        console.log('Read article')
    }
}

const juejinUser1 = new JuejinUser('ulivz', 'xxx', 3)
console.log(juejinUser1)
複製程式碼

在瀏覽器中檢視原型鏈:

深入JavaScript繼承原理

誒,不對啊,很明顯 juejinUser1.__proto__ 並不是 GithubUser 的一個例項。

實際上,這是因為之前我們為了能夠在類的方法中讀取私有變數,將JuejinUser.prototype的重新賦值放在了建構函式中,而此時例項已經建立,其__proto__還還指向老的JuejinUser.prototype。所以,重新賦值一下例項的__proto__就可以解決這個問題:

function GithubUser(username, password) {
    let _password = password 
    this.username = username 
    GithubUser.prototype.login = function () {
        console.log(this.username + '要登入Github,密碼是' + _password)
    }
}

function JuejinUser(username, password) {
    this.articles = 3 // 文章數量
    const prototype = new GithubUser(username, password)
    // JuejinUser.prototype = prototype // 這一行已經沒有意義了
    prototype.readArticle = function () {
        console.log('Read article')
    }
    this.__proto__ = prototype
}

const juejinUser1 = new JuejinUser('ulivz', 'xxx', 3)
console.log(juejinUser1)

複製程式碼

接著檢視原型鏈:

深入JavaScript繼承原理

Perfect!原型鏈已經出來,問題“好像”得到了完美解決!但實際上還是有明顯的問題:

  1. 在原型鏈上建立了屬性(一般來說,這不是一種好的實踐)
  2. 私自篡改__proto__,導致 juejinUser1.__proto__ === JuejinUser.prototype 不成立!從而導致 juejinUser1 instanceof JuejinUser 也不成立?。這不應該發生!

細心的同學會發現,造成這種問題的根本原因在於我們在例項化的時候動態修改了原型,那有沒有一種方法可以在例項化之前就固定好類的原型的refernce呢?

事實上,我們可以考慮把類的原型的賦值挪出來:

function JuejinUser(username, password) {
    this.articles = 3 // 文章數量
}

// 此時建構函式還未執行,無法訪問 username 和 password !!
JuejinUser.prototype =  new GithubUser() 

prototype.readArticle = function () {
    console.log('Read article')
}
複製程式碼

但是這樣做又有更明顯的缺點:

  1. 父類過早地被建立,導致無法接受子類的動態引數;
  2. 仍然在原型上建立了屬性,此時,多個子類的例項將共享同一個父類的屬性,完蛋, 會互相影響!

舉例說明缺點2

function GithubUser(username) {
    this.username = 'Unknown' 
}

function JuejinUser(username, password) {

}

JuejinUser.prototype =  new GithubUser() 
const juejinUser1 = new JuejinUser('ulivz', 'xxx', 3)
const juejinUser2 = new JuejinUser('egoist', 'xxx', 0)

//  這就是把屬性定義在原型鏈上的致命缺點,你可以直接訪問,但修改就是一件難事了!
console.log(juejinUser1.username) // 'Unknown'
juejinUser1.__proto__.username = 'U' 
console.log(juejinUser1.username) // 'U'

// 臥槽,無情地影響了另一個例項!!!
console.log(juejinUser2.username) // 'U'
複製程式碼

由此可見,類式繼承的兩種方式缺陷太多!

建構函式式繼承

通過 call() 來實現繼承 (相應的, 你也可以用apply)。

function GithubUser(username, password) {
    let _password = password 
    this.username = username 
    GithubUser.prototype.login = function () {
        console.log(this.username + '要登入Github,密碼是' + _password)
    }
}

function JuejinUser(username, password) {
    GithubUser.call(this, username, password)
    this.articles = 3 // 文章數量
}

const juejinUser1 = new JuejinUser('ulivz', 'xxx')
console.log(juejinUser1.username) // ulivz
console.log(juejinUser1.username) // xxx
console.log(juejinUser1.login()) // TypeError: juejinUser1.login is not a function
複製程式碼

當然,如果繼承真地如此簡單,那麼本文就沒有存在的必要了,本繼承方法也存在明顯的缺陷—— 建構函式式繼承並沒有繼承父類原型上的方法。

組合式繼承

既然上述兩種方法各有缺點,但是又各有所長,那麼我們是否可以將其結合起來使用呢?沒錯,這種繼承方式就叫做——組合式繼承:

function GithubUser(username, password) {
    let _password = password 
    this.username = username 
    GithubUser.prototype.login = function () {
        console.log(this.username + '要登入Github,密碼是' + _password)
    }
}

function JuejinUser(username, password) {
    GithubUser.call(this, username, password) // 第二次執行 GithubUser 的建構函式
    this.articles = 3 // 文章數量
}

JuejinUser.prototype = new GithubUser(); // 第二次執行 GithubUser 的建構函式
const juejinUser1 = new JuejinUser('ulivz', 'xxx')
複製程式碼

雖然這種方式彌補了上述兩種方式的一些缺陷,但有些問題仍然存在:

  1. 子類仍舊無法傳遞動態引數給父類!
  2. 父類的建構函式被呼叫了兩次。

本方法很明顯執行了兩次父類的建構函式,因此,這也不是我們最終想要的繼承方式。

原型繼承

原型繼承實際上是對類式繼承的一種封裝,只不過其獨特之處在於,定義了一個乾淨的中間類,如下:

function createObject(o) {
    // 建立臨時類
    function f() {
        
    }
    // 修改類的原型為o, 於是f的例項都將繼承o上的方法
    f.prototype = o
    return new f()
}
複製程式碼

熟悉ES5的同學,會注意到,這不就是 Object.create 嗎?沒錯,你可以認為是如此。

既然只是類式繼承的一種封裝,其使用方式自然如下:

JuejinUser.prototype = createObject(GithubUser)
複製程式碼

也就仍然沒有解決類式繼承的一些問題。

PS:我個人覺得原型繼承類式繼承應該直接歸為一種繼承!但無賴眾多JavaScript書籍均是如此命名,算是follow legacy的標準吧。

寄生繼承

寄生繼承是依託於一個物件而生的一種繼承方式,因此稱之為寄生

const juejinUserSample = {
    username: 'ulivz',
    password: 'xxx'
}

function JuejinUser(obj) {
    var o = Object.create(obj)
     o.prototype.readArticle = function () {
        console.log('Read article')
    }
    return o;
}

var myComputer = new CreateComputer(computer);
複製程式碼

由於實際生產中,繼承一個單例物件的場景實在是太少,因此,我們仍然沒有找到最佳的繼承方法。

寄生組合式繼承

看起來很玄乎,先上程式碼:

// 寄生組合式繼承的核心方法
function inherit(child, parent) {
    // 繼承父類的原型
    const p = Object.create(parent.prototype)
    // 重寫子類的原型
    child.prototype = p
    // 重寫被汙染的子類的constructor
    p.constructor = child
}

// GithubUser, 父類
function GithubUser(username, password) {
    let _password = password 
    this.username = username 
}

GithubUser.prototype.login = function () {
    console.log(this.username + '要登入Github,密碼是' + _password)
}

// GithubUser, 子類
function JuejinUser(username, password) {
    GithubUser.call(this, username, password) // 繼承屬性
    this.articles = 3 // 文章數量
}

// 實現原型上的方法
inherit(JuejinUser, GithubUser)

// 在原型上新增新方法
JuejinUser.prototype.readArticle = function () {
    console.log('Read article')
}

const juejinUser1 = new JuejinUser('ulivz', 'xxx')
console.log(juejinUser1)
複製程式碼

來瀏覽器中檢視結果:

深入JavaScript繼承原理

簡單說明一下:

  1. 子類繼承了父類的屬性和方法,同時,屬性沒有被建立在原型鏈上,因此多個子類不會共享同一個屬性。
  2. 子類可以傳遞動態引數給父類!
  3. 父類的建構函式只執行了一次!

Nice!這才是我們想要的繼承方法。然而,仍然存在一個美中不足的問題:

  • 子類想要在原型上新增方法,必須在繼承之後新增,否則將覆蓋掉原有原型上的方法。這樣的話 若是已經存在的兩個類,就不好辦了。

所以,我們可以將其優化一下:

function inherit(child, parent) {
    // 繼承父類的原型
    const parentPrototype = Object.create(parent.prototype)
    // 將父類原型和子類原型合併,並賦值給子類的原型
    child.prototype = Object.assign(parentPrototype, child.prototype)
    // 重寫被汙染的子類的constructor
    p.constructor = child
}
複製程式碼

但實際上,使用Object.assign來進行copy仍然不是最好的方法,根據MDN的描述:

  • The Object.assign() method is used to copy the values of all enumerable own properties from one or more source objects to a target object. It will return the target object.

其中有個很關鍵的詞:enumerable,這已經不是本節討論的知識了,不熟悉的同學可以參考 MDN - Object.defineProperty 補習。簡答來說,上述的繼承方法只適用於copy原型鏈上可列舉的方法,此外,如果子類本身已經繼承自某個類,以上的繼承將不能滿足要求。

終極版繼承

為了讓程式碼更清晰,我用ES6的一些API,寫出了這個我所認為的最合理的繼承方法:

  1. Reflect代替了Object
  2. Reflect.getPrototypeOf來代替ob.__ptoto__;
  3. Reflect.ownKeys來讀取所有可列舉/不可列舉/Symbol的屬性;
  4. Reflect.getOwnPropertyDescriptor讀取屬性描述符;
  5. Reflect.setPrototypeOf來設定__ptoto__

原始碼如下:

/*!
 * fancy-inherit
 * (c) 2016-2018 ULIVZ
 */
 
// 不同於object.assign, 該 merge方法會複製所有的源鍵
// 不管鍵名是 Symbol 或字串,也不管是否可列舉
function fancyShadowMerge(target, source) {
    for (const key of Reflect.ownKeys(source)) {
        Reflect.defineProperty(target, key, Reflect.getOwnPropertyDescriptor(source, key))
    }
    return target
}

// Core
function inherit(child, parent) {
    const objectPrototype = Object.prototype
    // 繼承父類的原型
    const parentPrototype = Object.create(parent.prototype)
    let childPrototype = child.prototype
    // 若子類沒有繼承任何類,直接合並子類原型和父類原型上的所有方法
    // 包含可列舉/不可列舉的方法
    if (Reflect.getPrototypeOf(childPrototype) === objectPrototype) {
        child.prototype = fancyShadowMerge(parentPrototype, childPrototype)
    } else {
        // 若子類已經繼承子某個類
        // 父類的原型將在子類原型鏈的盡頭補全
        while (Reflect.getPrototypeOf(childPrototype) !== objectPrototype) {
			childPrototype = Reflect.getPrototypeOf(childPrototype)
        }
		Reflect.setPrototypeOf(childPrototype, parent.prototype)
    }
    // 重寫被汙染的子類的constructor
    parentPrototype.constructor = child
}
複製程式碼

測試:

// GithubUser
function GithubUser(username, password) {
    let _password = password
    this.username = username
}

GithubUser.prototype.login = function () {
    console.log(this.username + '要登入Github,密碼是' + _password)
}

// JuejinUser
function JuejinUser(username, password) {
    GithubUser.call(this, username, password)
    WeiboUser.call(this, username, password)
    this.articles = 3
}

JuejinUser.prototype.readArticle = function () {
    console.log('Read article')
}

// WeiboUser
function WeiboUser(username, password) {
    this.key = username + password
}

WeiboUser.prototype.compose = function () {
    console.log('compose')
}

// 先讓 JuejinUser 繼承 GithubUser,然後就可以用github登入掘金了
inherit(JuejinUser, GithubUser) 

// 再讓 JuejinUser 繼承 WeiboUser,然後就可以用weibo登入掘金了
inherit(JuejinUser, WeiboUser)  

const juejinUser1 = new JuejinUser('ulivz', 'xxx')

console.log(juejinUser1)

console.log(juejinUser1 instanceof GithubUser) // true
console.log(juejinUser1 instanceof WeiboUser) // true
複製程式碼

深入JavaScript繼承原理

最後用一個問題來檢驗你對本文的理解:

  • 改寫上述繼承方法,讓其支援inherit(A, B, C ...), 實現類A依次繼承後面所有的類,但除了A以外的類不產生繼承關係。

總結

  1. 我們可以使用function來模擬一個類;
  2. JavaScript類的繼承是基於原型的, 一個完善的繼承方法,其繼承過程是相當複雜的;
  3. 雖然建議實際生產中直接使用ES6的繼承,但仍建議深入瞭解內部繼承機制。

題外話

最後放一個彩蛋,為什麼我會在寄生組合式繼承中尤其強調enumerable這個屬性描述符呢,因為:

  • ES6class中,預設所有類的方法是不可列舉的!?

以上,全文終)

注:本文屬於個人總結,部分表達可能會有疏漏之處,如果您發現本文有所欠缺,為避免誤人子弟,請放心大膽地在評論中指出,或者給我提 issue,感謝~

相關文章