前言
見解有限,如有描述不當之處,請幫忙及時指出,如有錯誤,會及時修正。
20180201更新:
修改用詞描述,如組合寄生式改成寄生組合式,修改多處筆誤(感謝@Yao Ding的反饋)
----------長文+多圖預警,需要花費一定時間----------
故事是從一次實際需求中開始的。。。
某天,某人向我尋求了一次幫助,要協助寫一個日期工具類,要求:
-
此類繼承自
Date
,擁有Date的所有屬性和物件 -
此類可以自由擴充方法
形象點描述,就是要求可以這樣:
// 假設最終的類是 MyDate,有一個getTest擴充方法
let date = new MyDate();
// 呼叫Date的方法,輸出GMT絕對毫秒數
console.log(date.getTime());
// 呼叫擴充的方法,隨便輸出什麼,譬如helloworld!
console.log(date.getTest());
複製程式碼
於是,隨手用JS中經典的寄生組合式寫了一個繼承,然後,剛準備完美收工,一執行,卻出現了以下的情景:
但是的心情是這樣的: ?囧
以前也沒有遇到過類似的問題,然後自己嘗試著用其它方法,多次嘗試,均無果(不算暴力混合法的情況),其實回過頭來看,是因為思路新奇,憑空想不到,並不是原理上有多難。。。
於是,藉助強大的搜素引擎,蒐集資料,最後,再自己總結了一番,才有了本文。
----------正文開始前----------
正文開始前,各位看官可以先暫停往下讀,嘗試下,在不借助任何網路資料的情況下,是否能實現上面的需求?(就以10分鐘
為限吧)
大綱
-
先說說如何快速快速尋求解答
-
stackoverflow上早就有答案了!
-
倘若用的是中文搜尋。
-
-
分析問題的關鍵
-
經典的繼承法有何問題
-
為什麼無法被繼承?
-
-
該如何實現繼承?
-
暴力混合法
-
ES5黑魔法
-
ES6大法
-
ES6寫法,然後babel打包
-
-
幾種繼承的細微區別
-
ES6繼承與ES5繼承的區別
-
建構函式與例項物件
-
[[Class]]與Internal slot
-
如何快速判斷是否繼承?
-
寫在最後的話
先說說如何快速快速尋求解答
遇到不會的問題,肯定第一目標就是如何快速尋求解決方案,答案是:
- 先去stackoverflow上看看有沒有類似的題。。。
於是,藉助搜尋引擎搜尋了下,第一條就符合條件,點開進去看描述
stackoverflow上早就有答案了!
先說說結果,再瀏覽一番後,確實找到了解決方案,然後回過頭來一看,驚到了,因為這個問題的提問時間是6 years, 7 months ago
。
也就是說,2011
年的時候就已經有人提出了。。。
感覺自己落後了一個時代**>_<**。。。
而且還發現了一個細節,那就是viewed:10,606 times
,也就是說至今一共也才一萬多次閱讀而已,考慮到前端行業的從業人數,這個比例驚人的低。
以點見面,看來,遇到這個問題的人並不是很多。
倘若用的是中文搜尋。
用中文搜尋並不丟人(我遇到問題時的本能反應也是去百度)。結果是這樣的:
嗯,看來英文關鍵字搜尋效果不錯,第一條就是符合要求的。然後又試了試中文搜尋。
效果不如人意,搜尋前幾頁,唯一有一條看起來比較相近的(segmentfault
上的那條),點進去看
怎麼說呢。。。這個問題關注度不高,瀏覽器數較少,而且上面的問題描述和預期的有點區別,仍然是有人回答的。 不過,雖然說問題在一定程度上得到了解決,但是回答者繞過了無法繼承這個問題,有點未竟全功的意思。。。
分析問題的關鍵
藉助stackoverflow上的回答
經典的繼承法有何問題
先看看本文最開始時提到的經典繼承法實現,如下:
/**
* 經典的js寄生組合式繼承
*/
function MyDate() {
Date.apply(this, arguments);
this.abc = 1;
}
function inherits(subClass, superClass) {
function Inner() {}
Inner.prototype = superClass.prototype;
subClass.prototype = new Inner();
subClass.prototype.constructor = subClass;
}
inherits(MyDate, Date);
MyDate.prototype.getTest = function() {
return this.getTime();
};
let date = new MyDate();
console.log(date.getTest());
複製程式碼
就是這段程式碼⬆,這也是JavaScript高程(紅寶書)中推薦的一種,一直用,從未失手,結果現在馬失前蹄。。。
我們再回顧下它的報錯:
再列印它的原型看看:
怎麼看都沒問題,因為按照原型鏈回溯規則,Date
的所有原型方法都可以通過MyDate
物件的原型鏈往上回溯到。
再仔細看看,發現它的關鍵並不是找不到方法,而是this is not a Date object.
嗯哼,也就是說,關鍵是:由於呼叫的物件不是Date的例項,所以不允許呼叫,就算是自己通過原型繼承的也不行
為什麼無法被繼承?
首先,看看MDN
上的解釋,上面有提到,JavaScript的日期物件只能通過JavaScript Date
作為建構函式來例項化。
然後再看看stackoverflow上的回答:
有提到,v8
引擎底層程式碼中有限制,如果呼叫物件的[[Class]]
不是Date
,則丟擲錯誤。
總的來說,結合這兩點,可以得出一個結論:
要呼叫Date上方法的例項物件必須通過Date構造出來,否則不允許呼叫Date的方法
該如何實現繼承?
雖然原因找到了,但是問題仍然要解決啊,真的就沒辦法了麼?當然不是,事實上還是有不少實現的方法的。
暴力混合法
首先,說說說下暴力的混合法,它是下面這樣子的:
說到底就是:內部生成一個Date
物件,然後此類暴露的方法中,把原有Date
中所有的方法都代理一遍,而且嚴格來說,這根本算不上繼承(都沒有原型鏈回溯)。
ES5黑魔法
然後,再看看ES5中如何實現?
// 需要考慮polyfill情況
Object.setPrototypeOf = Object.setPrototypeOf ||
function(obj, proto) {
obj.__proto__ = proto;
return obj;
};
/**
* 用了點技巧的繼承,實際上返回的是Date物件
*/
function MyDate() {
// bind屬於Function.prototype,接收的引數是:object, param1, params2...
var dateInst = new(Function.prototype.bind.apply(Date, [Date].concat(Array.prototype.slice.call(arguments))))();
// 更改原型指向,否則無法呼叫MyDate原型上的方法
// ES6方案中,這裡就是[[prototype]]這個隱式原型物件,在沒有標準以前就是__proto__
Object.setPrototypeOf(dateInst, MyDate.prototype);
dateInst.abc = 1;
return dateInst;
}
// 原型重新指回Date,否則根本無法算是繼承
Object.setPrototypeOf(MyDate.prototype, Date.prototype);
MyDate.prototype.getTest = function getTest() {
return this.getTime();
};
let date = new MyDate();
// 正常輸出,譬如1515638988725
console.log(date.getTest());
複製程式碼
一眼看上去不知所措?沒關係,先看下圖來理解:(原型鏈關係一目瞭然)
可以看到,用的是非常巧妙的一種做法:
-
正常繼承的情況如下:
-
new MyDate()
返回例項物件date
是由MyDate
構造的 -
原型鏈回溯是:
date(MyDate物件)->date.__proto__->MyDate.prototype->MyDate.prototype.__proto__->Date.prototype
-
-
這種做法的繼承的情況如下:
-
new MyDate()
返回例項物件date
是由Date
構造的 -
原型鏈回溯是:
date(Date物件)->date.__proto__->MyDate.prototype->MyDate.prototype.__proto__->Date.prototype
-
可以看出,關鍵點在於:
-
建構函式裡返回了一個真正的
Date
物件(由Date
構造,所以有這些內部類中的關鍵[[Class]]
標誌),所以它有呼叫Date
原型上方法的權利 -
建構函式裡的Date物件的
[[ptototype]]
(對外,瀏覽器中可通過__proto__
訪問)指向MyDate.prototype
,然後MyDate.prototype
再指向Date.prototype
。 所以最終的例項物件仍然能進行正常的原型鏈回溯,回溯到原本Date的所有原型方法 -
這樣通過一個巧妙的欺騙技巧,就實現了完美的Date繼承。不過補充一點,
MDN
上有提到儘量不要修改物件的[[Prototype]]
,因為這樣可能會干涉到瀏覽器本身的優化。 如果你關心效能,你就不應該在一個物件中修改它的 [[Prototype]]
ES6大法
當然,除了上述的ES5實現,ES6中也可以直接繼承(自帶支援繼承Date
),而且更為簡單:
class MyDate extends Date {
constructor() {
super();
this.abc = 1;
}
getTest() {
return this.getTime();
}
}
let date = new MyDate();
// 正常輸出,譬如1515638988725
console.log(date.getTest());
複製程式碼
對比下ES5中的實現,這個真的是簡單的不行,直接使用ES6的Class語法就行了。
而且,也可以正常輸出。
注意:這裡的正常輸出環境是直接用ES6執行,不經過babel打包,打包後實質上是轉化成ES5的,所以效果完全不一樣
ES6寫法,然後Babel打包
雖然說上述ES6大法是可以直接繼承Date的,但是,考慮到實質上大部分的生產環境是:ES6 + Babel
直接這樣用ES6 + Babel是會出問題的
不信的話,可以自行嘗試下,Babel打包成ES5後程式碼大致是這樣的:
然後當信心滿滿的開始用時,會發現:
對,又出現了這個問題,也許這時候是這樣的⊙?⊙
因為轉譯後的ES5原始碼中,仍然是通過MyDate
來構造,
而MyDate
的構造中又無法修改屬於Date
內部的[[Class]]
之類的私有標誌,
因此構造出的物件仍然不允許呼叫Date
方法(呼叫時,被引擎底層程式碼識別為[[Class]]
標誌不符合,不允許呼叫,丟擲錯誤)
由此可見,ES6繼承的內部實現和Babel打包編譯出來的實現是有區別的。 (雖說Babel的polyfill一般會按照定義的規範去實現的,但也不要過度迷信)。
幾種繼承的細微區別
雖然上述提到的三種方法都可以達到繼承Date
的目的-混合法嚴格說不能算繼承,只不過是另類實現。
於是,將所有能列印的主要資訊都列印出來,分析幾種繼承的區別,大致場景是這樣的:
可以參考:( 請進入除錯模式)dailc.github.io/fe-intervie…
從上往下,1, 2, 3, 4
四種繼承實現分別是:(排出了混合法)
-
ES6的Class大法
-
經典寄生組合式繼承法
-
本文中的取巧做法,Date構造例項,然後更改
__proto__
的那種 -
ES6的Class大法,Babel打包後的實現(無法正常呼叫的)
~~~~以下是MyDate們的prototype~~~~~~~~~
Date {constructor: ƒ, getTest: ƒ}
Date {constructor: ƒ, getTest: ƒ}
Date {getTest: ƒ, constructor: ƒ}
Date {constructor: ƒ, getTest: ƒ}
~~~~以下是new出的物件~~~~~~~~~
Sat Jan 13 2018 21:58:55 GMT+0800 (CST)
MyDate2 {abc: 1}
Sat Jan 13 2018 21:58:55 GMT+0800 (CST)
MyDate {abc: 1}
~~~~以下是new出的物件的Object.prototype.toString.call~~~~~~~~~
[object Date]
[object Object]
[object Date]
[object Object]
~~~~以下是MyDate們的__proto__~~~~~~~~~
ƒ Date() { [native code] }
ƒ () { [native code] }
ƒ () { [native code] }
ƒ Date() { [native code] }
~~~~以下是new出的物件的__proto__~~~~~~~~~
Date {constructor: ƒ, getTest: ƒ}
Date {constructor: ƒ, getTest: ƒ}
Date {getTest: ƒ, constructor: ƒ}
Date {constructor: ƒ, getTest: ƒ}
~~~~以下是物件的__proto__與MyDate們的prototype比較~~~~~~~~~
true
true
true
true
複製程式碼
看出,主要差別有幾點:
-
MyDate們的__proto__指向不一樣
-
Object.prototype.toString.call的輸出不一樣
-
物件本質不一樣,可以正常呼叫的
1, 3
都是Date
構造出的,而其它的則是MyDate
構造出的
我們上文中得出的一個結論是:由於呼叫的物件不是由Date構造出的例項,所以不允許呼叫,就算是自己的原型鏈上有Date.prototype也不行
但是這裡有兩個變數:分別是底層構造例項的方法不一樣,以及物件的Object.prototype.toString.call
的輸出不一樣。
(另一個MyDate.__proto__
可以排除,因為原型鏈回溯肯定與它無關)
萬一它的判斷是根據Object.prototype.toString.call
來的呢?那這樣結論不就有誤差了?
於是,根據ES6中的,Symbol.toStringTag
,使用黑魔法,動態的修改下它,排除下干擾:
// 分別可以給date2,date3設定
Object.defineProperty(date2, Symbol.toStringTag, {
get: function() {
return "Date";
}
});
複製程式碼
然後在列印下看看,變成這樣了:
[object Date]
[object Date]
[object Date]
[object Object]
複製程式碼
可以看到,第二個的MyDate2
構造出的例項,雖然列印出來是[object Date]
,但是呼叫Date方法仍然是有錯誤
此時我們可以更加準確一點的確認:由於呼叫的物件不是由Date構造出的例項,所以不允許呼叫
而且我們可以看到,就算通過黑魔法修改Object.prototype.toString.call
,內部的[[Class]]
標識位也是無法修改的。
(這塊知識點大概是Object.prototype.toString.call可以輸出內部的[[Class]],但無法改變它,由於不是重點,這裡不贅述)。
ES6繼承與ES5繼承的區別
從上文中的分析可以看到一點:ES6的Class寫法繼承是沒問題的。但是換成ES5寫法就不行了。
所以ES6的繼承大法和ES5肯定是有區別的,那麼究竟是哪裡不同呢?(主要是結合的本文繼承Date來說)
區別:(以SubClass
,SuperClass
,instance
為例)
-
ES5中繼承的實質是:(那種經典寄生組合式繼承法)
-
先由子類(
SubClass
)構造出例項物件this -
然後在子類的建構函式中,將父類(
SuperClass
)的屬性新增到this
上,SuperClass.apply(this, arguments)
-
子類原型(
SubClass.prototype
)指向父類原型(SuperClass.prototype
) -
所以
instance
是子類(SubClass
)構造出的(所以沒有父類的[[Class]]
關鍵標誌) -
所以,
instance
有SubClass
和SuperClass
的所有例項屬性,以及可以通過原型鏈回溯,獲取SubClass
和SuperClass
原型上的方法
-
-
ES6中繼承的實質是:
-
先由父類(
SuperClass
)構造出例項物件this,這也是為什麼必須先呼叫父類的super()
方法(子類沒有自己的this物件,需先由父類構造) -
然後在子類的建構函式中,修改this(進行加工),譬如讓它指向子類原型(
SubClass.prototype
),這一步很關鍵,否則無法找到子類原型(注,子類構造中加工這一步的實際做法是推測出的,從最終效果來推測) -
然後同樣,子類原型(
SubClass.prototype
)指向父類原型(SuperClass.prototype
) -
所以
instance
是父類(SuperClass
)構造出的(所以有著父類的[[Class]]
關鍵標誌) -
所以,
instance
有SubClass
和SuperClass
的所有例項屬性,以及可以通過原型鏈回溯,獲取SubClass
和SuperClass
原型上的方法
-
以上⬆就列舉了些重要資訊,其它的如靜態方法的繼承沒有贅述。(靜態方法繼承實質上只需要更改下SubClass.__proto__
到SuperClass
即可)
可以看著這張圖快速理解:
有沒有發現呢:ES6中的步驟和本文中取巧繼承Date的方法一模一樣,不同的是ES6是語言底層的做法,有它的底層優化之處,而本文中的直接修改__proto__容易影響效能
ES6中在super中構建this的好處?
因為ES6中允許我們繼承內建的類,如Date,Array,Error等。如果this先被建立出來,在傳給Array等系統內建類的建構函式,這些內建類的建構函式是不認這個this的。
所以需要現在super中構建出來,這樣才能有著super中關鍵的[[Class]]
標誌,才能被允許呼叫。(否則就算繼承了,也無法呼叫這些內建類的方法)
建構函式與例項物件
看到這裡,不知道是否對上文中頻繁提到的建構函式,例項物件有所混淆與困惑呢?這裡稍微描述下:
要弄懂這一點,需要先知道new
一個物件到底發生了什麼?先形象點說:
new MyClass()中,都做了些什麼工作
function MyClass() {
this.abc = 1;
}
MyClass.prototype.print = function() {
console.log('this.abc:' + this.abc);
};
let instance = new MyClass();
複製程式碼
譬如,上述就是一個標準的例項物件生成,都發生了什麼呢?
步驟簡述如下:(參考MDN,還有部分關於底層的描述略去-如[[Class]]標識位等)
-
建構函式內部,建立一個新的物件,它繼承自
MyClass.prototype
,let instance = Object.create(MyClass.prototype);
-
使用指定的引數呼叫建構函式
MyClass
,並將 this繫結到新建立的物件,MyClass.call(instance);
,執行後擁有所有例項屬性 -
如果建構函式返回了一個“物件”,那麼這個物件會取代整個
new
出來的結果。如果建構函式沒有返回物件,那麼new出來的結果為步驟1建立的物件。 (一般情況下建構函式不返回任何值,不過使用者如果想覆蓋這個返回值,可以自己選擇返回一個普通物件來覆蓋。當然,返回陣列也會覆蓋,因為陣列也是物件。)
結合上述的描述,大概可以還原成以下程式碼:(簡單還原,不考慮各種其它邏輯)
let instance = Object.create(MyClass.prototype);
let innerConstructReturn = MyClass.call(instance);
let innerConstructReturnIsObj = typeof innerConstructReturn === 'object' || typeof innerConstructReturn === 'function';
return innerConstructReturnIsObj ? innerConstructReturn : instance;
複製程式碼
-
注意⚠️:
-
普通的函式構建,可以簡單的認為就是上述步驟
-
實際上對於一些內建類(如Date等),並沒有這麼簡單,還有一些自己的隱藏邏輯,譬如
[[Class]]
標識位等一些重要私有屬性。- 譬如可以在MDN上看到,以常規函式呼叫Date(即不加 new 操作符)將會返回一個字串,而不是一個日期物件,如果這樣模擬的話會無效
-
覺得看起來比較繁瑣?可以看下圖梳理:
那現在再回頭看看。
什麼是建構函式?
如上述中的MyClass
就是一個建構函式,在內部它構造出了instance
物件
什麼是例項物件?
instance
就是一個例項物件,它是通過new
出來的?
例項與構造的關係
有時候淺顯點,可以認為建構函式是xxx就是xxx的例項。即:
let instance = new MyClass();
複製程式碼
此時我們就可以認為instance
是MyClass
的例項,因為它的建構函式就是它
例項就一定是由對應的建構函式構造出的麼?
不一定,我們那ES5黑魔法來做示例
function MyDate() {
// bind屬於Function.prototype,接收的引數是:object, param1, params2...
var dateInst = new(Function.prototype.bind.apply(Date, [Date].concat(Array.prototype.slice.call(arguments))))();
// 更改原型指向,否則無法呼叫MyDate原型上的方法
// ES6方案中,這裡就是[[prototype]]這個隱式原型物件,在沒有標準以前就是__proto__
Object.setPrototypeOf(dateInst, MyDate.prototype);
dateInst.abc = 1;
return dateInst;
}
複製程式碼
我們可以看到instance
的最終指向的原型是MyDate.prototype
,而MyDate.prototype
的建構函式是MyDate
,
因此可以認為instance
是MyDate
的例項。
但是,實際上,instance
卻是由Date
構造的
我們可以繼續用ES6
中的new.target
來驗證。
注意⚠️
關於new.target
,MDN
中的定義是:new.target返回一個指向構造方法或函式的引用。
嗯哼,也就是說,返回的是建構函式。
我們可以在相應的構造中測試列印:
class MyDate extends Date {
constructor() {
super();
this.abc = 1;
console.log('~~~new.target.name:MyDate~~~~');
console.log(new.target.name);
}
}
// new操作時的列印結果是:
// ~~~new.target.name:MyDate~~~~
// MyDate
複製程式碼
然後,可以在上面的示例中看到,就算是ES6的Class繼承,MyDate
構造中列印new.target
也顯示MyDate
,
但實際上它是由Date
來構造(有著Date
關鍵的[[Class]]
標誌,因為如果不是Date構造(如沒有標誌)是無法呼叫Date的方法的)。
所以,實際上用new.target
是無法判斷例項物件到底是由哪一個構造構造的(這裡指的是判斷底層真正的[[Class]]
標誌來源的構造)
在MDN上的定義也可以看到,new.target
返回的是直接建構函式(new作用的那個),所以請不要將直接建構函式與實際上的構造搞混
再回到結論:例項物件不一定就是由它的原型上的建構函式構造的,有可能建構函式內部有著寄生等邏輯,偷偷的用另一個函式來構造了下, 當然,簡單情況下,我們直接說例項物件由對應建構函式構造也沒錯(不過,在涉及到這種Date之類的分析時,我們還是得明白)。
[[Class]]與Internal slot
這一部分為補充內容。
前文中一直提到一個概念:Date內部的[[Class]]
標識
其實,嚴格來說,不能這樣泛而稱之(前文中只是用這個概念是為了降低複雜度,便於理解),它可以分為以下兩部分:
-
在ES5中,每種內建物件都定義了 [[Class]] 內部屬性的值,[[Class]] 內部屬性的值用於內部區分物件的種類
-
Object.prototype.toString
訪問的就是這個[[Class]] -
規範中除了通過
Object.prototype.toString
,沒有提供任何手段使程式訪問此值。 -
而且Object.prototype.toString輸出無法被修改
-
-
而在ES6中,之前的 [[Class]] 不再使用,取而代之的是一系列的
internal slot
-
Internal slot 對應於與物件相關聯並由各種ECMAScript規範演算法使用的內部狀態,它們沒有物件屬性,也不能被繼承
-
根據具體的 Internal slot 規範,這種狀態可以由任何ECMAScript語言型別或特定ECMAScript規範型別值的值組成
-
通過
Object.prototype.toString
,仍然可以輸出Internal slot值 -
簡單點理解(簡化理解),Object.prototype.toString的流程是:如果是基本資料型別(除去Object以外的幾大型別),則返回原本的slot, 如果是Object型別(包括內建物件以及自己寫的物件),則呼叫
Symbol.toStringTag
-
Symbol.toStringTag
方法的預設實現就是返回物件的Internal slot,這個方法可以被重寫
-
這兩點是有所差異的,需要區分(不過簡單點可以統一理解為內建物件內部都有一個特殊標識,用來區分對應型別-不符合型別就不給呼叫)。
JS內建物件是這些:
"Arguments", "Array", "Boolean", "Date", "Error", "Function", "JSON", "Math", "Number", "Object", "RegExp", "String"
複製程式碼
ES6新增的一些,這裡未提到:(如Promise物件可以輸出[object Promise]
)
而前文中提到的:
Object.defineProperty(date, Symbol.toStringTag, {
get: function() {
return "Date";
}
});
複製程式碼
它的作用是重寫Symbol.toStringTag,擷取date(雖然是內建物件,但是仍然屬於Object)的Object.prototype.toString
的輸出,讓這個物件輸出自己修改後的[object Date]
。
但是,僅僅是做到輸出的時候變成了Date,實際上內部的internal slot
值並沒有被改變,因此仍然不被認為是Date
如何快速判斷是否繼承?
其實,在判斷繼承時,沒有那麼多的技巧,就只有關鍵的一點:[[prototype]]
(__ptoto__
)的指向關係
譬如:
console.log(instance instanceof SubClass);
console.log(instance instanceof SuperClass);
複製程式碼
實質上就是:
-
SubClass.prototype
是否出現在instance
的原型鏈上 -
SuperClass.prototype
是否出現在instance
的原型鏈上
然後,對照本文中列舉的一些圖,一目瞭然就可以看清關係。有時候,完全沒有必要弄的太複雜。
寫在最後的話
由於繼承的介紹在網上已經多不勝數,因此本文沒有再重複描述,而是由一道Date繼承題引發,展開。(關鍵就是原型鏈)
不知道看到這裡,各位看官是否都已經弄懂了JS中的繼承呢?
另外,遇到問題時,多想一想,有時候你會發現,其實你知道的並不是那麼多,然後再想一想,又會發現其實並沒有這麼複雜。。。
附錄
部落格
初次釋出2018.01.15
於我個人部落格上面
www.dailichun.com/2018/01/15/…