噫籲嚱,js之難,難於上青天
在原型鏈和繼承上花了不知道多少時間,當每次自以為已經吃透的時候,總是會出現各種難以理解的么蛾子。也許就像kyle大佬說的那樣,js的繼承模式真的是‘蠢弟弟’設計吧。
本文小綱介紹
閱讀本文之前先約定,本文中稱 __proto__ 為 內建原型,稱 prototype 為 原型物件,建構函式 SubType 和 SuperType 分別稱為子類和父類。
請先看下圖,如果各位覺得soeasy,請直接 插隊這裡
es5寄生組合繼承
function SuperType(name){
this.name = name;
this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function(){
alert(this.name);
};
function SubType(name, age){
SuperType.call(this, name);
this.age = age;
}
SubType.prototype = Object.create(SuperType.prototype, {
constructor: {
value: SubType,
enumerable: false,
writable: true,
configurable: true
}
})
SubType.prototype.sayAge = function(){
alert(this.age);
};
let instance = new SubType('gim', '17');
instance.sayName(); // 'gim'
instance.sayAge(); // '17'
複製程式碼
- 首先,這段程式碼宣告瞭父類
SuperType
- 其次,宣告瞭父類的原型物件方法
sayName
。 - 再次,宣告瞭子類
SubType
,並在未來將要新建立的SubType
例項環境上呼叫父類SuperType.call
,以獲取父類中的name
和colors
屬性。 - 再次,用
Object.create()
方法把子類的原型物件上的內建物件__proto__
指向了父類的原型物件,並把子類建構函式重新賦值為子類。 - 然後,給子類的原型物件上新增方法 sayAge。
- 最後初始化例項物件
instance
。(呼叫new SubType('gim', '17')
的時候會生成一個__proto__
指向SubType.prototype
的空物件,然後把this
指向這個空物件。在新增完name、colors、age
屬性之後,返回這個‘空物件’,也就是instance
最終就是這個‘空物件’。)
此時,程式碼中生成的原型鏈關係如下圖所示(下面三張圖擼了一下午,喜歡的幫忙點個贊謝謝):
-
圖中有兩種顏色的帶箭頭的線,紅色的線是我們生成的例項的原型鏈,是我們之所以能呼叫到
instance.sayName()
和instance.sayAge()
的根本所在。當呼叫instance.sayName()
的時候,js引擎會先查詢instance
物件中的自有屬性。未找到sayName
屬性,則繼續沿原型鏈查詢,此時instance
通過內建原型__proto__
鏈到了SubType.prototype
物件上。但在SubType.prototype
上也未找到sayName
屬性,繼續沿原型鏈查詢,此時SubType.prototype
的__proto__
鏈到了SuperType.prototype
物件上。在物件上找到了sayName
屬性,於是查詢結束,開始呼叫。因此呼叫instance.sayName()
相當於呼叫了instance.__proto__.__proto__.sayName()
,只不過前者中sayName
函式內this
指向instance
例項物件,而後者sayName
函式內的this
指向了SuperType.prototype(instance.__proto__.__proto__ === SuperType.prototype)
物件。子類的原型物件的
__proto__
指向父類的原型物件。 -
黑色的帶箭頭的線則是 es5 繼承中產生的‘副作用’,使得所有的函式的
__proto__
指向了Function.prototype
,並最終指向 Object.prototype,從而使得我們宣告的函式可以直接呼叫toString
(定義在Function.prototype上)、hasOwnProperty
(定義在Object.prototype上) 等方法,如:SubType.toString()、SubType.hasOwnProperty()
等。在 es5 的實現中,子類的
__proto__
直接指向的是Function.prototype
。下面看看es6中有哪些不同吧。
es6的class ... extends ...
class SuperType {
constructor(name) {
this.name = name
this.colors = ["red", "blue", "green"];
}
sayName() {
alert(this.name)
}
}
class SubType extends SuperType {
constructor(name, age){
super(name)
this.age = age
}
sayAge() {
alert(this.age)
}
}
let instance = new SubType('gim', '17');
instance.sayName(); // 'gim'
instance.sayAge(); // '17'
複製程式碼
可以明顯的發現這段程式碼比之前的更加簡短和美觀。es6 class
實現繼承的核心在於使用關鍵字 extends
表明繼承自哪個父類,並且在子類建構函式中必須呼叫 super
關鍵字,super(name)
相當於es5繼承實現中的 SuperType.call(this, name)
。
雖然結果可能如你所料的實現了原型鏈繼承,但是這裡還是有個需要注意的點值得一說。
如圖,es6中的 class
繼承存在兩條繼承鏈:
-
子類
prototype
屬性的__proto__
屬性,表示方法的繼承,總是指向父類的prototype
屬性。 這點倒和經典繼承是一致的。 如紅線所示,子類SubType
的prototype
屬性的__proto__
指向父類SuperType
的prototype
屬性。 相當於呼叫Object.setPrototypeOf(SubType.prototype, SuperType.prototype)
; 因為和經典繼承相同,這裡不再累述。 -
子類的
__proto__
屬性,表示建構函式的繼承,總是指向父類。 這是個值得注意的點,和es5中的繼承不同,如藍線所示,子類SubType
的__proto__
指向父類SuperType
。相當於呼叫了Object.setPrototypeOf(SubType, SuperType)
; es5繼承中子類和父類的內建原型直接指向的都是Function.prototype
,所以說Function
是所有函式的爸爸。而在es6class...extends...
實現的繼承中,子類的內建原型直接指向的是父類。 之所以注意到這點,是因為看 kyle 大佬的《你不知道的javascript 下》的時候,看到了class MyArray extends Array{}
和var arr = MyArray.of(3)
這兩行程式碼,很不理解為什麼MyArray
上面為什麼能調到of
方法。因為按照es5中繼承的經驗,MyArray.__proto__
應該指向了Function.prototype
,而後者並沒有of方法。當時感覺世界觀都崩塌了,為什麼我以前的認知失靈了?第二天重翻阮一峰老師的《ECMAScript6入門》才發現原來class
實現的繼承是不同的。
知道了這點,就可以根據需求靈活運用Array
類構造自己想要的類了:
class MyArray extends Array {
[Symbol.toPrimitive](hint){
if(hint === 'default' || hint === 'number'){
return this.reduce((prev,curr)=> prev+curr, 0)
}else{
return this.toString()
}
}
}
let arr = MyArray.of(2,3,4);
arr+''; // '9'
複製程式碼
元屬性Symbol.toPrimitive
定義了MyArray
的例項發生強制型別轉換的時候應該執行的方法,hint
的值可能是default/number/string
中的一種。現在,例項arr
能夠在發生加減乘除的強制型別轉換的時候,陣列內的每項會自動執行加性運算。
以上就是js實現繼承的兩種模式,可以發現class繼承和es5寄生組合繼承有相似之處,也有不同的地方。雖然class繼承存在一些問題(如暫不支援靜態屬性等),但是子類的內建原型指向父類這點是個不錯的改變,這樣我們就可以利用原生建構函式(Array等)構建自己想要的類了。
kyle大佬提到的行為委託
在讀《你不知道的javascript 上》的時候,感觸頗多。這本書真的是本良心書籍,讓我學會了LHS/RHS,讀懂了閉包,瞭解了詞法作用域,徹底理解了this指向,基本懂了js的原型鏈繼承。所以當時就忍不住又從頭讀了一遍。如果說諸多感受中最大的感受是啥,那一定是行為委託了。我第一次見過有大佬能夠如此強悍(至少沒見過國內的大佬這麼牛叉的),強悍到直接號召讀者抵制js的繼承模式(無論寄生組合繼承還是class繼承),並且提倡使用行為委託模式實現物件的關聯。我真的被折服了,要知道class可是w3c委員會制定出的標準,並且已經廣泛的應用到了業界中。關鍵的關鍵是,我確實認為行為委託確實更加清晰簡單(如有異議請指教)。
let SuperType = {
initSuper(name) {
this.name = name
this.color = [1,2,3]
},
sayName() {
alert(this.name)
}
}
let SubType = {
initSub(age) {
this.age = age
},
sayAge() {
alert(this.age)
}
}
Object.setPrototypeOf(SubType,SuperType)
SubType.initSub('17')
SubType.initSuper('gim')
SubType.sayAge() // 'gim'
SubType.sayName() // '17'
複製程式碼
這就是模仿上面js繼承的兩個例子,利用行為委託實現的物件關聯。行為委託的實現非常超級極其的簡單,就是把父物件關聯到子物件的內建原型上,這樣就可以在子物件上直接呼叫父物件上的方法。行為委託生成的原型鏈沒有class繼承生成的原型鏈的複雜關係,一目瞭然。當然class有其存在的道理,但是在些許場景下,應該是行為委託更加合適吧。希望safari儘快實現Object.setPrototypeOf()
方法,太out了連ie都支援了。
小子愚鈍,如果行為委託完全能夠實現實現class繼承的功能,而且更加簡單和清晰,我們開發的過程中為什麼不嘗試用一下呢?