一文搞懂 JS 原型鏈的來龍去脈
點擊上方?程序員成長指北,關注公眾號
回復1,加入高級Node交流群
前言
在面向對象編程中,繼承是非常實用也非常核心的功能,這一切都基于面向類語言中的類。然而,javascript和面向類的語言不同,它沒有類作為藍圖,javascript中只有對象,但抽象繼承思想又是如此重要,于是聰明絕頂的javascript開發(fā)者們就利用javascript原型鏈的特性實現(xiàn)了和類繼承功能一樣的繼承方式。
何為原型
要想弄清楚原型鏈,我們得先把原型搞清楚,原型可以理解為是一種設計模式。以下是《你不知道的javascript》對原型的描述:
javascript中的對象有一個特殊的[[Prototype]]內置屬性,其實就是對其他對象的引用。幾乎所有的對象在創(chuàng)建時 [[Prototype]] 都會被賦予一個非空的值。
《javascript高級程序設計》這樣描述原型:
每個函數都會創(chuàng)建一個
prototype屬性,這個屬性是一個對象,包含應該由特定引用類型的實例共享的屬性和方法。實際上,這個對象就是通過調用構造函數創(chuàng)建的對象的原型。使用原型對象的好處是,在它上面定義的屬性和方法都可以被對象實例共享。原來在構造函數中直接賦給對象實例的值,可以直接賦值給它們的原型。
我們通過一段代碼來理解這兩段話:
function?Person()?{?}
//?在Person的原型對象上掛載屬性和方法
Person.prototype.name?=?'滑稽鴨'
Person.prototype.age?=?22
Person.prototype.getName?=?function?()?{
??return?this.name
}
const?hjy?=?new?Person()
console.log('hjy:?',hjy)
console.log('getName:?',hjy.getName())
復制代碼
這是上面這段代碼在chrome控制臺中顯示的結果:

可以看到,我們先是創(chuàng)建了一個空的構造函數Person,然后創(chuàng)建了一個Person的實例hjy,hjy本身是沒有掛載任何屬性和方法的,但是它有一個[[Prototype]]內置屬性,這個屬性是個對象,里面有name、age屬性和getName函數,定睛一看,這玩意兒可不就是上面寫的Person.prototype對象嘛。事實上,Person.prototype和hjy的[[Prototype]]都指向同一個對象,這個對象對于Person構造函數而言叫做原型對象,對于hjy實例而言叫做原型。下面一張圖直觀地展示上述代碼中構造函數、實例、原型之間的關系:

因此,構造函數、原型和實例的關系是這樣的:_每個構造函數都有一個原型對象(實例的原型),原型有一個constructor屬性指回構造函數,而實例有一個內部指針指向原型。_ 在chrome、firefox、safari瀏覽器環(huán)境中這個指針就是__proto__,其他環(huán)境下沒有訪問[[Prototype]]的標準方式。
這其中還有更多細節(jié)建議大家閱讀《javascript高級程序設計》
原型鏈
在上述原型的基礎上,如果hjy的原型是另一個類型的實例呢?于是hjy的原型本身又有一個內部指針指向另一個原型,相應的另一個原型也有一個指針指向另一個構造函數。這樣,實例和原型之間形成了一條長長的鏈條,這就是原型鏈。
所有普通的
[[Prototype]]都會指向內置的Object.prototype,而Object的[[Prototype]]指向null。也就是說所有的普通對象都源于Object.prototype,它包含javascript中許多通用的功能。
在原型鏈中,如果在對象上找不到需要的屬性或者方法,引擎就會繼續(xù)在[[Prototype]]指向的原型上查找,同理,如果在后者也沒有找到需要的東西,引擎就會繼續(xù)查找它的[[Prototype]]指向的原型。上圖理解一下:

理解繼承
繼承是面向對象編程的三大特征之一(封裝、繼承、多態(tài))。多個類中存在相同的屬性和行為時,將這些內容抽取到單獨一個類中,那么多個類無需再定義這些屬性和行為,只需要繼承那個類即可。多個類可以稱為子類,單獨這個類稱為父類或者超類,基類等。子類可以直接訪問父類中的非私有的屬性和行為。
以咱們人類為例,咱全地球人都是一個腦袋、雙手雙腳,很多基本特征都是一樣的。但人類也可以細分種類,有黃種人、白種人、黑種人,咱們如果要定義這三種人,無需再說一個腦袋、雙手雙腳之類的共同特征,黃種人就是在人類的基礎上將皮膚變?yōu)辄S色,白種人皮膚為白色,黑種人為黑色,如果有其他特征就再新增即可,例如藍眼睛、黃頭發(fā)等等。

如果用代碼封裝,咱們就可以將人類定義為基類或者超類,擁有腦袋、手、足等屬性,說話、走路等行為。黃種人、白種人、黑種人為子類,自動復制父類的屬性和行為到自身,然后在此基礎上新增或者重寫某些屬性和行為,例如黃種人擁有黃皮膚、黑頭發(fā)。這就是繼承的思想。
js中的繼承(原型繼承)
在其他面向類語言中,繼承意味著復制操作,子類是實實在在地將父類的屬性和方法復制了過來,但javascript中的繼承不是這樣的。根據原型的特性,js中繼承的本質是一種委托機制,對象可以將需要的屬性和方法委托給原型,需要用的時候就去原型上拿,這樣多個對象就可以共享一個原型上的屬性和方法,這個過程中是沒有復制操作的。
javascript中的繼承主要還是依靠于原型鏈,原型處于原型鏈中時即可以是某個對象的原型也可以是另一個原型的實例,這樣就能形成原型之間的繼承關系。
然而,依托原型鏈的繼承方式是有很多弊病的,我們需要輔以各種操作來消除這些缺點,在這個探索的過程中,出現(xiàn)了很多通過改造原型鏈繼承而實現(xiàn)的繼承方式。
js六種繼承方式
原型鏈繼承
直接利用原型鏈特征實現(xiàn)的繼承,讓構造函數的prototype指向另一個構造函數的實例。
function?Person()?{
??this.head?=?1
??this.hand?=?2
}
function?YellowRace()?{?}
YellowRace.prototype?=?new?Person()
const?hjy?=?new?YellowRace()
console.log(hjy.head)?//?1
console.log(hjy.hand)?//?2
復制代碼
上述代碼中的Person構造函數、YellowRace構造函數、hjy實例之間的關系如下圖:
根據原型鏈的特性,當我們查找hjy實例的head和hand屬性時,由于hjy本身并沒有這兩個屬性,引擎就會去查找hjy的原型,還是沒有,繼續(xù)查找hjy原型的原型,也就是Person原型對象,結果就找到了。就這樣,YellowRace和Person之間通過原型鏈實現(xiàn)了繼承關系。
但這種繼承是有問題的:
創(chuàng)建 hjy實例時不能傳參,也就是YellowRace構造函數本身不接受參數。當原型上的屬性是引用數據類型時,所有實例都會共享這個屬性,即某個實例對這個屬性重寫會影響其他實例。
針對第二點,我們通過一段代碼來看一下:
function?Person()?{
??this.colors?=?['white',?'yellow',?'black']
}
function?YellowRace()?{?}
YellowRace.prototype?=?new?Person()
const?hjy?=?new?YellowRace()
hjy.colors.push('green')
?console.log(hjy.colors)?//?['white',?'yellow',?'black',?'green']
const?laowang?=?new?YellowRace()
console.log(laowang.colors)?//?['white',?'yellow',?'black',?'green']
復制代碼
可以看到,hjy只是想給自己的生活增添一點綠色,但是卻被laowang給享受到了,這肯定不是我們想看到的結果。
為了解決不能傳參以及引用類型屬性共享的問題,一種叫盜用構造函數的實現(xiàn)繼承的技術應運而生。
盜用構造函數
盜用構造函數也叫作“對象偽裝”或者“經典繼承”,原理就是通過在子類中調用父類構造函數實現(xiàn)上下文的綁定。
function?Person(eyes)?{
??this.eyes?=?eyes
??this.colors?=?['white',?'yellow',?'black']
}
function?YellowRace()?{
??Person.call(this,?'black')?//?調用構造函數并傳參
}
const?hjy?=?new?YellowRace()
hjy.colors.push('green')
console.log(hjy.colors)?//?['white',?'yellow',?'black',?'green']
console.log(hjy.eyes)?//?black
const?laowang?=?new?YellowRace()
console.log(laowang.colors)?//?['white',?'yellow',?'black']
console.log(laowang.eyes)?//?black
復制代碼
上述代碼中,YellowRace在內部使用call調用構造函數,這樣在創(chuàng)建YellowRace的實例時,Person就會在YellowRace實例的上下文中執(zhí)行,于是每個YellowRace實例都會擁有自己的colors屬性,而且這個過程是可以傳遞參數的,Person.call()接受的參數最終會賦給YellowRace的實例。它們之間的關系如下圖所示:

雖然盜用構造函數解決了原型鏈繼承的兩大問題,但是它也有自己的缺點:
必須在構造函數中定義方法,通過盜用構造函數繼承的方法本質上都變成了實例自己的方法,不是公共的方法,因此失去了復用性。 子類不能訪問父類原型上定義的方法,因此所有類型只能使用構造函數模式,原因如上圖所示, YellowRace構造函數、hjy和laowang實例都沒有和Person的原型對象產生聯(lián)系。
針對第二點,我們看一段代碼:
function?Person(eyes)?{
??this.eyes?=?eyes
??this.getEyes?=?function?()?{
????return?this.eyes
??}
}
Person.prototype.ReturnEyes?=?function?()?{
??return?this.eyes
}
function?YellowRace()?{
??Person.call(this,?'black')
}
const?hjy?=?new?YellowRace()
console.log(hjy.getEyes())?//?black
console.log(hjy.ReturnEyes())?//?TypeError:?hjy.ReturnEyes?is?not?a?function
復制代碼
可以看到,hjy實例能繼承Person構造函數內部的方法getEyes(),對于Person原型對象上的方法,hjy是訪問不到的。
組合繼承
原型鏈繼承和盜用構造函數繼承都有各自的缺點,而組合繼承綜合了前兩者的優(yōu)點,取其精華去其糟粕,得到一種可以將方法定義在原型上以實現(xiàn)重用又可以讓每個實例擁有自己的屬性的繼承方案。
組合繼承的原理就是先通過盜用構造函數實現(xiàn)上下文綁定和傳參,然后再使用原型鏈繼承的手段將子構造函數的prototype指向父構造函數的實例,代碼如下:
function?Person(eyes)?{
??this.eyes?=?eyes
??this.colors?=?['white',?'yellow',?'black']
}
Person.prototype.getEyes?=?function?()?{
??return?this.eyes
}
function?YellowRace()?{
??Person.call(this,?'black')?//?調用構造函數并傳參
}
YellowRace.prototype?=?new?Person()?//?再次調用構造函數
const?hjy?=?new?YellowRace()
hjy.colors.push('green')
const?laowang?=?new?YellowRace()
console.log(hjy.colors)?//?['white',?'yellow',?'black',?'green']
console.log(laowang.colors)?//?['white',?'yellow',?'black']
console.log(hjy.getEyes())?//?black
復制代碼
hjy終于松了口氣,自己終于能獨享生活的一點“綠”,再也不會被老王分享去了。
此時Person構造函數、YellowRace構造函數、hjy和laowang實例之間的關系如下圖:

相較于盜用構造函數繼承,組合繼承額外的將YellowRace的原型對象(同時也是hjy和laowang實例的原型)指向了Person的原型對象,這樣就集合了原型鏈繼承和盜用構造函數繼承的優(yōu)點。
但組合繼承還是有一個小小的缺點,那就是在實現(xiàn)的過程中調用了兩次Person構造函數,有一定程度上的性能浪費。這個缺點在最后的寄生式組合繼承可以改善。
原型式繼承
2006年,道格拉斯.克羅克福德寫了一篇文章《Javascript中的原型式繼承》。這片文章介紹了一種不涉及嚴格意義上構造函數的繼承方法。他的出發(fā)點是即使不自定義類型也可以通過原型實現(xiàn)對象之間的信息共享。
文章最終給出了一個函數:
const?object?=?function?(o)?{
??function?F()?{?}
??F.prototype?=?o
??return?new?F()
}
復制代碼
其實不難看出,這個函數將原型鏈繼承的核心代碼封裝成了一個函數,但這個函數有了不同的適用場景:如果你有一個已知的對象,想在它的基礎上再創(chuàng)建一個新對象,那么你只需要把已知對象傳給object函數即可。
const?object?=?function?(o)?{
??function?F()?{?}
??F.prototype?=?o
??return?new?F()
}
const?hjy?=?{
??eyes:?'black',
??colors:?['white',?'yellow',?'black']
}
const?laowang?=?object(hjy)
console.log(laowang.eyes)?//?black
console.log(laowang.colors)?//?['white',?'yellow',?'black']
復制代碼
ES5新增了一個方法Object.create()將原型式繼承規(guī)范化了。相比于上述的object()方法,Object.create()可以接受兩個參數,第一個參數是作為新對象原型的對象,第二個參數也是個對象,里面放入需要給新對象增加的屬性(可選)。第二個參數與Object.defineProperties()方法的第二個參數是一樣的,每個新增的屬性都通過自己的屬性描述符來描述,以這種方式添加的屬性會遮蔽原型上的同名屬性。當Object.create()只傳入第一個參數時,功效與上述的object()方法是相同的。
const?hjy?=?{
??eyes:?'black',
??colors:?['white',?'yellow',?'black']
}
const?laowang?=?Object.create(hjy,?{
??name:?{
????value:?'老王',
????writable:?false,
????enumerable:?true,
????configurable:?true
??},
??age:?{
????value:?'32',
????writable:?true,
????enumerable:?true,
????configurable:?false
??}
})
console.log(laowang.eyes)?//?black
console.log(laowang.colors)?//?['white',?'yellow',?'black']
console.log(laowang.name)?//?老王
console.log(laowang.age)?//?32
復制代碼
稍微需要注意的是,object.create()通過第二個參數新增的屬性是直接掛載到新建對象本身,而不是掛載在它的原型上。_原型式繼承非常適合不需要單獨創(chuàng)建構造函數,但仍然需要在對象間共享信息的場合。_
上述代碼中各個對象之間的關系仍然可以用一張圖展示:

這種關系和原型鏈繼承中原型與實例之間的關系基本是一致的,不過上圖中的F構造函數是一個中間函數,在object.create()執(zhí)行完后它就隨著函數作用域一起被回收了。那最后hjy的constructor會指向何處呢?下面分別是瀏覽器和node環(huán)境下的打印結果:


查閱資料得知chrome打印的結果是它內置的,不是javascript語言標準。具體是個啥玩意兒我也不知道了??。
既然原型式繼承和原型鏈繼承的本質基本一致,那么原型式繼承也有一樣的缺點:
不能傳參,使用手寫的 object()不能傳,但使用Object.create()是可以傳參的。原對象中的引用類型的屬性會被新對象共享。
寄生式繼承
寄生式繼承與原型式繼承很接近,它的思想就是在原型式繼承的基礎上以某種方式增強對象,然后返回這個對象。
function?inherit(o)?{
??let?clone?=?Object.create(o)
??clone.sayHi?=?function?()?{?//?增強對象
????console.log('Hi')
??}
??return?clone
}
const?hjy?=?{
??eyes:?'black',
??colors:?['white',?'yellow',?'black']
}
const?laowang?=?inherit(hjy)
console.log(laowang.eyes)?//?black
console.log(laowang.colors)?//?['white',?'yellow',?'black']
laowang.sayHi()?//?Hi
復制代碼
這是一個最簡單的寄生式繼承案例,這個例子基于hjy對象返回了一個新的對象laowang,laowang擁有hjy的所有屬性和方法,還有一個新方法sayHai()。
可能有的小伙伴就會問了,寄生式繼承就只是比原型式繼承多掛載一個方法嗎?這也太low了吧。其實沒那么簡單,這里只是演示一下掛載一個新的方法來增強新對象,但我們還可以用別的方法呀,比如改變原型的constructor指向,在下面的寄生式組合繼承中就會用到。
寄生式組合繼承
寄生式組合繼承通過盜用構造函數繼承屬性,但使用混合式原型鏈繼承方法。基本思路就是使用寄生式繼承來繼承父類的原型對象,然后將返回的新對象賦值給子類的原型對象。
首先實現(xiàn)寄生式繼承的核心邏輯:
function?inherit(Father,?Son)?{
??const?prototype?=?Object.create(Father.prototype)?//?獲取父類原型對象副本
??prototype.constructor?=?Son?//?將獲取的副本的constructor指向子類,以此增強副本原型對象
??Son.prototype?=?prototype?//?將子類的原型對象指向副本原型對象
}
復制代碼
這里沒有將新建的對象返回出來,而是賦值給了子類的原型對象。
接下來就是改造組合式繼承,將第二次調用構造函數的邏輯替換為寄生式繼承:
function?Person(eyes)?{
??this.eyes?=?eyes
??this.colors?=?['white',?'yellow',?'black']
}
Person.prototype.getEyes?=?function?()?{
??return?this.eyes
}
function?YellowRace()?{
??Person.call(this,?'black')?//?調用構造函數并傳參
}
inherit(YellowRace,?Person)?//?寄生式繼承,不用第二次調用構造函數
const?hjy?=?new?YellowRace()
hjy.colors.push('green')
const?laowang?=?new?YellowRace()
console.log(hjy.colors)
console.log(laowang.colors)
console.log(hjy.getEyes())
復制代碼
上述寄生式組合繼承只調用了一次Person造函數,避免了在Person.prototype上面創(chuàng)建不必要、多余的屬性。于此同時,原型鏈依然保持不變,效率非常之高效。
如圖,寄生組合式繼承與組合式繼承中的原型鏈關系是一樣的:

判斷構造函數與實例關系
原型與實例的關系可以用兩種方式來確定:instanceof操作符和isPrototypeOf()方法。
instanceof
instanceof操作符左側是一個普通對象,右側是一個函數。
以o instanceof Foo為例,instanceof關鍵字做的事情是:判斷o的原型鏈上是否有Foo.prototype指向的對象。
function?Perosn(name)?{
??this.name?=?name
}
const?hjy?=?new?Perosn('滑稽鴨')
const?laowang?=?{
??name:?'老王'
}
console.log(hjy?instanceof?Perosn)?//?true
console.log(laowang?instanceof?Perosn)?//?false
復制代碼
根據instanceof的特性,我們可以實現(xiàn)一個自己instanceof,思路就是遞歸獲取左側對象的原型,判斷其是否和右側的原型對象相等,這里使用Object.getPrototypeOf()獲取原型:
const?myInstanceof?=?(left,?right)?=>?{
??//?邊界判斷
??if?(typeof?left?!==?'object'?&&?typeof?left?!==?'function'?||?left?===?null)?return?false
??let?proto?=?Object.getPrototypeOf(left)???//?獲取左側對象的原型
??while?(proto?!==?right.prototype)?{??//?找到了就終止循環(huán)
????if?(proto?===?null)?return?false?????//?找不到返回?false
????proto?=?Object.getPrototypeOf(proto)???//?沿著原型鏈繼續(xù)獲取原型
??}
??return?true
}
復制代碼
isPrototypeOf()
isPrototypeOf()不關心構造函數,它只需要一個可以用來判斷的對象就行。以Foo.prototype.isPrototypeOf(o)為例,isPrototypeOf()做的事情是:判斷在a的原型鏈中是否出現(xiàn)過Foo.prototype。
function?Perosn(name)?{
??this.name?=?name
}
const?hjy?=?new?Perosn('滑稽鴨')
const?laowang?=?{
??name:?'老王'
}
console.log(Perosn.prototype.isPrototypeOf(hjy))
console.log(Perosn.prototype.isPrototypeOf(laowang))
復制代碼
new 關鍵字
在實現(xiàn)各種繼承方式的過程中,經常會用到new關鍵字,那么new關鍵字起到的作用是什么呢?
簡單來說,new關鍵字就是綁定了實例與原型的關系,并且在實例的的上下文中調用構造函數。下面就是一個最簡版的new的實現(xiàn):
const?myNew?=?function?(Fn,?...args)?{
??const?o?=?{}
??o.__proto__?=?Fn.prototype
??Fn.apply(o,?args)
??return?o
}
function?Person(name,?age)?{
??this.name?=?name
??this.age?=?age
??this.getName?=?function?()?{
????return?this.name
??}
}
const?hjy?=?myNew(Person,?'滑稽鴨',?22)
console.log(hjy.name)
console.log(hjy.age)
console.log(hjy.getName())
復制代碼
實際上,真正的new關鍵字會做如下幾件事情:
創(chuàng)建一個細新的 javaScript對象(即 {} )為步驟1新創(chuàng)建的對象添加屬性 proto,將該屬性鏈接至構造函數的原型對象將 this指向這個新對象執(zhí)行構造函數內部的代碼(例如給新對象添加屬性) 如果構造函數返回非空對象,則返回該對象,否則返回剛創(chuàng)建的新對象。
代碼如下:
const?myNew?=?function?(Fn,?...args)?{
??const?o?=?{}
??o.__proto__?=?Fn.prototype
??const?res?=?Fn.apply(o,?args)
??if?(res?&&?typeof?res?===?'object'?||?typeof?res?===?'function')?{
????return?res
??}
??return?o
}
復制代碼
有些小伙伴可能會疑惑最后這個判斷是為了什么?因為語言的標準肯定是嚴格的,需要考慮各種情況下的處理。比如const res = Fn.apply(o, args)這一步,如果構造函數有返回值,并且這個返回值是對象或者函數,那么new的結果就應該取這個返回值,所以才有了這一層判斷。
結語
功力不夠,時間來湊。本人還是一個22屆即將畢業(yè)的非科班本科生,剛開始寫的時候感覺無從下筆,每天只能磨一點點,畫圖也花了不少時間,不過這也是成長的一部分,之前對原型鏈的概念一直模模糊糊,這個寫作探索的過程中對知識的鞏固理解非常有幫助。如果看完此文對你有點幫助,還請手下留贊??,感謝感謝。
關于本文
作者:滑稽鴨
https://juejin.cn/post/7075354546096046087
Node 社群
我組建了一個氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學習感興趣的話(后續(xù)有計劃也可以),我們可以一起進行Node.js相關的交流、學習、共建。下方加 考拉 好友回復「Node」即可。
如果你覺得這篇內容對你有幫助,我想請你幫我2個小忙:
1. 點個「在看」,讓更多人也能看到這篇文章 2. 訂閱官方博客?www.inode.club?讓我們一起成長 點贊和在看就是最大的支持??
