你不可不知的原型鏈Prototype | JavaScript鍛鍊日記

CHC1024
狗奴工程師
Published in
13 min readMar 31, 2020

前言

繼上次的《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來檢查:

如果想知道更多如何檢視屬性的作法,可以參考這裡

所以假設屬性存在的情況下,我們可以同時使用hasOwnPropertyin,只要前者為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會做以下的幾個步驟:

  1. 創建新的物件,先叫他O
  2. 將O的__proto__指向Point2D.prototype,讓他接上原型鏈
  3. 以O為執行環境,去呼叫Point2D的構建函式(使用apply)
  4. 回傳O

只用說的你可能不懂,我們直接上程式碼:

Q4: 那我要怎麼實作繼承?

前面說過Javascript並沒有類別,也就代表不會有一般我們熟知的繼承,但因為需要連結物件,所以選擇以原型鏈來達到繼承的效果。

有寫過C++或是Java的人應該對繼承的作法不陌生,其實Javascript的寫法也有點類似,但多了一些步驟,畢竟要把原型鏈串起來本身就不是一般的繼承會做的事。

那我們來實作一下該怎麼繼承已經存在的原型鏈吧!

別懷疑真的就是這樣,而且我知道又多了一個你可能沒看過的函式,讓我解釋給你聽。

Object.create裡面會接一個物件,而這個函式會回傳一個物件,我們叫他O。基本上O會繼承前面那個物件的所有屬性,並且將自己的__proto__指向該物件。

因此,上面的程式碼就會變成:

  1. 新建一個物件O,並複製Parent.prototype的屬性
  2. 將O的__proto__指向Parent.prototype
  3. 設定Child.prototype為O

另外,圖片中值得注意的是:第二行不是必要的,但盡量要寫。

因為事實上如果你不寫,輸出Child.prototype.constructor就會變成Parent,這樣其實並不合理,畢竟一個「類別」的構建函式名稱應該要跟自己的名稱一樣才對。因此,在實作上會建議要記得加上這行。

p.s. 在ES6中,使用extends關鍵字時會幫你打理好一切,所以不需要自己寫那一段。(偉哉語法糖!)

那在最後,我們就運用自己寫的function來實現繼承吧:

整個原型鏈會長這樣:

總結

經過今天的磨練,我們有以下的收穫:

  1. Javascript沒有類別,亦沒有建構子,有的只是函式
  2. 一旦我們對函式使用new,該函式就變成構建函式
  3. 任何物件的__proto__必定指向某個東西的prototype
  4. Object.prototype是所有原型鏈的終點
  5. Javascript的繼承是透過原型鏈的串接實現
  6. 可透過Object.create()把兩個物件連接起來

寫這篇的時候,我才了解原來「構建函式」中的this是從原型鏈的概念來的。老實說,每次增長新知識的時候,會有種成就感,讓人覺得探究原理真的很有趣(前提是聽得懂的話XD)。

我把一些我看過的資料附在下面了,有興趣的讀者可以點進去看,雖然內容應該不會差太多,但可以當作複習一下自己的觀念。

總之,如果這篇對你有幫助,還請分享給其他人喔!

沒意外,我想下次我想做跟Hoist相關的介紹,那我們就下次見囉!!

參考資料

  1. Javascript繼承機制的設計思想
  2. 你懂 JavaScript 嗎?#19 原型(Prototype)
  3. MDN — Javascript中的「繼承」
  4. 該來理解 JavaScript 的原型鍊了

--

--