原型繼承與原型鏈
在這個單元裡,我們會來認識「原型繼承」的觀念,你會碰到一系列看似深奧的專有名詞,如「原型 (prototype) 」和「原型鏈 (prototype chain) 」,但別忘記,我們的目的是「讓一個物件取得另一個物件的屬性與方法」。
先定義一下「繼承 (inheritance)」和「原型繼承 (prototypal inheritance)」兩個術語::
- 在 JavaScript 裡,「繼承」的定義是「讓一個物件取得另一個物件的屬性與方法」;
- JavaScript 的繼承系統會透過原型鍊 (prototype chain) 來連接到其他物件的功能,與「classical inheritance」的系統略有不同 (e.g. 沒有拷貝程式碼),因此稱為「原型繼承」。
淺談 JavaScript 的原型繼𠄘
讓我們透過以下例子來展示「繼承」的概念,也就是「一個物件如何取得另一個物件的屬性與方法」。說明中會有一些陌生的術語,之後會慢慢介紹,請你先留意整體的觀念。
首先讓我們建立一個字串來當成例子:
const name = 'AlphaPhoneX'
console.log(name) // "AlphaPhoneX"
做為一個基本型別,name
本身並不具備屬性和方法,然而,字串 name
的原型來自建構子 String(),你可以用 constructor.name
來查看:
const name = 'AlphaPhoneX'
console.log(name.constructor.name) // "String"
我們確認了變數 name
是透過建構式函式 String()
產生的實例。此時,原型鏈可以呈現如下:
透過原型鏈,字串 name
可以取用 String.prototype 裡定義好的方法,因此你可以使用 .split()
, .slice()
, .toLowerCase()
等字串方法:
name.toLowerCase() // "alphaphonex"
name.slice(0, 5) // "Alpha"
name.split('a') // ["Alph", "PhoneX"]
這就是 JavaScript 裡的原型繼承,雖然有一些陌生的術語,但核心目的是「取得另一個物件的屬性與方法」,如此一來,你才能應用物件導向程式設計,把不同物件間共用的屬性與方法封裝到上層的範本裡。
什麼是原型?
每一個透過建構式函式產生的物件,都會攜帶一個原型物件,你可以把它想像成一個工具包,只要加入到這個工具包裡的屬性與方法,就能透過原型鏈來共享。
請在 console 裡使用 name.
時(打到 .
就好),這時候瀏覽器會很貼心的幫我們列出所有存在 name
本身的屬性或方法:
這裡看到的清單,是 name
的原型物件裡所有可用的屬性與方法,當你呼叫這些方法時,JavaScript 會透過原型鏈查找到可用的函式。
在原型鏈中尋找屬性與方法
接著讓我們看看呼叫方法時,實際的過程。例如當我們調用 name.toLowerCase()
時,JavaScript 會發生以下程式:
- 查看
name
本身是否有toLowerCase
方法,然後發現name
沒有這個方法; - 到 「
name
的原型」去找,也就是 String.protype,它會在這裡成功找到toLowerCase
。 - 根據 String.prototype.toLowerCase 裡的函式設計,執行
name.toLowerCase()
。
如果你調用一個不存在的方法如 name.abc()
,它會一直向上找到 null
為止,才會確定 name
沒有這個方法而回傳以下錯誤:
查看原型鏈裡的資訊
查看某物件的原型:Object.getPrototypeOf()
你可以使用 Object.getPrototypeOf()
來檢視某物件的原型:
// Object.getPrototypeOf(name)
console.log(Object.getPrototypeOf(name))
從 console
的結果中可以發現 name
的原型是 String
,也就是說,它產生自 constructor function String
:
原型鏈可能不只一層,你可以進一步尋找 「name 的原型的原型」:
let prototypeOfName = Object.getPrototypeOf(name)
let prototypeOfPrototypeOfName = Object.getPrototypeOf(prototypeOfName)
傳統上,另一種取得原型的方式是直接使用 __proto__
屬性,因此透過 name.__proto__
一樣可以取得 name 這個字串的原型,得到的結果會是一樣的:
let prototypeOfName = name.__proto__
let prototypeOfPrototypeOfName = name.__proto__.__proto__
注意:雖然 .__proto__
看起來比較方便,我們在示範中也會使用,但這個方法已宣告未來可能會被棄用,因此在正式撰寫程式碼時,請使用 Object.getPrototypeOf()
來取得原型。
補充:在 JavaScript 中任何東西的原型最後都是來自物件
在瞭解了原型和原型鏈的概念後,是該來面對事實的時候了——在 JavaScript 中幾乎所有資料型別的原型最後其實都是物件。
聽到這你可能會有點訝異,之前不是還大費周章地介紹了 primitive type 和 object 的不同嗎?怎麼會說這些東西其實都來自物件呢?
注意,我們說的是「它們來自物件」,而不是「它們是物件」。
當我們知道如何使用 Object.getPrototypeOf
來檢視原型後,你可以試著觀察上面這些資料型態的原型,以數值 (Number) 為例:
let number = 3
let prototypeOfNumber = Object.getPrototypeOf(number)
let prototypeOfPrototypeOfNumber = Object.getPrototypeOf(prototypeOfNumber)
let prototypeOfPrototypeOfPrototypeOfNumber = Object.getPrototypeOf(prototypeOfPrototypeOfNumber)
接著把它們 console.log()
出來看:
console.log(prototypeOfNumber) // object created by Number()
console.log(prototypeOfPrototypeOfNumber) // object created by Object()
console.log(prototypeOfPrototypeOfPrototypeOfNumber) // null
你會發現 number
的原型是一個透過 Number()
建構式函式所建立的實例,裡面包含了許多給數值使用的方法,像是 toExponential
, toFixed
等等:
我們還可以看到在 prototypeOfNumber
裡面還有 __proto__
這個屬性,意思就是說,number
的原型鏈不只一層,還有「number
的原型的原型」,一樣透過 console
出來可以看到:
你會發現,「number 的原型的原型」是透過 Object()
這個建構式函式所建立的實例,裡面有許多給物件可以使用的方法,像是 hasOwnProperty()
, isPrototypeOf()
等等。
換句比較通俗的話來說,就是 number
的爸爸是 Number()
,所以我們可以使用和數值有關的方法,而因為 Number()
的爸爸是 Object()
,因此 number
除了具有數值相關的方法,但它同樣具有物件的方法在內可以使用。
從 console.log()
中我們也可以發現在 prototypeOfPrototypeOfNumber
沒有 __proto__
屬性了,表示這是最後一層的原型了。這也就是為什麼我們會說,在 JavaScript 中幾乎所有的東西都是物件,因為不論是字串、數值、布林值、陣列,甚至是函式,當我們使用 Object.getPrototypeOf()
去追尋它的祖宗時,你會發現都有物件的影子在內,而且也都可以使用物件的方法。
Recap
在這個單元中提到了原型 (prototype) 和原型鏈 (prototype chain) 的概念,而且可以透過 Object.getPrototypeOf()
來知道在 JavaScript 中幾乎所有的東西都會繼承自物件,因此帶有可用的物件方法。在下個單元中,將進一步說明如何透過原型和原型鏈的概念,來把方法代入建構式函式中使用。