Constructor Pattern:用模具生出同款物件

·12 min

此為《Learning JavaScript Design Patterns, 2nd Edition》學習筆記,對應第 7 章(JavaScript Design Patterns - The Constructor Pattern)

模式用途
  • 分類:建立型
  • 解決什麼問題:如何建立多個結構一致、可共享方法的物件

從按鈕開始

為了更貼近實際開發情境,我打算用一個最常見的元件——按鈕——當作案例。登入、登出、註冊⋯⋯這些按鈕雖然長得差不多,但都需要 id、文字標籤 (label)、點擊事件 (onClick)

從這個案例出發,我會先展示最原始的寫法,再一步步演進到 Constructor Pattern。

一步步演進

1. 用大括號直接定義物件

最直接的方式就是先寫一個物件,然後手動複製多個:

let btn1 = {
  id: 'login',
  label: '登入',
  onClick: () => console.log('登入')
}

let btn2 = {
  id: 'logout',
  label: '登出',
  onClick: () => console.log('登出')
}

這樣當然可以用,不過每次都要重複定義 onClick,寫多了就不容易維護。這種做法一開始很快,但當需要更多按鈕時就會顯得笨重。

補充:object literal

這種直接用大括號 {} 來建立物件,專有名詞叫做 object literal(物件字面值)1

2. 用函式幫忙產生物件

手動寫每個物件太重複了,所以我乾脆把重複邏輯包成一個函式,讓它在最後 return 一個物件。這樣每次呼叫函式就能拿到一個新的物件,程式碼會簡潔很多。

function createButton(id, label) {
  return {
    id,
    label,
    onClick() {
      console.log(`${label} clicked`)
    }
  }
}

let btn1 = createButton('login', '登入')
let btn2 = createButton('logout', '登出')

這樣少打很多字,但問題還在:每次呼叫 createButton,都會複製一份新的 onClick 方法。十顆按鈕就十份,這樣會浪費掉不少記憶體,而且如果要修改方法,就得改很多份,不容易維護。

補充:factory function

這種「用函式幫忙產生物件」的寫法,在 JavaScript 社群常被叫作 factory function(工廠函式)。

3. 用 new 自動化建立物件

用函式 return 物件雖然方便,但還是有兩個缺點:一是方法會一直被複製,二是我得自己寫 return。能不能更自動一點?

這就是 new2 的用處。當我在一個函式前面加上 new,JavaScript 會幫我做以下幾件事:

  1. 建立一個新物件,並把它的內部 [[Prototype]] 指到該函式的 prototype
  2. 以此新物件作為 this 執行函式本體。
  3. 如果函式裡有額外用 return 回傳一個物件,new 最後會拿那個物件當結果;如果沒有這樣做,new 就會自動把步驟 (1) 建立的新物件當作結果回傳。
function Button(id, label) {
  this.id = id
  this.label = label
}

let btn1 = new Button('login', '登入')
let btn2 = new Button('logout', '登出')

以上方的例子來說,new Button('login', '登入') 就會是:

  1. 建立一個新的空物件 {}
  2. 把這個新物件的原型([[Prototype]])指向 Button.prototype
  3. 以這個新物件作為 this 執行 Button 函式:
    1. this.id = id 會把 "login" 設定到新物件的 id 屬性。
    2. this.label = label 會把 "登入" 設定到新物件的 label 屬性。
  4. 最後,這個新物件(已經有 idlabel 屬性)會被回傳並指派給 btn1

這樣生出來的 btn1、btn2 就是這個函式的 instance (實例)

Constructor function 的命名與限制

Button(...) 這類設計來搭配 new 使用的函式,通常被稱作 constructor function(建構函式),慣例上會用大寫開頭。注意箭頭函式物件字面值都不能用 new

如果沒有 new,會發生什麼事?

如果直接呼叫 Button(...) 而不是用 new Button(...)

  • 在非嚴格模式下,this 指到全域物件,會污染全域。
  • 在嚴格模式下,thisundefined,會直接報錯。

4. prototype:共享方法

方法則放在 prototype3 上,這樣所有 instance 就能共用:

function Button(id, label) {
  this.id = id
  this.label = label
}

// 共享的方法要放在 prototype 上
Button.prototype.onClick = function () {
  console.log(`${this.label} clicked`)
}

let btn1 = new Button('login', '登入')
let btn2 = new Button('logout', '登出')

// 驗證方法是否真的共享
console.log(btn1.onClick === btn2.onClick) // true 同一個方法!

btn1.onClick() // 登入 clicked
btn2.onClick() // 登出 clicked

不論有幾顆按鈕,方法都只需要寫一份。

prototype 三角關係
  • prototype:只有函式才有的屬性。它指向一個物件,這個物件的目的就是存放要給 instance (實例) 共用的屬性與方法。
  • [[Prototype]]:每個物件都有的內部屬性。它指向該物件的「原型」。當你用 new Button() 建立 btn1 時,btn1[[Prototype]] 就會指向 Button.prototype
  • __proto__4:是為了相容性而保留的存取器屬性(legacy accessor),建議使用 Object.getPrototypeOf() 方法來讀取。

5. Constructor Pattern

btn1、btn2 各自有屬性,方法 onClick 則透過 Button.prototype 共享
btn1、btn2 各自有屬性,方法 onClick 則透過 Button.prototype 共享

這種做法有幾個好處:

  • 可以很有系統地生出很多長得一樣、功能也一致的物件,不用一直重複寫
  • 按鈕們都共用 prototype 上的方法,所以不會每顆按鈕都複製一份,省記憶體又好維護
  • 因為有 prototype 這條線連著,可以用 instanceof5 來判斷「這是不是 Button 做出來的」,也可以像做「IconButton」這種加上圖示的新按鈕,在原本 Button 的基礎上擴充功能,做出更多變化的元件

簡單來說,Constructor Pattern 就是讓我可以做出一堆「同款」物件,大家都能用同一組方法,也方便之後加新功能或做型別判斷。

6. ES6 class 語法糖

後來 ES6 推出了 class6,讓寫法更直觀:

class Button {
  constructor(id, label) {
    this.id = id
    this.label = label
  }

  onClick() {
    console.log(`${this.label} clicked`)
  }
}

let btn1 = new Button('login', '登入')
let btn2 = new Button('logout', '登出')

屬性和方法都能寫在同一個區塊,看起來就是一個「完整物件的定義」,不必再分散在 constructor 與 prototype。不過要注意,它其實只是語法糖,底層依然是 constructor function 搭配 prototype 在運作。

重點整理:五步學習卡

1) 問題來源

建立多個相似物件時:

  • 物件字面值:重複又難維護
  • 用函式幫忙產生物件:方法一份份複製

👉 需要一致的建立方式,還能共享方法。

2) 白話描述

Constructor function(建構函式)搭配 prototype,就像一個模具;透過 new 倒料,就能生出一堆 instance(實例)
Constructor function(建構函式)搭配 prototype,就像一個模具;透過 new 倒料,就能生出一堆 instance(實例)

Constructor Pattern 就像「按鈕模具」:

  • 倒入不同的材料(id, label)
  • 出來一顆顆按鈕(instance)
  • 它們共享同一份「做法」(prototype 上的方法)

3) 專業術語

在 JavaScript 裡,Constructor Pattern = constructor function + prototype。這是一種物件建立模式,提供一致的方式來產生物件,並透過 prototype 共享方法與支援繼承。

4) 視覺化

一圖看 Constructor Pattern:instance 各自擁有屬性,方法則由 prototype 集中共享。
一圖看 Constructor Pattern:instance 各自擁有屬性,方法則由 prototype 集中共享。

在畫這張圖時,我參考了 Just JavaScript 這套互動式教材的圖示語彙。我很喜歡它用「變數就像一條線,指向某個值」的比喻,搭配視覺化的方式,讓我這個偏向視覺學習的人更能掌握抽象概念。這次延伸到物件之間的關聯,我也沿用了相同的表達方式。

5) 程式碼與適用情境

適用情境

  • 需要建立大量結構一致、方法應該共享的情況
  • 需要 instanceof 型別判斷與繼承

不適用情境

  • 只需要少量物件、一次性的物件(物件字面值通常最簡單)
  • 方法不需要共享或後續擴充/繼承(函式就足夠)

ES5 Constructor Pattern

function Button(id, label) {
  this.id = id
  this.label = label
}

Button.prototype.onClick = function () {
  console.log(`${this.label} clicked`)
}

let btn1 = new Button('login', '登入')
let btn2 = new Button('logout', '登出')

這樣生出來的 btn1btn2,就是用 Button 這個「模具」打造出來的物件。這種透過 new 建立的物件,通常會被叫做它的 instance。

ES6 Class 語法糖

class Button {
  constructor(id, label) {
    this.id = id
    this.label = label
  }

  onClick() {
    console.log(`${this.label} clicked`)
  }
}

const btn1 = new Button('login', '登入')
const btn2 = new Button('logout', '登出')

ES6 class 只是 Constructor Pattern 的語法糖,本質與 ES5 constructor function + prototype 相同。兩者都是同一個模式的不同表達方式。

後記

原本以為 Constructor Pattern 很單純,結果一路寫下來才發現裡頭其實藏了不少細節。new 的流程、prototype 的關聯被一個個拉出來,才發現自己有很多地方不熟,只好邊寫邊補,等於又把之前沒完全搞懂的東西重新整理了一遍。

而且也讓我意識到,只有「五步學習卡片」是不太夠的。最後我調整了筆記的結構:開頭先放「模式用途」,交代它屬於哪一類模式、要解的問題是什麼,幫自己抓住焦點;範例換成前端常見的按鈕,並在程式碼前加一段情境鋪墊,好更快進入狀況;最後再用五步卡片收斂重點,把過程和整理拆開,讀起來更清楚。

這些調整其實都是邊寫邊試出來的,一直在想怎麼安排才有循序漸進的感覺。過程中也做了不少取捨:有些地方先放進來,有些則決定暫時拿掉,這個拿捏的過程還蠻有趣的。

把零散的觀念連起來後,整個脈絡都清楚了,這種把知識點打通的感覺真的很爽快呀!😆

Footnotes

  1. 物件字面值 (Object literals) — MDN 技術文件

  2. new 運算子 — MDN 技術文件

  3. Function.prototype — MDN 技術文件

  4. Object.prototype.__proto__ — MDN 技術文件

  5. instanceof 運算子 — MDN 技術文件

  6. class 語法 — MDN 技術文件

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