前言
繼上次的《What’s THIS | 淺談Javascript中令人困擾的this》,我們談論到this在Javascript中的運作方式以及該如何判斷他的值。今天我們要來談談什麼是原型鏈,討論在Javascript中如何實現類別與繼承機制、分辨實體繼承關係與原型鏈是否有其終點。
原型鏈是Javascript的核心部分,也是面試時容易出現的題目。老實說,他的內容真的蠻多的,尤其是討論到專有名詞時,你會被那些錯綜複雜的關係給搞得糊裡糊塗。但當你把他的運作原理搞懂後,你會發現一切似乎都變得很明瞭,除了對Javascript又有更深的了解,你的技術也會跟著向上提升。
總之,藉著這次的機會,我會以一些比較簡單的切入點來介紹原型,除此之外也會搭配模型圖以及程式碼幫助讀者學習。
如果你是對Javascript有興趣且想更深入了解物件的創建與繼承如何運作的人,就請跟著我一起看下去吧!
JS和其他物件導向語言的差別
要理解原型鏈,我認為可以先從Javascript的誕生說起。於1994年,一款由網景公司製作的瀏覽器正式問世,但並不具備我們所熟知的使用者互動,完全由背後的伺服器處理資料。所以如果你想知道使用者是否有填寫表單的某一欄,必須要等伺服器拿到資料才能判斷,導致整個過程會變得非常緩慢,因此Javascript就接著誕生了。
因為創造的動機只是為了建立一款增進使用者互動的程式語言,所以Javascript之父 — Brendan Eich 認為不需要設計得太複雜,因而不打算引入「類別」的概念。
好了,看到這裡,你就會知道為什麼Javascript描述物件時那麼方便,但也衍生一個問題:「可是Javascript不是也有new可以用?」。
既然Javascript沒有類別,要如何呈現原型對象呢?
還記得Java是怎麼建立實體的嗎?就是使用new時會去呼叫該類別的constructor(構建函式),如下圖:
剛好Javascript最會的就是寫函式了,所以在Javascript中就是直接在new後面接上構建函式而不會是類別。因此就出現了我們熟知的程式碼:
單看呼叫的那一行,你就會發現其實跟Java有八成像,Point2D就是一個構建函式,透過關鍵字new呼叫構建函式來生出一個實體。
總而言之,Javascript並不像其他物件導向的程式語言,他不具有類別,即便在ES6出現class的關鍵字,那也是語法糖而已。可是即便沒有引入類別,他仍可設計類似的機制來達到相同效果,並兼顧到簡單上手的這項特質。
什麼是原型?
除了基礎的屬性以外,我們也可以幫構建函式加入一些方法,像是:
然而,這樣做其實是有問題的。因為Javascript並沒有直接引入類別,所以當我們設定this.toString
時,其實就是直接配置一個空間給toString給每個實體。
可是toString這個method在每個Point2D實體都是做一樣的事情,但在這種情況下反而佔了兩個空間,導致出現兩個不同的函式。
以下檢測兩個實體的toString是否相同:
OK,印證設置在構建函式中的函式屬性並不會被分享,而是為每個實體配置一個空間給這個函式。
那我們該如何讓不同的實體彼此分享同一個函式呢?這時候就是我們今天的主角登場拉!
原型Prototype
每個構建函式都會有他對應的一個模型,我們把他稱做「原型」。接續上面的議題,我們可以透過新增一個屬性在該構建函式的prototype中,使得該屬性得以被所有以這個原型建構出來的實體分享。
把上面的範例修改一下:
如此一來我們就只需要花一個空間就可以使所有Point2D實體都能夠使用toString這個method。
請不要主動修改原生原型
有時你可能會覺得原生的功能不敷使用,想要自己加一些功能上去,像是:
雖然並不會直接修改到瀏覽器本身,只會對當前的運行環境產生效果,但事實上不推薦這麼做。
除非你能夠清楚地寫相關的文件與測試程式,否則你永遠不會知道修改後會影發什麼後果。
小結
- Javascript沒有類別,也沒有建構子。創建物件靠的是函式
- 只要在new之後的函式就是構建函式
- 在原型中加入屬性可使實體共用該屬性
- 非必要請不要更改原生原型
原型鏈如何建構?
你有沒有想過,當我呼叫原型中的屬性,像是上面的toString,Javascript是怎麼幫我找到這個函式的?
從直觀上來看,因為point1是Point2D的實體,所以當他從point1身上找不到toString,他就會跑去Point2D.prototype
找看看有沒有。
但是!身為工程師,絕不接受直覺式思考,一定有什麼機制引導Javascript往prototype找,所以就讓我們探索背後到底是怎麼回事。
我們先把point1印出來看一下:
除了前面的x跟y是我們一開始賦予point1的屬性,最後還有一個叫做<prototype>
的屬性。
如果我們再把<prototype>
展開的話:
我們就會發現其實<prototype>
就是Point2D.prototype
(這邊就不放測試圖了,讀者可以自行做測試)。也就是說,point1會透過<prototype>
連接到Point2D.prototype
上,所以呼叫toString時才有辦法往上搜尋。
<prototype>
的正式屬性名稱應該是__proto__
,但MDN建議大家應該要使用Object.getPrototypeOf()
去存取這個物件,而不要使用__proto__
。不過為了待會方便介紹,這裡還是用__proto__
來稱呼(抱歉了MDN)。
如果你有搞懂剛剛我說了什麼,加上你有認真看我PO的圖片,應該會發現Point2D.prototype
也有<prototype>
(以下稱__proto__
)。
這裡先給讀者一個觀念:
__proto__指向的必定是一個prototype
所以根據這句話,我們可以知道Point2D.prototype
的__proto__
會指向某一個prototype(好像繞口令……)。
因此,如果我們今天呼叫某個函式,而且在Point2D.prototype
也找不到的話,Javascript就會繼續透過__proto__
去找,直到某個東西的__proto__
是null為止,意即最上層。
因此,串接一連串的__proto__
形成的鏈就叫做「原型鏈」。雖然Javascript本身並不支援類別,但透過原型鏈可以達到像是「繼承」的效果,可使用上層的method。
那麼誰是這個所謂的最上層呢?我們可以透過下面的程式幫我們找到:
由此可知Object就是原型鏈的頂點,因為他的__proto__
指向的是null。
小結
__proto__
會指向某個東西的prototype,以利Javascript向上查找屬性Object.prototype
是原型鏈的頂點- 原型鏈可達成繼承的效果,但不等於Javascript具有類別
常見問題
到目前為止,我們談到Javascript的「類別」是以原型(prototype)的方式呈現,以及透過原型鏈來達到類似繼承的功能。
相信做到這裡,讀者對原型也有一定的了解了,在正式進入常見QA前,先來考考你一題:
要公布答案囉~
…
…
第一個輸出是Function.prototype
,第二個則是Object.prototype
。
其實不難理解,因為Point2D本身就是一個function實體,所以他的__proto__
一定會指向Function.prototype
。至於第二個輸出,因為即便是function,他仍然是一個物件,所以任何原生型別或是函式都是繼承於Object.prototype
,因此答案才會是這樣。
好的,那我們廢話不多說,趕快來看幾個常見的問題!
Q1: 我要怎麼知道屬性是否為當前物件的?
當我們需要檢查屬性是否屬於當前的物件時,我們可以使用hasOwnProperty
來檢驗。
x存在於物件point中,而z不存在於任何地方。至於type則是存在於原型鏈中但不屬於point,所以回傳false。
如果我們想檢查這個屬性是否存在於原型鏈中,我們可以使用prop in obj
來檢查:
如果想知道更多如何檢視屬性的作法,可以參考這裡
所以假設屬性存在的情況下,我們可以同時使用hasOwnProperty
與in
,只要前者為false後者為true,則代表屬性是存在於原型鏈中而非物件本身。
當然,我們也可以自己寫一個function來模擬:
Q2: 我是誰的實體?
如果我們想檢查物件是否為指定的構建函式所涉及的實體,可以使用instanceof
做檢測。位於左方的運算元是被檢測的物件,而右方則是指定的構建函式。
若左邊的物件是由右邊的構建函式所生成的話,會回傳true,反之則回傳false。instanceof
厲害的地方是,他會往上搜查原型鏈,只要找得到指定的構建函式,就算找到。
舉個例子:
因為john是由Person建構出來的實體,而Person的上層是Object,所以前面兩行都是true。而因為整條原型鏈中不存在String.prototype
,所以回傳就會是false。
前面說過Object是所有原型鏈的終點,但你有沒有試著這麼做:
第一行是我們的已知,但第二行呢?答案是true,這又是為什麼?
這裡畫一張圖會比較好理解:
Q3: 創建實體的時候,在構建函式裡的this是怎麼設定的?
上次我們談到this的時候,最一開始給一張圖,裡面用ES6的語法寫了一個類別(當然ES6的class只是語法糖)。重點是,當我呼叫構建函式幫我新建一個物件時,為什麼this就會代表這個全新的物件呢?
其實一切都要從new背後做的事情說起。
我們拿前面的Point2D來說,假設現在我們打一行程式碼:var point = new Point(20, 30);
,那Javascript會做以下的幾個步驟:
- 創建新的物件,先叫他O
- 將O的
__proto__
指向Point2D.prototype
,讓他接上原型鏈 - 以O為執行環境,去呼叫Point2D的構建函式(使用apply)
- 回傳O
只用說的你可能不懂,我們直接上程式碼:
Q4: 那我要怎麼實作繼承?
前面說過Javascript並沒有類別,也就代表不會有一般我們熟知的繼承,但因為需要連結物件,所以選擇以原型鏈來達到繼承的效果。
有寫過C++或是Java的人應該對繼承的作法不陌生,其實Javascript的寫法也有點類似,但多了一些步驟,畢竟要把原型鏈串起來本身就不是一般的繼承會做的事。
那我們來實作一下該怎麼繼承已經存在的原型鏈吧!
別懷疑真的就是這樣,而且我知道又多了一個你可能沒看過的函式,讓我解釋給你聽。
Object.create
裡面會接一個物件,而這個函式會回傳一個物件,我們叫他O。基本上O會繼承前面那個物件的所有屬性,並且將自己的__proto__
指向該物件。
因此,上面的程式碼就會變成:
- 新建一個物件O,並複製
Parent.prototype
的屬性 - 將O的
__proto__
指向Parent.prototype
- 設定
Child.prototype
為O
另外,圖片中值得注意的是:第二行不是必要的,但盡量要寫。
因為事實上如果你不寫,輸出Child.prototype.constructor
就會變成Parent,這樣其實並不合理,畢竟一個「類別」的構建函式名稱應該要跟自己的名稱一樣才對。因此,在實作上會建議要記得加上這行。
p.s. 在ES6中,使用extends關鍵字時會幫你打理好一切,所以不需要自己寫那一段。(偉哉語法糖!)
那在最後,我們就運用自己寫的function來實現繼承吧:
整個原型鏈會長這樣:
總結
經過今天的磨練,我們有以下的收穫:
- Javascript沒有類別,亦沒有建構子,有的只是函式
- 一旦我們對函式使用new,該函式就變成構建函式
- 任何物件的
__proto__
必定指向某個東西的prototype Object.prototype
是所有原型鏈的終點- Javascript的繼承是透過原型鏈的串接實現
- 可透過
Object.create()
把兩個物件連接起來
寫這篇的時候,我才了解原來「構建函式」中的this是從原型鏈的概念來的。老實說,每次增長新知識的時候,會有種成就感,讓人覺得探究原理真的很有趣(前提是聽得懂的話XD)。
我把一些我看過的資料附在下面了,有興趣的讀者可以點進去看,雖然內容應該不會差太多,但可以當作複習一下自己的觀念。
總之,如果這篇對你有幫助,還請分享給其他人喔!
沒意外,我想下次我想做跟Hoist相關的介紹,那我們就下次見囉!!