javaScript設計模式 : Abstract Factory(抽象工廠)

 


每次專案長大,總會冒出一堆「這頁要深色主題、那支線要換金流供應商、還要保證所有元件彼此相容」的需求。要是你靠到處 if/else 收線,過沒多久就會被自己埋的條件地雷絆倒。

這篇想用淺白的例子聊聊 Abstract Factory(抽象工廠):它不追求炫技,而是幫你把「同一系列的產品」— 像是按鈕、對話框、開關,或是付款、開發票 — 用一個入口統一生出來,確保風格與協定不會混搭走鐘。全文以 JavaScript 為主,從同步 UI 主題到非同步第三方服務,帶你看設計意圖、使用時機、與其他模式的差別,並附上實作細節、測試思路、常見雷點。希望本篇文章能夠幫助到需要的您。


目錄

{tocify} $title={目錄} 


什麼是 Abstract Factory?


定義(精簡版)

Abstract Factory 的核心是:用一個「工廠物件」來產生一組相關(或相依)的產品物件,而不讓客戶端(呼叫端)接觸到產品的具體類別。你選定哪個工廠,就得到同一「產品族(Product Family)」下相容的元件組合。


直覺比喻

把它想成「同品牌、同系列的整套家具」:同一系列的桌椅櫃在尺寸與風格上互相對齊。你只決定買哪個系列(挑工廠),不用自己去配對每個零件(個別產品)。


目標

封裝選擇產品族的邏輯(例如:Light/Dark 主題、Vendor A/Vendor B 供應商)。

確保產品間的一致性與相容性(不會把 Dark 的 Button 搭 Light 的 Dialog)。

降低客戶端對具體類別的依賴,讓切換品牌/主題更容易。


何時需要它:適用情境與反指標

適用情境

需要同時建構多個相互關聯的物件(例如 UI 元件、SDK 子模組、資料儲存與快取對應)。

你有多種替換方案(平台、主題、供應商),而且希望用一個開關切換整組行為。

產品之間存在相容性約束:必須來自同一族系才能保證運作。


反指標(不建議使用)

只需要產生單一物件、且不會形成族系。

替換方案極少或基本不變;導入 Abstract Factory 只會增加結構複雜度。

需求仍在快速探索期,結構尚未穩定。


與其他模式的差異

模式 核心目的 何時用
Factory Method 延遲到子類決定要建構哪個單一產品 只有一種產品類型,但實作多樣
Abstract Factory 提供一個工廠介面,產生一族相關產品 需要多個相依物件的整套變體
Builder 將建立步驟分離,組裝同一產品的不同配置 強調一步步組裝同一個複雜物件


心法:產品數量 × 關聯度高 → Abstract Factory;單一產品但實作不同 → Factory Method;同一產品的建構流程很複雜 → Builder。


範例一(同步版):UI 主題元件工廠(Light/Dark)

此例示範「產品族」的概念:Button、Dialog、Switch 要來自同一主題才有一致視覺與交互體驗。


1.    設計重點

使用 ES6 class 表達產品與工廠。

以 Symbol 當作家族標記,避免不同主題交叉混用。

抽象基底類別提供預設拋錯,提示實作者覆寫。


2.    程式碼

// === 族系防呆:以 Symbol 標記產品所屬的 family ===
const FAMILY = Symbol('theme-family');

// === 抽象產品介面(以慣例表示,JS 無強制介面) ===
class AbstractButton {
  render() { throw new Error('AbstractButton.render() must be implemented'); }
}
class AbstractDialog {
  open() { throw new Error('AbstractDialog.open() must be implemented'); }
}
class AbstractSwitch {
  toggle() { throw new Error('AbstractSwitch.toggle() must be implemented'); }
}

// === 抽象工廠(基底類) ===
class AbstractThemeFactory {
  createButton() { throw new Error('createButton() must be implemented'); }
  createDialog() { throw new Error('createDialog() must be implemented'); }
  createSwitch() { throw new Error('createSwitch() must be implemented'); }
}

// === Light 族系 ===
class LightButton extends AbstractButton {
  constructor() {
    super();
    this[FAMILY] = 'light';
  }
  render() { return `<button style="background:#fff;color:#222;border:1px solid #ddd">Light Button</button>`; }
}
class LightDialog extends AbstractDialog {
  constructor() {
    super();
    this[FAMILY] = 'light';
  }
  open() { console.log('[LightDialog] open with soft shadow'); }
}
class LightSwitch extends AbstractSwitch {
  constructor() {
    super();
    this[FAMILY] = 'light';
    this.on = false;
  }
  toggle() {
    this.on = !this.on;
    console.log(`[LightSwitch] ${this.on ? 'ON' : 'OFF'}`);
  }
}
class LightThemeFactory extends AbstractThemeFactory {
  createButton() { return new LightButton(); }
  createDialog() { return new LightDialog(); }
  createSwitch() { return new LightSwitch(); }
}

// === Dark 族系 ===
class DarkButton extends AbstractButton {
  constructor() {
    super();
    this[FAMILY] = 'dark';
  }
  render() { return `<button style="background:#222;color:#f5f5f5;border:1px solid #444">Dark Button</button>`; }
}
class DarkDialog extends AbstractDialog {
  constructor() {
    super();
    this[FAMILY] = 'dark';
  }
  open() { console.log('[DarkDialog] open with high-contrast backdrop'); }
}
class DarkSwitch extends AbstractSwitch {
  constructor() {
    super();
    this[FAMILY] = 'dark';
    this.on = false;
  }
  toggle() {
    this.on = !this.on;
    console.log(`[DarkSwitch] ${this.on ? 'ON' : 'OFF'}`);
  }
}
class DarkThemeFactory extends AbstractThemeFactory {
  createButton() { return new DarkButton(); }
  createDialog() { return new DarkDialog(); }
  createSwitch() { return new DarkSwitch(); }
}

// === 工廠選擇器(註冊表) ===
const themeRegistry = new Map([
  ['light', () => new LightThemeFactory()],
  ['dark',  () => new DarkThemeFactory()],
]);

function getThemeFactory(name = 'light') {
  const make = themeRegistry.get(name);
  if (!make) throw new Error(`Unknown theme: ${name}`);
  return make();
}

// === 族系一致性檢查(可在開發/測試時啟用) ===
function assertSameFamily(...components) {
  const fam = components[0]?.[FAMILY];
  if (!fam) throw new Error('Component missing FAMILY tag');
  const ok = components.every(c => c[FAMILY] === fam);
  if (!ok) throw new Error('Mixed product families detected');
}

// === 客戶端使用 ===
const factory = getThemeFactory('dark'); // 換成 'light' 即全站換主題
const btn = factory.createButton();
const dlg = factory.createDialog();
const sw  = factory.createSwitch();

assertSameFamily(btn, dlg, sw);
console.log(btn.render());
dlg.open();
sw.toggle();


3.    重點回顧

客戶端只面對工廠與抽象行為,不需要知道具體類別名稱。

切換產品族=更換工廠,零散 if/switch 被收斂到一個入口。

assertSameFamily 是實務上常用的小技巧,避免誤混產品族


範例二(非同步版):第三方服務供應商工廠(付款+帳單)


實務上,建立產品時常常需要非同步資源(Token、連線、金鑰驗證)。以下示範 Abstract Factory 與 async/await 的結合。


1.    設計重點

產品需要外部設定(如 API Key),在工廠內完成初始化。

工廠方法回傳 Promise,呼叫端以 await 取得已就緒的產品。

以「付款閘道(PaymentGateway)」與「帳單服務(InvoiceService)」為同族產品。


2.    程式碼

class AbstractPaymentGateway {
  async charge(cents, customerId) { throw new Error('implement'); }
}
class AbstractInvoiceService {
  async createInvoice(payload) { throw new Error('implement'); }
}

class AbstractVendorFactory {
  async createGateway() { throw new Error('implement'); }
  async createInvoiceService() { throw new Error('implement'); }
}

// === Vendor A ===
class VendorAGateway extends AbstractPaymentGateway {
  constructor(api) { super(); this.api = api; this.vendor = 'A'; }
  async charge(cents, customerId) {
    return this.api.post('/charge', { amount: cents, customerId });
  }
}
class VendorAInvoiceService extends AbstractInvoiceService {
  constructor(api) { super(); this.api = api; this.vendor = 'A'; }
  async createInvoice(payload) {
    return this.api.post('/invoice', payload);
  }
}
class VendorAFactory extends AbstractVendorFactory {
  constructor({ apiKey }) { super(); this.apiKey = apiKey; }
  async createGateway() {
    const api = await connectA(this.apiKey);
    return new VendorAGateway(api);
  }
  async createInvoiceService() {
    const api = await connectA(this.apiKey);
    return new VendorAInvoiceService(api);
  }
}

// === Vendor B(另一套協定) ===
class VendorBGateway extends AbstractPaymentGateway {
  constructor(client) { super(); this.client = client; this.vendor = 'B'; }
  async charge(cents, customerId) {
    return this.client.send('PAY', { cents, customerId });
  }
}
class VendorBInvoiceService extends AbstractInvoiceService {
  constructor(client) { super(); this.client = client; this.vendor = 'B'; }
  async createInvoice(payload) {
    return this.client.send('INVOICE', payload);
  }
}
class VendorBFactory extends AbstractVendorFactory {
  constructor({ token }) { super(); this.token = token; }
  async createGateway() {
    const client = await connectB(this.token);
    return new VendorBGateway(client);
  }
  async createInvoiceService() {
    const client = await connectB(this.token);
    return new VendorBInvoiceService(client);
  }
}

// === 模擬連線 ===
async function connectA(apiKey) {
  await new Promise(r => setTimeout(r, 10));
  return { post: (path, data) => ({ vendor: 'A', path, data, ok: true }) };
}
async function connectB(token) {
  await new Promise(r => setTimeout(r, 10));
  return { send: (cmd, data) => ({ vendor: 'B', cmd, data, ok: true }) };
}

// === 工廠選擇 ===
function getVendorFactory(kind, credentials) {
  switch (kind) {
    case 'A': return new VendorAFactory(credentials);
    case 'B': return new VendorBFactory(credentials);
    default: throw new Error(`Unknown vendor: ${kind}`);
  }
}

// === 客戶端使用 ===
(async () => {
  const factory = getVendorFactory('B', { token: 'secret' });
  const gateway = await factory.createGateway();
  const invoice = await factory.createInvoiceService();

  const payRes = await gateway.charge(9900, 'cus_123');
  const invRes = await invoice.createInvoice({ customerId: 'cus_123', items: [{ name: 'Pro Plan', cents: 9900 }] });

  console.log(payRes, invRes);
})();


3.    重點回顧

非同步工廠是 Web/Node.js 常態,請務必將 async/await 納入設計。

把供應商特性封裝在工廠內,讓上層只關心抽象行為(charge、createInvoice)。


進階技巧:註冊表、函式工廠、族系防呆

1.    註冊表(Registry)

用 Map 保存工廠建構器,方便在組態或插件機制下動態擴充:

const registry = new Map();
function registerFactory(key, maker) { registry.set(key, maker); }
function resolveFactory(key) {
  const maker = registry.get(key);
  if (!maker) throw new Error(`Factory not found: ${key}`);
  return maker();
}


2.    函式工廠(無 class 版)

在偏向函數式的程式碼庫,可用閉包與物件工廠達成同等效果:

const FAMILY = Symbol('family');

function createThemeFactory(theme) {
  const fam = theme;
  return {
    createButton() {
      return {
        [FAMILY]: fam,
        render() { return `<button>${fam} button</button>`; }
      };
    },
    createDialog() {
      return {
        [FAMILY]: fam,
        open() { console.log(`[${fam}] dialog open`); }
      };
    }
  };
}

const factory = createThemeFactory('light');
const btn = factory.createButton();
const dlg = factory.createDialog();


3.    族系防呆(品牌化檢查)

前述的 Symbol 標記能有效防止誤混產品族,也利於測試時做靜態檢查:

function assertFamilyMatch(a, b) {
  if (a?.[FAMILY] !== b?.[FAMILY]) {
    throw new Error('Family mismatch');
  }
}


測試與維護:如何寫測試、如何演進

1.    單元測試建議

行為導向:測試抽象能力(例如 render() 是否回傳正確結構),避免綁死具體類別名稱。

族系一致性:新增測試檢驗 FAMILY 是否一致。

非同步工廠:用 await 驗證產物是否就緒,並對 API 呼叫做 mock。

// 假設使用 Node.js assert
import assert from 'node:assert/strict';

const factory = getThemeFactory('light');
const a = factory.createButton();
const b = factory.createDialog();
assert.equal(a[Symbol.for('theme-family')], undefined); // 範例:防止濫用 Symbol.for

// 建議使用 assertSameFamily(見前文)


小提醒:測試不要「嘗試去知道」私有欄位;盡量靠公開行為(方法輸出、副作用)判斷。


2.    維護與演進

新增新族系(例如 HighContrast)時,直接新增一個工廠類別與相對應產品;客戶端不需修改。

若產品清單擴增(新增 createTooltip),抽象工廠介面也要同步擴充,避免族系實作不完整。

把「選擇哪個工廠」的邏輯收斂到單點(環境變數、設定檔、A/B 測試參數)。


常見錯誤與雷點

1.    過度工程

症狀:明明只有一個產品、兩個簡單實作,卻硬上 Abstract Factory。

建議:先用 Factory Method 或單純函式;等「產品族」需求明朗再升級。


2.    把具體類別外漏到客戶端

反例:客戶端寫 new DarkButton()。

後果:切換族系時要改遍全站。請改為呼叫 factory.createButton()。


3.    條件蔓延(if/switch everywhere)

反例:到處 if (theme === 'dark') {...} else {...}。

解法:把條件收斂到工廠選擇器或註冊表。


4.    忽略非同步建立

反例:建立產品需要 token/連線,卻用同步介面回傳半成品。

解法:工廠方法回傳 Promise,呼叫端 await。


5.    產品族混搭

症狀:Dark Button 配 Light Dialog。

解法:導入 FAMILY 標記與 assertSameFamily。可在開發模式啟用。


6.    測試難寫、難換 mock

反例:客戶端內部直接 new VendorAGateway()。

解法:注入 factory 作為依賴,測試時用假工廠替換。


7.    把工廠變成全域單例,導致狀態污染

症狀:測試之間互相干擾。

建議:讓工廠無狀態或以建構參數注入設定;測試結束記得重置。


8.    跨平台差異未隔離(Browser vs Node)

症狀:某族系依賴 DOM API,另一族系在 Node 不可用。

建議:在工廠內部隔離平台差異,或拆成不同發佈目標。


9.    未定義失敗策略

症狀:第三方連線失敗、產品建立中斷。

建議:工廠方法清楚回傳錯誤,並提供重試/降級策略。


10.    版本協議不一致

症狀:同族產品升版後介面微調,舊客戶端壞掉。

建議:以抽象介面版本化(v1/v2),並在工廠層做轉接。


總結

Abstract Factory 的價值在於:把「產品族的切換」與「產品間的一致性」抽離出來,形成可替換、可測試、可擴充的結構。

在 JavaScript/Node.js 的世界,請務必考慮非同步建立,把 async/await 視為工廠介面的一等公民。

搭配註冊表、族系防呆標記與依賴注入,可有效降低條件蔓延與耦合。

初期可從簡(函式工廠、單一族系),待需求成熟再升級為正式的 Abstract Factory。


附錄:極簡對照速記

我有多個相關物件要一起換? → Abstract Factory

我只要換一個物件的實作? → Factory Method

我要一步步組裝同一個複雜物件? → Builder

JS 要不要考慮 async? → 幾乎都要

如何防止混搭? → Symbol 族系標記+測試


延伸閱讀推薦:

javaScript設計模式 : Factory Method   (工廠方法)

javaScript設計模式 : Builder(建造者模式)

javaScript設計模式 : Prototype(原型)

javaScript設計模式 : Singleton (單例模式)

javaScript設計模式 : Adapter(轉接器模式)

javaScript設計模式 : Bridge( 橋接模式 )

javaScript設計模式 : Composite(組合模式)

 javaScript設計模式 : Decorator(裝飾者)

javaScript設計模式 : Facade(外觀模式)

javaScript設計模式 : Flyweight(享元模式)

javaScript設計模式 : Proxy(代理模式)

javaScript設計模式 : Chain of Responsibility(責任鏈)

javaScript設計模式 : Command Pattern(命令模式)

javaScript設計模式 : Interpreter(直譯器)

javaScript設計模式 : Iterator(迭代器)

javaScript設計模式 : Mediator(仲裁者)

javaScript設計模式 : Memento(備忘錄)

javaScript設計模式 : Observer( 觀察者 )

javaScript設計模式 : State(狀態模式)

javaScript設計模式 : Strategy(策略模式)

javaScript設計模式 : Template Method (模板方法)

javaScript設計模式 : Visitor(訪問者模式)

張貼留言 (0)
較新的 較舊