這一篇進入正題來複習一下 JavaScript 中物件的繼承。“高程”中一共列舉了 6 種繼承的方式。看起來是有些嚇人,但仔細梳理就能發現其中也是有一個演變過程的。這篇筆記就是我自己對這個過程的理解。如果有不足的地方,還希望各位可以指出。
再次安利一下“高程”,真的寫得非常棒。有一定基礎和專案經驗的同學絕對要去看一看,能提高不少。
基礎概念
在進入繼承之前,我們再把一些基本概念複習一下。
0. 建構函式、原型物件、例項
談到物件,一定會出現這三個概念。在 JavaScript 中,原型物件不需要我們手動去定義,當我們定義一個類或者建構函式後,JavaScript 會自動生成對應的原型物件,我們可以通過 prototype
屬性訪問;我們寫的 function A(){}
就是建構函式;而我們用 new
呼叫建構函式返回的值就是物件的例項。
// 建構函式:其實就是一般的函式。函式名大寫只是一個約定規則而已。
// 事實上除了 new 之外,我們也可以像呼叫一般的函式一樣使用。
// 在 ES6 中就是 Class 裡面的 constructor
function A(){
this.a = 'a'
}
// 原型物件:當我們定義物件的時候,就會生成一個對應的原型物件。
// 該函式的 prototype 屬性就指向原型物件,這個就是原型鏈的精髓。
A.prototype
// 例項: 用 new 呼叫建構函式的返回值。
var a = new A()
複製程式碼
1. 原型鏈
下面再來看一下原型鏈的概念。原型鏈的概念我們一定不會陌生。那麼就不多說了,直接上圖。這是一個最基本的原型鏈,我們要仔細理解這張圖並且搞清楚 例項、物件原型、建構函式以及與 Object
之間的關係。
在複習完上面兩個概念之後(特別是原型鏈),相信在後面理解繼承的時候會有所幫助。如果在後面有所困惑的話,不妨回來看看基礎概念。
下面就開始進入正題。
JavaScript 中的繼承
1. 原型鏈繼承
我們對上面基礎的例子做一個擴充。再加入一個物件 B
,我們同樣畫成圖。
A
和 B
是互相獨立的兩個物件(類),並且它們都是 繼承 了 JavaScript 的物件之祖——Object
物件。請注意,在一般的物件中,就已經存在了一個 物件 與 Object
物件 的繼承關係了。
那麼現在我們要讓物件B
去繼承物件A
,就可以模仿物件和Object
的關係,修改B
的原型物件的指向。
這麼一來,B
就可以順著原型鏈訪問到A
了。由於現在B.prototype = A.prototype
了,那麼在B.prototype
上做的任何修改都會影響到A.prototype
了。所以我們再稍微調整一下,讓 B.prototype = new A()
。
這樣,完整的原型鏈繼承的關係圖就出來了。
簡單的示例程式碼如下,小夥伴們可以在 Chrome 中試玩一下。
function A(){ this.a = 'a' }
function B(){ this.b = 'b' }
// 不要這麼做,因為修改 B 的 prototype 會影響 A 的 prototype
// B.prototype = A.prototype
B.prototype = new A()
B.prototype.constructor = B
複製程式碼
物件的 constructor
在上面的示例程式碼中,最後一行我們對物件 B 的建構函式重新進行了賦值。這是因為,當我們改變了 B.prototype
的時候,會切斷原來 B 的建構函式與 B.prototype
之間的聯絡。
雖然這個屬性對我們的繼承關係沒有影響(instanceof
方法結果仍然正確)。但是從程式碼含義上來說,我們最好還是修改稱為正確的指向。
另外,對於例項來說 a.constructor.prototype === A.prototype // true
。即我們可以通過例項的建構函式去給物件原型新增屬性和方法。儘管沒人會推薦我們這麼去做,但讓屬性指向正確的值會比較好。
和物件的建立一樣,原型鏈的方法是比較簡單的。但是也有一個明顯的缺陷,就是“無法”對父類傳不同的值。即 B.prototype = new A(xx)
時之後所有的 B 的示例都會帶上這個值,因此就產生了侷限性。
回想一下在建立物件時,我們是怎麼解決這個問題的?
2. 建構函式繼承
在建立物件時,我們知道不同的例項在建立時只要向建構函式中傳入不同的值,就會得到不同的值。那麼回到繼承上,為了解決原型鏈繼承無法向父類傳遞不同值的問題,我們同樣也需要藉助建構函式。
在進入正題前,我們再看一下建構函式,然後想一下如果不用 new 呼叫建構函式會是怎樣?
下面是一個 Person
的建構函式。一般來說我們使用 new
關鍵字建立 Person
的例項。但是有沒有想過,建構函式也是函式,如果我們不用 new
而是普通地呼叫會是怎麼情況呢?
function Person(name, age, sex){
this.name = name
this.age = age
this.sex = sex
}
// 直接呼叫會是怎麼情況呢?
Person('Kizunaai', 2, 'female')
複製程式碼
熟悉 this
特性的小夥伴肯定能反應過來。獨立呼叫函式時,若在非嚴格模式下,this
指向的是 window
(瀏覽器環境)。那麼我們看一下 window
。
window
中果然就有了 age
這個屬性,並且值為 2。也就是說,直接呼叫建構函式就相當於把建構函式中的屬性賦給呼叫它的物件了。
好,趁熱打鐵,我們直接來看程式碼。
function Person(name, age, sex){
this.name = name
this.age = age
this.sex = sex
}
function VTuber(name, age, sex){
// 呼叫 Person 的建構函式實際上就是把 Person 的值賦給 VTuber
// 在 ES6 中就是 super()
Person.call(this, name, age, sex)
}
var kizunaai = new VTuber('kizunaai', 2, 'female')
var luna = new VTuber('luna', 100, 'female')
kizunaai.name // 'kizunaai'
luna.name // 'luna'
複製程式碼
我們在 Chrome 中分別列印一下之前的例項。可以看到 VTuber
的例項只是包含了 Person
的屬性而已,而在原型鏈上兩者是沒有任何關係的。
所以再想一下,建構函式的方法其實真的是“繼承”嗎?因為對於“子類”來說,是沒有辦法呼叫父類原型上的方法的。而在用建構函式建立物件時我們就已經知道,把方法寫在建構函式裡顯然不是一個好的解決方法。
3. 組合式繼承
既然原型鏈和建構函式正好能彌補互相之間的缺陷,組合起來我們能愉快地進行繼承了。也沒什麼新的知識點,就直接上程式碼了。
function Person(name, age, sex){
this.name = name
this.age = age
this.sex = sex
}
Person.prototype.sayHello = function(){
return `${this.name} say hello ~`
}
function VTuber(name, age, sex){
// 建構函式保證了不同值的傳遞
Person.call(this, name, age, sex)
}
// 原型鏈保證了方法的傳遞(還有意義上)
VTuber.prototype = new Person()
VTuber.prototype.constructor = VTuber
var kizunaai = new VTuber('kizunaai', 2, 'female')
var luna = new VTuber('luna', 100, 'female')
kizunaai.name // 'kizunaai'
luna.sayHello() // 'luna say hello ~'
複製程式碼
4. 寄生組合式繼承
組合式繼承是我們最常用的繼承方法,幾乎可以說是滿足了我們的需求。硬要挑刺的話,也就是父類的建構函式呼叫兩次的問題了。
// 以之前的程式碼為例
// 第一次在子類的建構函式中呼叫
Person.call(this, name, age, sex)
// 第二次在建立原型鏈時呼叫
VTuber.prototype = new Person()
複製程式碼
其中第一次是一定省不掉的,要下功夫的話就是在第二次建立原型鏈的時候了。還是以前面的程式碼為例,我們就這麼繼承,資料結構會是怎麼樣的?
可以看到在 VTuber.prototype
上也有 name, age, sex
三個屬性,但實際上這三個屬性根本沒有意義。那麼解決的思路就有了,我們需要藉助一個空的物件來搭一座橋。(千萬別說讓 VTuber.prototype = Person.prototype
了,理由參考原型鏈那部分)
function Person(name, age, sex){
this.name = name
this.age = age
this.sex = sex
}
Person.prototype.sayHello = function(){
return `${this.name} say hello ~`
}
function VTuber(name, age, sex){
// 建構函式保證了不同值的傳遞
Person.call(this, name, age, sex)
}
// 我們要借用一個空物件作為過渡
// VTuber.prototype = new Person()
function A(){}
A.prototype = Person.prototype
VTuber.prototype = new A()
VTuber.prototype.constructor = VTuber
var kizunaai = new VTuber('kizunaai', 2, 'female')
var luna = new VTuber('luna', 100, 'female')
kizunaai.name // 'kizunaai'
luna.sayHello() // 'luna say hello ~'
複製程式碼
上面我們借用了一個 A
來打了個橋。這樣一來就在原型鏈上就沒有多餘的屬性了。(其實兩次建構函式是肯定要調的,只是第二次調誰的問題)
而這種搭橋的方式,在“高程”中也被稱為是原型式繼承。其中關於原型式和寄生式分別和原型鏈、建構函式相對應,感覺沒有這兩種直觀而且也不常用(個人感覺)所以就不做展開了,有興趣的小夥伴還是推薦去閱讀“高程”。
小結
至此,有關於繼承的筆記就到此為止。這一篇順著物件的原型鏈的概念開始,介紹了 JavaScript 中物件繼承的幾種方式。其中組合繼承的方式是我們最常見也是用的最廣的,我們需要好好了解一下。
在看書的過程中,像這樣給自己拋點問題,找一找方法間的演變脈絡即很有趣也很容易理解。不知道各位小夥伴有什麼好的學習方法呢?不妨互相交流一下吧~