前言
若要介紹原型鏈,我們可以先從繼承說起,Java、C# 等物件導向語言擁有類別繼承的概念,也就是 Class,它就像一張藍圖,可以在內部定義之後透過 new 關鍵字建立物件。
而 JavaScript 在 ES6 後也有 Class ,但這並不是真的 Class,因為 JavaScript 的繼承是基於原型的方式來達成,物件直接從其他物件繼承,以 __proto__ 來連接物件之間彼此的關係,稱之為原型鏈(prototype chain)。
此篇文章只會說明原型鏈基本應該理解的東西,使用原型所需注意的細節真的很多,篇幅有限。
優點
比起類別繼承,原型繼承的方式具有彈性、可擴充性,且懂得如何利用原型能讓 JavaScript 更有效率地執行,並避開一些雷點,理解比較厲害的大大寫的程式碼(有些 LeetCode 的實作題目也有用到原型的方式)。
實作及圖解原型鏈(prototype chain)
首先,我們先來寫一段程式碼,假設有一個 User,名字為 noah,今年 20 歲:
上面以函式建構式 new 出一個 noah 物件,並傳入了 name、age 參數,不過這並沒有什麼特別的,於是接著加入以下程式碼:
你會發現很神奇,noah 明明沒有 getInfo 方法卻可以呼叫!?因為這就是原型鏈的概念:
- 當 JavaScript 發現該物件並沒有你所找尋的屬性或方法時,它就會往該物件的原型找,若原型沒有,再往原型的原型找,直到 null。
- 物件的 __proto__ 會連結到原型物件的 prototype。
- prototype 包含原型物件的構造函式(constructor)、你所設立的屬性或方法,以及連結的 __proto__。
因此,JavaScript 發現 noah 物件沒有 getInfo( ),開始往 noah 的原型找,發現 User 裡面有 getInfo( ) 並回傳給你使用,如下圖。
若要觀察物件之間的關係可以用 __proto__ 或 Object.getPrototypeOf( ) 測試,以下結果證明 noah 確實連結到 User.prototype:
這樣你能了解平常在處理陣列時為什麼可以使用 .forEach()、.reverse(),而字串則能夠 .concat()、.splice() !因為當 JavaScript 找不到屬性或方法就會往原型找,而這些方法都已經被寫好在原型了。
那將方法設置在 Prototype 跟直接在 User 設置有什麼不同!啊不是都可以用?
一、在函式建構式設置
每個被建立的實體物件都會有相同的方法,浪費了記憶體空間。
二、在原型物件的 Prototype 上設置
每個被建立的實體物件都能共享該方法。
因此,理論上應盡量把實體物件常使用到的屬性或方法放在原型物件的 Prototype 上,藉此節省記憶體空間。
Object.create
剛剛都是以「函式建構式加上 new 關鍵字」來建立物件及連結原型物件,而 Object.create() 為第二種方法。
Object.create(proto [, propertiesObject])
該方法為「替一個新物件指定它所參考的原型物件」。但這種說法我認為並不好理解,因為其 __proto__ 的指向與函式建構式的不同:
- Object.create( ) 建立的物件,其 __proto__ 是指向參考的物件
- 函式建構式 new 建立的物件,其 __proto__ 是指向函式的 prototype
啊~先不要搞得那麼複雜,只要記住「Object.create( ) 所丟進去的物件參數等同於第一種方法的 .prototype 的屬性及方法 」就好,看程式碼會比較好理解:
[補充]:看完之後,你是否能理解 MDN 上這個複雜例子了呢~?此篇還沒有探討到 Object.create( ) 的繼承,未來如果有機會的話會再探討。
Object.create(null)
另外,Object.create(null) 可以建立一個完全沒有參考原型的空物件,這麼做的主要原因通常是我們需要一個非常乾淨的物件環境,避免有時候命名相撞導致原型被污染。
在上述程式碼中,即使將函式建構式指定為空物件,還是無法創造一個乾淨的物件;但是 dict2 真的就是空物件,這表示你甚至不能在 dict2 使用 Object 最基本的 toString() 與 ValueOf()。
所以,dict2 放的東西不用怕跟任何 Object 相撞,而且要客製化該物件的彈性程度比平常高出很多。
其它使用原型鏈事項
一、Prototype 應該儲存什麼
前面有提到將東西放在原型物件上能有效減少記憶體空間,並具有差不多快的效能(Maybe),但不是所有東西都要往原型物件塞。
在 Effective JavaScript 書中提及通常放到原型物件大多都是「方法」,若「屬性」要放到原型物件,可以先確認以下事項,否則,建議儲存到實體物件上。
屬性是真的需要被共用的資料;或者這個屬性的值基本上不會變動(類似 const)。
二、陣列避免使用 for-in 遍歷
for in 是以 key-value 結構的方式進行遍歷,但是這會遍歷到陣列其原型物件 prototype 的東西。
三、不要隨意修改 __proto__
- 這與效能有關,JavaScript 引擎有針對一些物件特性的操作做效能上的優化,隨意更動結構可能會破壞設定。
- 隨意改變物件的原型可能導致你無法預測接下來會出現什麼錯誤。
- 若要自訂物件之間的連結建議使用 Object.create(),避免用 __proto__。
補充:那麼 ES6 Class 是什麼?
- 其他物件導向語言的 Class 並不是物件,是一種藍圖,直到真的 new 之前不會得到有物件產生。
- ES6 的 Class 就是一個物件,然後你以該物件作為新物件連接的原型。
- 為一種語法糖衣(syntactic sugar),因為從上述說明可得知原理都不變,皆屬於原型繼承的概念。
- 以 class 宣告的物件沒有 new 關鍵字直接回報錯誤,避免忘記 new 而造成隱藏的 bug。
總結
這次對基本的原型鏈介紹也讓我更明白裡面的運作,其實有時看一看以為自己懂了,但有時一個瞬間又被搞混了,建議可以畫出原型鏈的概念圖,會讓自己對於細節清晰許多。
而本篇所運用的例子其實還沒介紹到對於 Prototype 與 Prototype 之間的繼承,以及一些操作原型鏈的 method、polyfill 概念…等,有時間的話會再補上一篇。
參考資料
- Herman, D. (2013). Effective JavaScript: 68 Specific Ways to Harness the Power of JavaScript.
- JavaScript: Understanding the Weird Parts.
以上為筆者自己對於原型鏈的理解及整理,若有錯誤或問題可以在留言區幫忙糾正,感謝 😃