一篇文章讓你搞清楚 JavaScript 繼承的本質、prototype
、__proto__
、constructor
都是什麼。
很多小夥伴表示不明白 JavaScript 的繼承,說是原型鏈,看起來又像類,究竟是原型還是類?各種 prototype
、__proto__
、constructor
內部變數更是傻傻搞不清楚。其實,只要明白繼承的本質就很能理解,繼承是為了程式碼複用。複用並不一定得通過類,JS 就採用了一種輕量簡明的原型方案來實現。Java/C++ 等強型別語言中有類和物件的區別,但 JS 只有物件。它的原型也是物件。只要你完全拋開物件導向的繼承思路來看 JS 的原型繼承,你會發現它輕便但強大。
原文連結:blog.linesh.tw/#/post/2018…
Github:github.com/linesh-simp…
目錄
- 繼承方案的設計要求
- 被複用的物件:
prototype
- 優雅的 API:ES6
class
- 簡明的向上查詢機制:
__proto__
- 建構函式又是個啥玩意兒
- 雙鏈合璧:終極全圖
- 總結
- 參考
繼承方案的設計要求
前面我們講,繼承的本質是為了更好地實現程式碼複用。再仔細思考,可以發現,這裡的「程式碼」指的一定是「資料+行為」的複用,也就是把一組資料和資料相關的行為進行封裝。為什麼呢?因為,如果只是複用行為,那麼使用函式就足夠了;而如果只是複用資料,這使用 JavaScript 物件就可以了:
const parent = {
some: 'data',
}
const child = {
...parent,
uniq: 'data',
}
複製程式碼
因此,只有資料+行為(已經類似於一個「物件」的概念)的封裝,才是繼承技術所必須出現的地方。為了滿足這樣的程式碼複用,一個繼承體系的設計需要支援什麼需求呢?
- 儲存公用的資料和函式
- 覆蓋被繼承物件資料或函式的能力
- 向上查詢/呼叫被繼承物件函式的資料或函式的能力
- 優雅的語法(API)
- 增加新成員的能力
- 支援私有資料
「支援私有資料」,這個基本所有方案都沒實現,此階段我們可以不用糾結;而「增加新成員的能力」,基本所有的方案都能做到,也不再贅述,主要來看前四點。
被複用的物件:prototype
JavaScript 的繼承有多種實現方式,具體有哪些,推薦讀者可閱讀:JavaScript 語言精粹一書 和 這篇文章。這裡,我們直接看一版比較優秀的實現:
function Animal(name) {
this.name = name
this.getName = function() {
return this.name
}
}
function Cat(name, age) {
Animal.call(this, name)
this.age = age || 1
this.meow = function() {
return `${this.getName()}eowww~~~~~, I'm ${this.age} year(s) old`
}
}
const cat = new Cat('Lily', 2)
console.log(cat.meow()) // 'Lilyeowww~~~~~, I'm 2 year(s) old'
複製程式碼
這個方案,具備增添新成員的能力、呼叫被繼承物件函式的能力等。一個比較重大的缺陷是:物件的所有方法 getName
meow
,都會隨每個例項生成一份新的拷貝。這顯然不是優秀的設計方案,我們期望的結果是,繼承自同一物件的子物件,其所有的方法都共享自同一個函式例項。
怎麼辦呢?想法也很簡單,就是把它們放到同一個地方去,並且還要跟這個「物件」關聯起來。如此一想,用來生成這個「物件」的函式本身就是很好的地方。我們可以把它放在函式的任一一個變數上,比如:
Animal.functions.getName = function() {
return this.name
}
Cat.functions.meow = function() {
return `${this.getName()}eowww~~~~~, I'm ${this.age} year(s) old`
}
複製程式碼
但這樣呼叫起來,你就要寫 animal.functions.getName()
,並不方便。不要怕,JavaScript 這門語言本身已經幫你內建了這樣的支援。它內部所用來儲存公共函式的變數,就是你熟知的 prototype
。當你呼叫物件上的方法時(如 cat.getName()
),它會自動去 Cat.prototype
上去幫你找 getName
函式,而你只需要寫 cat.getName()
即可。兼具了功能的實現和語法的優雅。
最後寫出來的程式碼會是這樣:
function Animal(name) {
this.name = name
}
Animal.prototype.getName = function() {
return this.name
}
function Cat(name, age) {
Animal.call(this, name)
this.age = age || 1
}
Cat.prototype = Object.create(Animal.prototype, { constructor: Cat })
Cat.prototype.meow = function() {
return `${this.getName()}eowww~~~~~, I'm ${this.age} year(s) old`
}
複製程式碼
請注意,只有函式才有 prototype
屬性,它是用來做原型繼承的必需品。
優雅的 API:ES6 class
然鵝,上面這個寫法仍然並不優雅。在何處呢?一個是 prototype
這種暴露語言實現機制的關鍵詞;一個是要命的是,這個函式內部的 this
,依靠的是作為使用者的你記得使用 new
操作符去呼叫它才能得到正確的初始化。但是這裡沒有任何線索告訴你,應該使用 new
去呼叫這個函式,一旦你忘記了,也不會有任何編譯期和執行期的錯誤資訊。這樣的語言特性,與其說是一個「繼承方案」,不如說是一個 bug,一個不應出現的設計失誤。
而這兩個問題,在 ES6 提供的 class
關鍵詞下,已經得到了非常妥善的解決,儘管它叫一個 class,但本質上其實是通過 prototype 實現的:
class Animal {
constructor(name) {
this.name = name
}
getName() {
return this.name
}
}
class Cat extends Animal {
constructor(name, age) {
super(name)
this.age = age || 1
}
meow() {
return `${this.getName()}eowww~~~~~, I'm ${this.age} year(s) old`
}
}
複製程式碼
- 如果你沒有使用
new
操作符,編譯器和執行時都會直接報錯。為什麼呢,我們將在下一篇文章講解 extends
關鍵字,會使直譯器直接在底下完成基於原型的繼承功能
現在,我們已經看到了一套比較完美的繼承 API,也看到其底下使用 prototype
儲存公共變數的地點和原理。接下來,我們要解決另外一個問題:prototype
有了,例項物件應該如何訪問到它呢?這就關係到 JavaScript 的向上查詢機制了。
簡明的向上查詢機制:__proto__
function Animal(name) {
this.name = name
}
Animal.prototype.say = function() {
return this.name
}
const cat = new Animal('kitty')
console.log(cat) // Animal { name: 'kitty' }
cat.hasOwnProperty('say') // false
複製程式碼
看上面 ? 一個最簡單的例子。打出來的 cat
物件本身並沒有 say
方法。那麼,被例項化的 cat
物件本身,是怎樣向上查詢到 Animal.prototype
上的 say
方法的呢?如果你是 JavaScript 引擎的設計者,你會怎樣來實現呢?
我拍腦袋這麼一想,有幾種方案:
- 在
Animal
中初始化例項物件cat
時,順便存取一個指向Animal.prototype
的引用 - 在
Animal
中初始化例項物件時,記錄其「型別」(也即是Animal
)
// 方案1
function Animal(name) {
this.name = name
// 以下程式碼由引擎自動加入
this.__prototype__ = Animal.prototype
}
const cat = new Animal('kitty')
cat.say() // -> cat.__prototype__.say()
// 方案2
function Animal(name) {
this.name = name
// 以下程式碼由引擎自動加入
this.__type__ = Animal
}
const cat = new Animal('kitty')
cat.say() // -> cat.__type__.prototype.say()
複製程式碼
究其實質,其實就是:例項物件需要一個指向其函式的引用(變數),以拿到這個公共原型 prototype
來實現繼承方案的向上查詢能力。讀者如果有其他方案,不妨留言討論。
無獨有偶,這兩種方案,在 JavaScript 中都有實現,只不過變數的命名與我們的取法有所差異:第一種方案中,實際的變數名叫 __proto__
而不是 __prototype__
;第二種方案中,實際的變數名叫 constructor
,不叫俗氣的 __type__
。實際上,用來實現繼承、做向上查詢的這個引用,正是 __proto__
;至於 constructor,則另有他用。不過要注意的是,[儘管基本所有瀏覽器都支援 __proto__
][mdn __proto__
],它並不是規範的一部分,因此並不推薦在你的業務程式碼中直接使用 __proto__
這個變數。
從上圖可以清楚看到,prototype
是用來儲存型別公共方法的一個物件(正因此每個型別有它基本的方法),而 __proto__
是用來實現向上查詢的一個引用。任何物件都會有 __proto__
。Object.prototype
的 __proto__
是 null,也即是原型鏈的終點。
建構函式又是個啥玩意兒?
再加入 constructor 這個東西,它與 prototype
、__proto__
是什麼關係?這個地方,說複雜就很複雜了,讓我們儘量把它說簡單一些。開始之前,我們需要查閱一下[語言規範][ecmascript 2015(es6) specification],看一些基本的定義:
- 物件:物件是一組集合,其中可包含零個或多個屬性。物件都有一個原型物件(譯者注:即 [[Prototype]]/
__proto__
) - 函式:是物件型別的一員
- 建構函式:建構函式是個用於建立物件的函式物件。每個建構函式都有一個
prototype
物件,用以實現原型式繼承,作屬性共享用
這裡說明了什麼呢?說明了建構函式是函式,它比普通函式多一個 prototype
屬性;而函式是物件,物件都有一個原型物件 __proto__
。這個東西有什麼作用呢?
上節我們深挖了用於繼承的原型鏈,它連結的是原型物件。而物件是通過建構函式生成的,也就是說,普通物件、原型物件、函式物件都將有它們的建構函式,這將為我們引出另一條鏈——
在 JavaScript 中,誰是誰的建構函式,是通過 constructor
來標識的。正常來講,普通物件(如圖中的 cat
和 { name: 'Lin' }
物件)是沒有 constructor
屬性的,它是從原型上繼承而來;而圖中粉紅色的部分即是函式物件(如 Cat
Animal
Object
等),它們的原型物件是 Function.prototype
,這沒毛病。關鍵是,它們是函式物件,物件就有建構函式,那麼函式的建構函式是啥呢?是 Function
。那麼問題又來了,Function
也是函式,它的建構函式是誰呢?是它自己:Function.constructor === Function
。由此,Function
即是建構函式鏈的終結。
上面我們提到,constructor
也可以用來實現原型鏈的向上查詢,然後它卻別有他用。有個啥用呢?一般認為,它是用以支撐 instanceof
關鍵字實現的資料結構。
雙鏈合璧:終極全圖
好了,是時候進入最燒腦的部分了。前面我們講了兩條鏈:
- 原型鏈。它用來實現原型繼承,最上層是
Object.prototype
,終結於null
,沒有迴圈 - 建構函式鏈。它用來表明構造關係,最上層迴圈終結於
Function
把這兩條鏈結合到一起,你就會看到一條雙螺旋 DNA這幾張你經常看到卻又看不懂的圖:
圖都是引用自其它文章,點選圖片可跳轉到原文。其中,第一篇文章 [一張圖理解 JS 的原型][] 是我見過解析得最詳細的,本文的很多靈感也來自這篇文章。
理解了上面兩條鏈以後,這兩個全圖實際上就不難理解了。分享一下,怎麼來讀懂這個圖:
- 首先看建構函式鏈。所有的普通物件,
constructor
都會指向它們的建構函式;而建構函式也是物件,它們最終會一級一級上溯到Function
這個建構函式。Function
的建構函式是它自己,也即此鏈的終結; Function
的prototype
是Function.prototype
,它是個普通的原型物件;- 其次看原型鏈。所有的普通物件,
__proto__
都會指向其建構函式的原型物件[Class].prototype
;而所有原型物件,包括建構函式鏈的終點Function.prototype
,都會最終上溯到Object.prototype
,終結於 null。
也即是說,建構函式鏈的終點 Function
,其原型又融入到了原型鏈中:Function.prototype -> Object.prototype -> null
,最終抵達原型鏈的終點 null
。至此這兩條契合到了一起。
總結
講到這裡,我想關於 JavaScript 繼承中的一些基本問題可以解釋清楚了:
JavaScript 繼承是類繼承還是原型繼承?不是使用了 new 關鍵字麼,應該跟類有關係吧?
是完全的原型繼承。儘管用了 new
關鍵字,但其實只是個語法糖,跟類沒有關係。JavaScript 沒有類。它與類繼承完全不同,只是長得像。好比雷鋒和雷峰塔的關係。
prototype
是什麼東西?用來幹啥?
prototype
是個物件,只有函式上有。它是用來儲存物件的屬性(資料和方法)的地方,是實現 JavaScript 原型繼承的基礎。
__proto__
是什麼東西?用來幹啥?
__proto__
是個指向 prototype
的引用。用以輔助原型繼承中向上查詢的實現。雖然它得到了所有瀏覽器的支援,但並不是規範所推薦的做法。嚴謹地說,它是一個指向 [[Prototype]]
的引用。
constructor
是什麼東西?用來幹啥?
是物件上一個指向建構函式的引用。用來輔助 instanceof
等關鍵字的實現。
參考
- 一張圖理解 JS 的原型
- Prototypal Inheritance in JavaScript
- How Prototypal Inheritance really works
- ECMAScript 2015(ES6) Specification
- 從proto和 prototype 來深入理解 JS 物件和原型鏈
- JavaScript 深入之繼承的多種方法
- MDN: Inheritance in JavaScript
- MDN: Inheritance and the prototype chain
- MDN: Details of the object model
- MDN:
__proto__