本文首發於個人 Github,歡迎 issue / fxxk。
ES6
的class
語法糖你是否已經用得是否爐火純青呢?那如果迴歸到ES5
呢?本文,將繼續上一篇 《萬物皆空之 JavaScript 原型》 篇尾提出的疑問如何用 JavaScript 實現類的繼承
來展開闡述:
通過本文,你將學到:
- 如何用
JavaScript
模擬類中的私有變數; - 瞭解常見的幾種
JavaScript
繼承方法,原理及其優缺點; - 實現一個較為
fancy
的JavaScript
繼承方法。
此外,如果你完全明白了文末的終極版繼承
,你也就懂了這兩篇所要講的核心知識,同時,也能說明你擁有不錯的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
個疑問:
- 如何實現
private方法
呢? - 能否實現
protected屬性/方法
呢?
繼承
用掘金的使用者都應該知道,我們可以選擇直接使用 Github
登入,那麼,結合上一節,我們如果建立了一個 JuejinUser
來繼承 GithubUser
,那麼 JuejinUser
及其例項就可以呼叫 Github
的 login
方法了。首先,先寫出這個簡單 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)
複製程式碼
在瀏覽器中檢視原型鏈:
誒,不對啊,很明顯 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)
複製程式碼
接著檢視原型鏈:
Perfect!原型鏈已經出來,問題“好像”得到了完美解決!但實際上還是有明顯的問題:
- 在原型鏈上建立了屬性(一般來說,這不是一種好的實踐)
- 私自篡改
__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`)
}
複製程式碼
但是這樣做又有更明顯的缺點:
- 父類過早地被建立,導致無法接受子類的動態引數;
- 仍然在原型上建立了屬性,此時,多個子類的例項將共享同一個父類的屬性,完蛋, 會互相影響!
舉例說明缺點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`)
複製程式碼
雖然這種方式彌補了上述兩種方式的一些缺陷,但有些問題仍然存在:
- 子類仍舊無法傳遞動態引數給父類!
- 父類的建構函式被呼叫了兩次。
本方法很明顯執行了兩次父類的建構函式,因此,這也不是我們最終想要的繼承方式。
原型繼承
原型繼承實際上是對類式繼承
的一種封裝,只不過其獨特之處在於,定義了一個乾淨的中間類,如下:
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)
複製程式碼
來瀏覽器中檢視結果:
簡單說明一下:
- 子類繼承了父類的屬性和方法,同時,屬性沒有被建立在原型鏈上,因此多個子類不會共享同一個屬性。
- 子類可以傳遞動態引數給父類!
- 父類的建構函式只執行了一次!
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,寫出了這個我所認為的最合理的繼承方法:
- 用
Reflect
代替了Object
; - 用
Reflect.getPrototypeOf
來代替ob.__ptoto__
; - 用
Reflect.ownKeys
來讀取所有可列舉/不可列舉/Symbol的屬性; - 用
Reflect.getOwnPropertyDescriptor
讀取屬性描述符; - 用
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
複製程式碼
最後用一個問題來檢驗你對本文的理解:
- 改寫上述繼承方法,讓其支援
inherit(A, B, C ...)
, 實現類A
依次繼承後面所有的類,但除了A
以外的類不產生繼承關係。
總結
- 我們可以使用
function
來模擬一個類; JavaScript
類的繼承是基於原型的, 一個完善的繼承方法,其繼承過程是相當複雜的;- 雖然建議實際生產中直接使用
ES6
的繼承,但仍建議深入瞭解內部繼承機制。
題外話
最後放一個彩蛋,為什麼我會在寄生組合式繼承中尤其強調enumerable
這個屬性描述符呢,因為:
ES6
的class
中,預設所有類的方法是不可列舉的!?
以上,全文終)
注:本文屬於個人總結,部分表達可能會有疏漏之處,如果您發現本文有所欠缺,為避免誤人子弟,請放心大膽地在評論中指出,或者給我提 issue,感謝~