Prototype Pattern:在現有物件上長出新功能

·12 min

此為《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.prototypeObject.create(null) 例外)。

const obj = {}
console.log(Object.getPrototypeOf(obj) === Object.prototype) // true(一般情況)

就算是空物件,背後還是偷偷連到 Object.prototype,所以才會有 toStringhasOwnProperty 這些方法。

至於為什麼 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.create1,它可以幫我直接建立一個新物件,並指定它的 [[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,所以像 toStringhasOwnProperty 這些常見的屬性都不會有。
  • 很適合拿來單純儲存一些資料,因為不會被內建屬性影響。
  • 不過因為它沒有繼承 hasOwnProperty,要檢查屬性時改由 Object.prototype 上的那個方法來執行:Object.prototype.hasOwnProperty.call(obj, key)

3. ES6 class extends

ES6 有了 classextends,繼承寫起來更簡單與直觀:

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 的初始化」,把每個實例自己的資料(像 idlabel)塞進 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)。
  • 會產生很多實例,方法要共享(效能、記憶體考量)。
  • 需要維持型別/關係(instanceofisPrototypeOf)。

不適用情境

  • 只需要少量、一次性物件(物件字面值更快)。
  • 需求偏向「組合」而非「繼承」(像 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」的筆記,我真的卡了很久。書裡明明只有三頁半的篇幅,但涵蓋的抽象概念就是讓我腦袋轉不過來,動彈不得。偏偏就在這時《空洞騎士:絲綢之歌》發售了。

我先生是前作粉絲,早就準備好要衝了。我這個手殘黨,本來想說看看實況就好,沒想到遊戲裡的角色設計實在太可愛,讓我越看越入迷,最後乾脆就黏在他旁邊「陪玩」,一邊讚嘆它驚人的美術與音樂設計。

這款遊戲從角色、音樂到關卡設計都太出色,完全成了我逃避寫筆記的完美藉口。結果寫筆記的過程就陷入一個惡性循環:卡關時跑去看遊戲,看完回來寫一點,然後又卡關,又再去看遊戲⋯⋯就這樣來來回回,總算才把這篇筆記慢慢磨出來。

Footnotes

  1. Object.create() — MDN 技術文件

感謝你讀到這裡!如果我的分享讓你有所共鳴或啟發,歡迎請我喝杯珍奶 🧋,給我一點繼續分享的能量: https://anna.bobaboba.me