Prototype Pattern:在現有物件上長出新功能
此為《Learning JavaScript Design Patterns, 2nd Edition》學習筆記,對應第 7 章(JavaScript Design Patterns - The Prototype Pattern)
模式用途
- 分類:建立型
- 解決什麼問題:如何在現有物件基礎上擴充功能,讓新物件能沿著原型鏈繼承方法
在上一篇 Constructor Pattern,我已經用 prototype
讓一堆按鈕共用方法。 不過還有另一個延伸的情境:如果我想做一個「IconButton」,在 Button 的基礎上多加個圖示呢? 光靠共享方法還不夠,我需要一種方式,能在現有物件上再加東西。
先把原型鏈補起來
要搞懂怎麼做 IconButton,得先釐清「prototype 為什麼能共享方法」這件事。Constructor Pattern 那時候用到了,但當時只是知道「方法放 prototype 上會共享」而已。 到了 Prototype Pattern,我才發現不能只知道「prototype 可以共享方法」,還得搞懂「原型鏈」怎麼運作,不然後面會霧煞煞。
物件往上最後都會連到 Object.prototype
(Object.create(null)
例外)。
const obj = {}
console.log(Object.getPrototypeOf(obj) === Object.prototype) // true(一般情況)
就算是空物件,背後還是偷偷連到 Object.prototype
,所以才會有 toString
、hasOwnProperty
這些方法。
至於為什麼 Object.create(null)
是例外,後面會提到。
constructor function 都會帶一個 prototype
function Button() {}
console.log(Button.prototype) // { constructor: Button }
函式在建立時,會擁有一個 prototype
物件,實例的 [Prototype]]
會指向它;因此將方法放在 prototype
上能被所有實例共享。(我會把它想像成函式身上的「小口袋」:放進去的東西,實例都拿得到。)
prototype 裡的 constructor 又指回函式
function Button() {}
const btn = new Button()
console.log(btn.constructor === Button) // true
prototype.constructor
會指回原本的函式,這樣從實例就能知道「我是誰生出來的」。但 constructor 可被重設或覆寫,做型別判斷時不一定準確。
原型鏈(prototype chain):找不到就往上找
function Button(label) {
this.label = label
}
Button.prototype.onClick = function () {
console.log(`${this.label} clicked`)
}
const btn = new Button('登入')
btn.onClick() // 在 btn 實例上沒找到 onClick → 沿著 [[Prototype]] 到 Button.prototype 找到 → 執行
原型鏈的機制就是這樣:找不到就往上找,直到 null
為止。也因為這樣,方法放在 prototype 上,大家都能共享。
共享解決了重複問題,但如果我要在 Button
基礎上做出 IconButton
,就需要用到這條鏈來「擴充」。
一步步演進:從 Button 到 IconButton
1. 直覺寫法(錯誤)
一開始我直覺把 IconButton.prototype
指向 Button.prototype
,但這樣有問題:兩邊共用同一個 prototype,改一邊另一邊也跟著變。
function Button(id, label) {
this.id = id
this.label = label
}
Button.prototype.onClick = function () {
console.log(`${this.label} clicked`)
}
function IconButton(id, label, icon) {
Button.call(this, id, label)
this.icon = icon
}
// ❌ 兩個共用同一份 prototype,改一邊會影響另一邊
IconButton.prototype = Button.prototype
2. 用 Object.create 連上去(正確)
除了用物件字面值 {}
或是 constructor 搭配 new
,在 ES5 還有一個方法:Object.create
1,它可以幫我直接建立一個新物件,並指定它的 [[Prototype]]
指到哪裡。
// 字面值:預設指到 Object.prototype
const a = {}
// constructor:指到 Foo.prototype
function Foo() {}
const b = new Foo()
// Object.create:我自己決定要指到誰
const parent = { kind: 'parent' }
const c = Object.create(parent)
console.log(c.kind) // "parent"
{}
生出來的東西,背後會接到Object.prototype
。new Foo()
,會接到Foo.prototype
。Object.create(proto)
,就是直接把新物件拉到proto
。
回到 IconButton
的例子,我就可以改用 Object.create
幫它建立一個新的 prototype 物件,背後再連到 Button.prototype
,這樣就不會互相影響。
function Button(id, label) {
this.id = id
this.label = label
}
Button.prototype.onClick = function () {
console.log(`${this.label} clicked`)
}
function IconButton(id, label, icon) {
Button.call(this, id, label) // 繼承 Button 的屬性
this.icon = icon
}
// ✅ 建一個新 prototype,接到 Button.prototype
IconButton.prototype = Object.create(Button.prototype)
// 因為上一行會讓 IconButton.prototype.constructor 變成 Button
// 記得把 constructor 指回 IconButton
IconButton.prototype.constructor = IconButton
IconButton.prototype.render = function () {
console.log(`Render ${this.label} with ${this.icon}`)
}
const btn = new IconButton('login', '登入', '🔑')
btn.onClick() // 繼承自 Button
btn.render() // IconButton 自己的方法
寫到這我才理解為什麼書中會特別強調 Object.create
是「真正的原型繼承」。因為它直接把新物件拉到指定的原型上,而不是像 constructor function 那樣先 new
,再偷偷幫物件接 prototype。
Object.create(null)
的原型鏈長什麼樣?
const dict = Object.create(null)
console.log(Object.getPrototypeOf(dict)) // null
// dict.toString // → undefined
// Object.prototype.hasOwnProperty.call(dict, 'x') // 只能這樣檢查
- 這種物件跟一般的物件不一樣,完全沒有連到
Object.prototype
,所以像toString
、hasOwnProperty
這些常見的屬性都不會有。 - 很適合拿來單純儲存一些資料,因為不會被內建屬性影響。
- 不過因為它沒有繼承
hasOwnProperty
,要檢查屬性時改由Object.prototype
上的那個方法來執行:Object.prototype.hasOwnProperty.call(obj, key)
。
3. ES6 class extends
ES6 有了 class
和 extends
,繼承寫起來更簡單與直觀:
class Button {
constructor(id, label) {
this.id = id
this.label = label
}
onClick() {
console.log(`${this.label} clicked`)
}
}
class IconButton extends Button {
constructor(id, label, icon) {
// super() 會自動呼叫 Button 的 constructor
super(id, label)
this.icon = icon
}
render() {
console.log(`Render ${this.label} with ${this.icon}`)
}
}
class
寫起來簡潔許多,但底層仍然是透過 constructor function 和 prototype 來建立原型鏈,並且會用到與 Object.create
等效的步驟來連結 prototype。
4. 如果不用 constructor function,直接用物件呢?
const ButtonProto = {
init(id, label) {
this.id = id
this.label = label
return this
},
onClick() {
console.log(`${this.label} clicked`)
}
}
const IconButtonProto = Object.create(ButtonProto)
IconButtonProto.initIcon = function (id, label, icon) {
this.init(id, label)
this.icon = icon
return this
}
IconButtonProto.render = function () {
console.log(`Render ${this.label} with ${this.icon}`)
}
const btn = Object.create(IconButtonProto).initIcon('login', '登入', '🔑')
btn.onClick()
btn.render()
這種寫法更直白:直接拿一個物件當原型,再長出新物件。 這種方式在技術上叫 OLOO(Objects Linked to Other Objects),意思就是「物件直接接物件」。不用 class 或 constructor function,只要用 Object.create
把新物件連到舊物件上就行了。
為什麼需要額外的 init 與 initIcon 函式?
用 Object.create(proto)
生出「連到某個原型的純物件」,不是用可被建構的函式,所以沒有自動跑的 constructor,也就不能用 new
;因此要靠手動的 init
來扮演「constructor 的初始化」,把每個實例自己的資料(像 id
、label
)塞進 this
。
至於 initIcon
,則是 IconButton
的「加料版初始化」:先呼叫 this.init(id, label)
打好基礎,再補上 icon
,這樣把「基礎款」和「加料款」分開,避免把所有需求都擠進同一個 init
而互相污染、也讓職責更清晰。
列舉屬性的陷阱
書裡還特別提醒了一點:
It is worth noting that prototypal relationships can cause trouble when enumerating properties of objects and (as Crockford recommends) wrapping the contents of the loop in a hasOwnProperty() check.
那具體的麻煩到底是什麼呢?
const params = { q: 'javascript', page: 2 }
// 預期只會拿到 q, page
for (const key in params) {
console.log(key)
}
如果有人(可能是第三方套件或壞程式)往 Object.prototype
加了屬性:
Object.prototype.debug = true
再跑一次:
for (const key in params) {
console.log(key)
}
// q, page, debug ❌
原型鏈上的 debug
被撈出來,混進原本的資料,這在組 query string 或序列化表單時會出問題。
解決方法就是加 hasOwnProperty
過濾:
for (const key in params) {
if (Object.prototype.hasOwnProperty.call(params, key)) {
console.log(key)
}
}
// q, page
這是書中提到的 Douglas Crockford(《JavaScript: The Good Parts》作者)建議的做法。
更安全的替代方案:
Object.keys(params).forEach(key => console.log(key))
// 或
for (const [k, v] of Object.entries(params)) {
console.log(k, v)
}
// 或(現代瀏覽器/Node 版本)
if (Object.hasOwn(params, 'q')) {
// ...
}
重點整理:五步學習卡
1) 問題來源
繼承與擴充物件時:
- 傳統 constructor function 繼承寫法冗長
- ES6 class 語法糖更簡潔,但底層概念相同
- 有時想直接用物件鏈結更直覺
👉 需要一種簡潔且有效率的物件擴充方式。
2) 白話描述
Constructor Pattern 是「用模具壓出按鈕」。Prototype Pattern 比較像「在原本的按鈕上再加東西」,例如加個圖示,就變成 IconButton
。 靠的是那條原型鏈:新物件不必重做一份,只要接在原來的按鈕上,舊的功能就能一起帶過來。
3) 專業術語
在 JavaScript 裡,Prototype Pattern 是靠物件的 [[Prototype]]
連結(原型鏈),讓新物件繼承現有物件的屬性與方法。 通常會用 Object.create
建立新物件,或用 constructor function / class
搭配 prototype 實現。
4) 視覺化
Prototype Pattern:在 Button 原型上長出 IconButton,共享 onClick,IconButton 自己有 render。
5) 程式碼與適用情境
適用情境
- 需要在現有物件上擴充功能(像 Button → IconButton)。
- 會產生很多實例,方法要共享(效能、記憶體考量)。
- 需要維持型別/關係(
instanceof
、isPrototypeOf
)。
不適用情境
- 只需要少量、一次性物件(物件字面值更快)。
- 需求偏向「組合」而非「繼承」(像 React 元件邏輯)。
- 繼承層級太深,閱讀成本高。
ES5 Object.create 寫法
function Button(id, label) {
this.id = id
this.label = label
}
Button.prototype.onClick = function () {
console.log(`${this.label} clicked`)
}
function IconButton(id, label, icon) {
Button.call(this, id, label)
this.icon = icon
}
IconButton.prototype = Object.create(Button.prototype)
IconButton.prototype.constructor = IconButton
IconButton.prototype.render = function () {
console.log(`Render ${this.label} with ${this.icon}`)
}
ES6 class 寫法
class Button {
constructor(id, label) {
this.id = id
this.label = label
}
onClick() {
console.log(`${this.label} clicked`)
}
}
class IconButton extends Button {
constructor(id, label, icon) {
super(id, label)
this.icon = icon
}
render() {
console.log(`Render ${this.label} with ${this.icon}`)
}
}
後記
這篇關於「Prototype Pattern」的筆記,我真的卡了很久。書裡明明只有三頁半的篇幅,但涵蓋的抽象概念就是讓我腦袋轉不過來,動彈不得。偏偏就在這時《空洞騎士:絲綢之歌》發售了。
我先生是前作粉絲,早就準備好要衝了。我這個手殘黨,本來想說看看實況就好,沒想到遊戲裡的角色設計實在太可愛,讓我越看越入迷,最後乾脆就黏在他旁邊「陪玩」,一邊讚嘆它驚人的美術與音樂設計。
這款遊戲從角色、音樂到關卡設計都太出色,完全成了我逃避寫筆記的完美藉口。結果寫筆記的過程就陷入一個惡性循環:卡關時跑去看遊戲,看完回來寫一點,然後又卡關,又再去看遊戲⋯⋯就這樣來來回回,總算才把這篇筆記慢慢磨出來。