你應該知道,JavaScript是一門基於原型鏈的語言,而我們今天的主題 -- “繼承”就和“原型鏈”這一概念息息相關。甚至可以說,所謂的“原型鏈”就是一條“繼承鏈”。有些困惑了嗎?接着看下去吧。
一、構造函數,原型屬性與實例對象
要搞清楚如何在JavaScript中實現繼承,我們首先要搞懂構造函數,原型屬性與實例對象三者之間的關系,讓我們先看一段代碼:
function Person(name, age) {
var gender = girl // ①
this.name = name // ②
this.age = age
}
// ③
Person.prototype.sayName = function() {
alert(this.name)
}
// ④
var kitty = new Person('kitty', 14)
kitty.sayName() // kitty
讓我們通過這段代碼澄清幾個概念:
Person
是一個“構造函數”(它用來“構造”對象,並且是一個函數),①處gender
是該構造函數的“私有屬性”,②處的語句定義了該構造函數的“自有屬性”;- ③處的
prototype
是Person
的“原型對象”(它是實例對象的“原型”,同時它是一個對象,但同時它也是構造函數的“屬性”,所以也有人稱它為“原型屬性”),該對象上定義的所有屬性(和方法)都會被“實例對象”所“繼承”(我們終於看到這兩個字了,但是不要心急,我們過一會才會談論它); - ④處的變量“kitty”的值是構造函數
Person
的“實例對象”(它是由構造函數生成的一個實例,同時,它是一個對象),它可以訪問到兩種屬性,一種是通過構造函數生成的“自有屬性”,一種是原型對象可以訪問的所有屬性;
對以上這些概念有清楚的認識,才能讓你對JavaScript的“繼承”與“原型鏈”的理解更加深刻,所以務必保障你已經搞清楚了他們之間的關系。(如果沒有,務必多看幾遍,你可以找張紙寫寫畫畫,我第一次就是這么做的)
徹底搞清楚了?那讓我們繼續我們的主題 -- “繼承”。
你是否覺得奇怪,為什么我們的實例對象可以訪問到構造函數原型屬性上的屬性(真是拗口)?答案是因為“每一個對象自身都擁有一個隱式的[[proto]]
屬性,該屬性默認是一個指向其構造函數原型屬性的指針”(其實我想說它是一個鈎子,在對象創建時默認“勾住”了其構造函數的原型屬性,但是我發現emoji居然沒有鈎子的圖標,所以...🤷🏻♂️,不過我還是覺得鈎子更形象些...)。
當JavaScript引擎發現一個對象訪問一個屬性時,會首先查找對象的“自有屬性”,如果沒有找到則會在[[proto]]
屬性指向的原型屬性中繼續查找,如果還沒有找到的話,你知道其實原型屬性也是一個對象,所以它也有一個隱式的[[proto]]
屬性指向它的原型屬性...,正如你所料,如果一直沒有找到該屬性,JavaScript引擎會一直這樣找下去,直到找到最頂部構造函數Object
的prototype
原型屬性,如果還是沒有找到,會返回一個undefined
值。這個不斷查找的過程,有一個形象生動的名字“攀爬原型鏈”。
現在你應該對“原型鏈”就是“繼承鏈”這一說法有點感覺了吧,讓我們暫時休息一下,對兩個我們遺漏的知識點補充說明:
- 隱式的
[[proto]]
屬性 - 原型對象
prototype
(一)隱式的[[proto]]
屬性
何為“隱式屬性”呢?即是開發者無法訪問卻確實存在的屬性,你可能會問,既然是隱式的,如何證明它的存在呢?問得好,答案是雖然JavaScript語言沒有暴露給我們這個屬性,但是瀏覽器卻幫助我們可以獲取到該屬性,在Chorme中,我們可以通過瀏覽器為對象添加的_proto_
屬性訪問到[[proto]]
的值。你可以自己試試在控制台中打印這個屬性,證明我沒有說謊。
(二)原型對象prototype
還記的我們之前提到JavaScript世界一條重要的概念嗎?“每一個對象自身都擁有一個隱式的[[proto]]
屬性,該屬性默認是一個指向其構造函數原型屬性的指針”。其實與其對應的,還有一條重要的概念我需要在這里告訴你“幾乎所有函數都擁有prototype
原型屬性”。這兩個概念確實非常重要,因為每當你搞混了構造函數,原型屬性,實例對象之間的關系,以及JavaScript世界中的繼承規則時,想想這兩個概念總能幫助你剝離迷霧,重新發現真相。
(三)JavaScript世界兩個重要概念
因為他們真的很重要,所以我特別使用一個藍色開頭的列表再寫一遍(保持耐心,朋友!)
- 每一個對象自身都擁有一個隱式的
[[proto]]
屬性,該屬性默認是一個指向其構造函數原型屬性的指針; - 幾乎所有函數都擁有
prototype
原型屬性;
至此,我們搞清楚了構造函數,原型屬性與實例對象三者的關系,相信我,理解清楚這三者的關系能讓你以更清晰的視角去觀察JavaScript的繼承世界,而在下一章中,我們將更進一步,直奔主題的闡述在JavaScript世界中如何實現繼承,當然,還有背后的原理。
二、在JavaScript世界中實現繼承
既然說了要直奔主題,我們便直接開始對JavaScript世界中對象的繼承方式展開說明。不過在那之前,讓我們再統一我們對“繼承”這一概念的認識:即我們想要一個對象能夠訪問另一個對象的屬性,同時,這個對象還能夠添加自己新的屬性或是覆蓋可訪問的另一個對象的屬性,我們實現這個目標的方式叫做“繼承”。
而在JavaScript世界,實現繼承的方式有以下兩種:
- 創建一個對象並指定其繼承對象(原型對象);
- 修改構造函數的原型屬性(對象);
看起來很合乎邏輯對吧,我們能夠針對“對象”,令一個對象繼承另一個對象,也能夠轉而針對創建對象的“構造函數”,以實現實例對象的繼承。但是這里有個陷阱(你可能注意到了),對於一個已經定義的對象,我們無法再改變其繼承關系,我們的第一種方式只能在“創建對象時”定義對象的繼承對象。這是為什么呢?答案是因為“我們設置一個對象的繼承關系,本質上是在操作對象隱式的[[proto]]
屬性”,而JavaScript只為我們開通了在對象創建時定義[[proto]]
屬性的權限,而拒絕讓我們在對象定義時再修改或訪問這一屬性(所以它是“隱式”的)。很遺憾,在對象定義后改變它的繼承關系確實是不可能的。
好了,是時候看看JavaScript世界中繼承的主角了 -- Object.create()
(一)關於Object.create()
和對象繼承
正如之前所說,Object.create()
函數是JavaScript提供給我們的一個在創建對象時設置對象內部[[proto]]
屬性的API,相信你已經清楚的知道了,通過修改[[proto]]
屬性的值,我們就能決定對象所繼承的對象,從而以我們想要的方式實現繼承。
讓我們細致的了解一下Object.create()
函數:
var x = {
name: 'tom',
sayName: function() {
console.log(this.name)
}
}
var y = Object.create(x, {
name: {
configurable: true,
enumerable: true,
value: 'kitty',
writable: true,
}
})
y.sayName() // 'kitty'
看到了嗎,Object.create()
函數接收兩個參數,第一個參數是創建對象想要繼承的原型對象,第二個參數是一個屬性描述對象(不知道什么是屬性描述對象?看看我之前的這篇文章),然后會返回一個對象。
讓我們談談在調用Object.create()
時究竟發生了什么:
- 創建了一個空對象,並賦值給相應變量;
- 將第一個參數對象設置為該對象
[[proto]]
屬性的值; - 在該對象上調用
defineProperty()
方法,並將第二個參數傳入該方法中;
相信到這里你已經完全明白了如何在創建對象時實現繼承了,但這樣的方法有很多局限,比如我們只能在創建對象時設置對象的繼承對象,又比如這種設置繼承的方式是一次性的,我們永遠無法依靠這種方式創造出多個有相同繼承關系的對象,而對於這種情況,我們理所當然的要請出我們的第二個主角 -- prototype
原型對象。
(二)關於prototype
和構造函數繼承
還記得我們之前反復提及構造函數,原型屬性與實例對象的關系吧?我們還強調了“幾乎所有的函數都擁有prototype
屬性”,現在就是應用這些知識的時候了,其實說到繼承,構造函數生產實例對象的過程本身就是一種天然的繼承。實例對象天然的繼承着原型對象的所有屬性,這其實是JavaScript提供給開發者第二種(也是默認的)設置對象[[proto]]
屬性的方法。
但是這種”天然的“繼承方式缺點在於只存在兩層繼承:自定義構造函數的prototype
對象繼承Object構造函數的prototype
屬性,構造函數的實例對象繼承構造函數的prototype
屬性。而我們有時想要更加靈活,滿足需求,甚至是”更長“的原型鏈(或者說是”繼承鏈“)。這是JavaScript默認的繼承模式下無法實現的,但解決方式也很符合直覺,既然我們無法修改對象的[[proto]]
屬性,我們就去修改[[proto]]
屬性指向的對象 -- 原型對象。
我們說過原型對象也是一個對象對吧?所以我們就有了以下操作:
function Foo(x, y) {
this.x = x
this.y = y
}
Foo.prototype.sayX = function() {
console.log(this.x)
}
Foo.prototype.sayY = function() {
console.log(this.y)
}
function Bar(z) {
this.z = z
this.x = 10
}
Bar.prototype = Object.create(Foo.prototype) // 注意這里
Bar.prototype.sayZ = function() {
console.log(this.z)
}
Bar.prototype.constructor = Bar
var o = new Bar(1)
o.sayX() // 10
o.sayZ() // 1
相信你注意到了,我通過修改了構造函數Bar的原型屬性,將其值設置為一個繼承對象為Foo.prototype
的空對象,在之后,我又為在該對象添加了一些屬性(注意到我添加的constructor
屬性了嗎?如果你不明白為什么,你應該去了解一下我這么做的理由。)和方法。這樣,構造函數Bar的實例對象就會在查詢屬性時攀爬原型鏈,從自有屬性開始,途徑Bar.prototype
,Foo.prototype
,最終到達Object.prototype
。這正是我們想要的!太棒了!
毫不意外的,這種繼承的方式被稱為”構造函數繼承“,在JavaScript中是一種關鍵的實現的繼承方法,相信你已經很好的掌握了。
但是慢着,還有一個問題沒有解決,讓我們回到剛才的代碼,看看如果我們在源代碼上添加一條o.sayY()
會發生什么?答案是控制台會輸出undefined
。
毫不意外對吧,畢竟我們從來都沒有定義過y屬性。但是假如我們也想讓構造函數Bar的實例對象擁有構造函數Foo的設置的自有屬性又該怎么辦呢?答案是通過”構造函數竊取“技術,這將是我們下一章也是最后一章要討論的話題。
(三)構造函數竊取
如果”竊取“所繼承的構造函數的自有屬性呢?答案是巧妙的使用.call()
和.apply()
方法,讓我們修改一下之前的代碼:
function Foo(x, y) {
this.x = x
this.y = y
}
Foo.prototype.sayX = function() {
console.log(this.x)
}
Foo.prototype.sayY = function() {
console.log(this.y)
}
function Bar(z) {
this.z = z
this.x = 10
Foo.call(this, z, z) // 注意這里
}
Bar.prototype = Object.create(Foo.prototype)
Bar.prototype.sayZ = function() {
console.log(this.z)
}
Bar.prototype.constructor = Bar
var o = new Bar(1)
o.sayX() // 1
o.sayY() // 1
o.sayZ() // 1
Done!我們成功竊取了構造函數Foo的兩個自有屬性,構造函數Bar的實例對象現在也有了x和y的值!
雖然答案已經一目了然了,但還是讓我再解釋一下這是怎么做到的:首先我們知道構造函數也是函數,因此我們可以像普通函數一樣調用他,讓我們以單純的函數視角看待構造函數Foo,它不過是往this
所指的對象上添加了兩個屬性,然后返回了undefined值,當我們單純調用該函數時,this
的指向為window
(不明白為什么指向window,你可以閱讀我的這篇文章)。但是通過call()
和apply()
函數,我們可以人為的改變函數內this
指針的指向,所以我們將構造函數內的this
傳入call()
函數中,奇妙的事情發生了,原先為Foo函數實例對象添加的屬性現在添加到了Bar函數的實例對象上!
“構造函數竊取”,我喜歡“竊取”這兩個字,確實很巧妙。
太棒了 你終於看完了這篇文章,是否徹底搞懂JavaScript中的繼承了呢?希望如此。
算是個獎勵,我之前有將JavaScript中的繼承知識總結為一張思維導圖,你可以點擊這里查看。知識總是反復記憶才能真正掌握,希望你能常回來看看。加油👊 !