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

 


你應該也遇過這種場景:同樣是「發通知」,有時要走 Email,有時改成 SMS,再來產品說想加 Push。大家先用個 switch 撐著,過兩週就變巨無霸。每多一個渠道,測試、例外、重試邏輯都得重寫一次,風險隨之放大。其實不必這麼累。工廠方法的精神是把「選誰上場」變成一個可替換、可擴充的鈕,呼叫端只看同一套介面,至於背後派誰上陣、怎麼初始化、要不要快取,通通由工廠負責。

本文不賣關子,直接用 JS 帶你搭一套好用的工廠:從最小介面開始,逐步加入非同步、裝飾器、故障轉移,最後列出常見坑給你繞開。目標是務實:少改舊碼、多加變體,維護起來不心驚。希望本篇文章能夠幫助到需要的您。


目錄

{tocify} $title={目錄} 


為什麼你會需要工廠方法?

當「物件如何被建立」這件事開始變複雜——例如依環境(Web/Node)、平台(iOS/Android PWA)、配置(dev/prod)、或策略(快取/安全/壓縮)而有所不同時,把 “建立物件” 的細節抽離到專門的地方,能讓程式碼更乾淨、可測、可擴充。

Factory Method(工廠方法)就是為此而生:把「建立哪一種產品」的決策,延遲到子類別或組態,客端只依賴抽象介面,不用知道具體類別名稱與 new 的細節。


先釐清名詞:Factory Method vs. 簡單工廠 vs. 抽象工廠

三者常被混用,但定位不同:

簡單工廠(Simple Factory)

一個函式或物件,接參數回傳對應的實例:create('csv') -> CsvExporter。

易懂,但決策通常集中在一個 switch/case 中,不利延展。


工廠方法(Factory Method)

把「建立產品」的步驟定義為一個可被覆寫的方法。

父類別(Creator)提供流程骨架,子類別覆寫 createXxx() 來決定要產哪個實作。

優點:開放/封閉(新增子類別即可擴充);

缺點:若產品矩陣過大,子類別可能爆量。


抽象工廠(Abstract Factory)

建立一族相關產品的一組工廠,例如同時產生 Button、Checkbox、Dialog 的「深色主題工廠」或「蘋果風格工廠」。

跨多產品線時很強,但結構更重。


簡單記憶:

想快速用參數挑類別 → 簡單工廠

想把「怎麼選類別」變成可覆寫、可擴充的決策點 → 工廠方法

想同時建立多個關聯產品 → 抽象工廠


何時該用、何時不必用

適用情境

物件建立邏輯複雜,且會隨場景/環境變化(例:不同部署環境用不同 Logger/HTTP Client)。

想把 new 的細節隔離,讓客端只依賴抽象介面,提升測試性與可替換性。

想遵守 Open/Closed Principle:新增行為時不改舊碼(透過繼承或組態擴充)。


不建議使用

僅是單一且簡單的 new,沒有變體或條件分支。

專案規模小、團隊偏好函數式與工廠函式(可用「函式工廠 + 策略」替代)。


核心結構(以類別版示意)

// Product 介面(JavaScript 沒有 interface,這裡用 JSDoc 說明)
/**
 * @typedef {Object} Channel
 * @property {(msg: string) => void} deliver
 */

// Concrete Products
class EmailChannel {
  deliver(msg) { console.log(`[EMAIL] ${msg}`); }
}
class SmsChannel {
  deliver(msg) { console.log(`[SMS] ${msg}`); }
}

// Creator:定義流程骨架,留一個「工廠方法」給子類別覆寫
class NotificationSender {
  send(message) {
    const channel = this.createChannel(); // <-- 工廠方法
    channel.deliver(message);
  }
  // 工廠方法(預設丟錯,強迫子類別實作)
  createChannel() {
    throw new Error('You must override createChannel()');
  }
}

// Concrete Creators:只覆寫如何建立產品
class EmailNotificationSender extends NotificationSender {
  createChannel() { return new EmailChannel(); }
}
class SmsNotificationSender extends NotificationSender {
  createChannel() { return new SmsChannel(); }
}

// 用法
new EmailNotificationSender().send('歡迎加入!');
new SmsNotificationSender().send('您的驗證碼是 123456');


重點:send() 的流程固定,但用哪個 Channel 交給 createChannel() 決定。

新增 PushNotificationSender?寫一個子類別覆寫即可,不動舊碼。


實戰 1:通知通道(類別版,含日誌與錯誤處理)

加入更多實務細節:通道可能失敗、要記錄日誌、異常要切換備援。

class Logger {
  info(msg) { console.log(`[INFO] ${msg}`); }
  error(msg) { console.error(`[ERROR] ${msg}`); }
}

class EmailChannel {
  deliver(msg) {
    if (!msg) throw new Error('Empty message');
    // 真實環境可呼叫第三方 API
    console.log(`[EMAIL] ${msg}`);
  }
}
class SmsChannel {
  deliver(msg) { console.log(`[SMS] ${msg}`); }
}

class NotificationSender {
  constructor(logger = new Logger()) {
    this.logger = logger;
  }

  send(message) {
    const channel = this.createChannel(); // 工廠方法
    try {
      channel.deliver(message);
      this.logger.info(`sent via ${channel.constructor.name}`);
    } catch (err) {
      this.logger.error(`send failed: ${err.message}`);
      this.onFailover(message, err);
    }
  }

  createChannel() { throw new Error('override me'); }

  // 可選的 Template Hook:故障時的備援策略
  onFailover(message, err) {
    this.logger.info('no failover strategy, dropping message.');
  }
}

class EmailNotificationSender extends NotificationSender {
  createChannel() { return new EmailChannel(); }
  onFailover(message) {
    this.logger.info('fallback to SMS');
    new SmsNotificationSender(this.logger).send(message);
  }
}

class SmsNotificationSender extends NotificationSender {
  createChannel() { return new SmsChannel(); }
}


這段示範工廠方法 + 範本方法(Template Method)一起用:

父類別固定流程,子類別只負責「產什麼產品」與「失敗時怎麼補強」。


實戰 2:函式工廠(Functional Style),更貼近 JavaScript 習慣

很多 JS 團隊偏好函式與閉包而非類別。這裡用函式工廠模擬工廠方法概念:把建立邏輯封裝在高階函式中,外部只拿到統一介面。

/**
 * @typedef {(data: any) => string} Exporter
 */

/** 產品們:各種匯出器 */
const createCsvExporter = () => /** @type {Exporter} */ (data) =>
  Object.values(data).join(',');

const createJsonExporter = () => /** @type {Exporter} */ (data) =>
  JSON.stringify(data);

/** 工廠方法概念:用閉包“決定”回傳哪種 Exporter */
const createExporterFactory = (format) => {
  /** @type {Record<string, () => Exporter>} */
  const registry = {
    csv: createCsvExporter,
    json: createJsonExporter,
  };
  const make = registry[format];
  if (!make) throw new Error(`Unknown format: ${format}`);
  return make(); // 這裡就是“工廠方法”的決策點
};

// 用法
const exporter = createExporterFactory('csv');
console.log(exporter({ id: 1, name: 'Alice' })); // 1,Alice


優點:

更輕量、容易測試(傳不同的 registry 進去即可替換產品)。

不需要繼承,擴充靠「註冊新產品」。


實戰 3:非同步工廠(初始化需要等待的產品)

真實世界裡,很多產品需要非同步初始化(開連線、載入金鑰、Warm-up Cache)。這時工廠方法本身就該是 async。

class DbClient {
  constructor(conn) { this.conn = conn; }
  async query(sql) { /* ... */ return []; }
}

// 模擬非同步建立連線
const connect = async (dsn) => {
  await new Promise(r => setTimeout(r, 50));
  return { dsn, connectedAt: Date.now() };
};

class RepositoryCreator {
  async createClient() { throw new Error('override'); } // async 工廠方法

  async fetchUsers() {
    const client = await this.createClient();
    return client.query('SELECT * FROM users;');
  }
}

class PostgresRepository extends RepositoryCreator {
  constructor(dsn) { super(); this.dsn = dsn; this._cached = null; }
  async createClient() {
    if (this._cached) return this._cached; // 避免重複連線:快取
    const conn = await connect(this.dsn);
    this._cached = new DbClient(conn);
    return this._cached;
  }
}


重點:

把等待成本藏在工廠方法,呼叫端只管流程(fetchUsers())。

若建立昂貴,記得快取,避免每次都重連。


可測試性:用工廠方法注入 Test Double

工廠方法能自然注入替身(stub/mock),讓測試不依賴外部資源。

class FakeChannel { constructor() { this.sent = []; } deliver(m) { this.sent.push(m); } }
class TestableSender extends NotificationSender {
  constructor(fake, logger) { super(logger); this.fake = fake; }
  createChannel() { return this.fake; }
}

// 測試
const fake = new FakeChannel();
const logs = [];
const logger = { info: m => logs.push(m), error: m => logs.push(m) };

new TestableSender(fake, logger).send('hi');
console.assert(fake.sent[0] === 'hi');


擴充策略:避免子類別爆炸(組態式工廠)

當變種很多(上百種),純繼承會讓子類別爆棚。這時可用「註冊表 + 組態」,盡量把差異下放到資料層。

class PaymentChannel {
  deliver(amount) { /* ... */ }
}
class StripeChannel extends PaymentChannel {/*...*/}
class PaypalChannel extends PaymentChannel {/*...*/}

class PaymentSender {
  constructor(registry) { this.registry = registry; }
  send(amount, provider) {
    const ChannelCtor = this.registry[provider];
    if (!ChannelCtor) throw new Error(`Unknown provider: ${provider}`);
    const channel = new ChannelCtor();
    channel.deliver(amount);
  }
}

// 擴充只需註冊,不改主流程
const sender = new PaymentSender({
  stripe: StripeChannel,
  paypal: PaypalChannel,
});


常見錯誤與雷點

1.    把所有選擇寫在一個巨大 switch

症狀:新增/修改時必改這支檔,越改越危險。

對策:改用工廠方法(由子類別或註冊表決定),或拆成多個小型策略。


2.    工廠回傳的產品沒有統一介面

症狀:呼叫端寫一堆 if (p.deliver) ... else if (p.send) ...。

對策:先定義最小穩定介面(以 JSDoc/TypeScript 註記),一致命名。


3.    把昂貴物件每次都 new(不快取)

症狀:效能不穩、連線爆量。

對策:在工廠方法加入快取或使用連線池。


4.    把建構細節散落於客端

症狀:客端充滿 new A(new B(new C(...)))。

對策:把巢狀建構集中到工廠,客端只拿介面。


5.    同步/非同步混用

症狀:有時回傳實例,有時回傳 Promise,呼叫端一團亂。

對策:明確規範:若產品需要初始化,就讓工廠方法固定 async,呼叫端用 await。


6.    過度設計

症狀:只有一種產品也硬要上工廠方法。

對策:YAGNI。先用直接 new,等出現第二種變體再抽象。


7.    this 綁定踩雷(在 JS 常見)

症狀:把成員方法當回呼傳遞,this 變 undefined。

對策:用箭頭函式或顯式 bind;或回傳純函式產品(閉包)。

class BadFactory {
  create() {
    return {
      // 若回呼以獨立函式呼叫,this 將不是你想的
      deliver: this._deliver
    };
  }
  _deliver(msg) { console.log('send', msg, 'ctx=', this); }
}
// 對策:用箭頭函式捕捉詞法 this,或直接回傳無 this 的函式


8.    用字串拼裝類名或動態 eval

症狀:安全風險、難以靜態分析。

對策:用**註冊表(map)**替代 eval,把可用的產品白名單化。


9.    把工廠當成全域單例亂用

症狀:無法在測試或不同流程中替換產品。

對策:顯式傳入工廠/registry(依賴注入),避免硬編碼全域狀態。


10.    區分錯 Simple/Factory Method/Abstract Factory

症狀:想擴充卻發現仍需改原 switch。

對策:要「新增變體不改舊碼」,請用工廠方法(覆寫)或註冊表(資料驅動)。


問題集

Q1:Factory Method 一定要用類別與繼承嗎?

不一定。JavaScript 很適合用函式工廠 + 註冊表實現同樣精神:把決策點集中、把介面統一。

Q2:與策略模式差在哪?

策略模式著重可替換的行為(一組可互換的演算法),而工廠方法著重產品的建立。兩者常一起用:工廠生出不同策略。

Q3:跟 DI 容器、Service Locator 的關係?

DI 容器可視為更通用的「工廠集合」。工廠方法是在程式碼層次定義決策點,DI 是在配置層次管理依賴。小中型專案用工廠方法已足夠。

Q4:在前端框架(React/Vue)還需要嗎?

有時需要。像「依環境建立不同 API Client」、「依 A/B 測試建立不同追蹤器」,用工廠方法能讓 Hook/Composable 更乾淨可測。


進階示例:組態驅動 + 快取 + 非同步

整合上面觀念,做一個可組態的 HTTP Client 工廠:支援重試、逾時、以及在 Node/瀏覽器使用不同底層實作。

// 抽象介面(以 JSDoc 描述)
// /**
//  * @typedef {Object} HttpClient
//  * @property {(url: string, opts?: RequestInit) => Promise<Response>} fetch
//  */

// 產品:瀏覽器 Fetch
const createBrowserClient = (baseUrl) => {
  /** @type {import('./types').HttpClient} */
  return {
    async fetch(path, opts) {
      const res = await window.fetch(baseUrl + path, { ...opts });
      return res;
    }
  };
};

// 產品:Node Fetch(這裡以全域 fetch 代表 Node 18+,若無則需帶入 polyfill)
const createNodeClient = (baseUrl) => ({
  async fetch(path, opts) {
    const res = await fetch(baseUrl + path, { ...opts });
    return res;
  }
});

// Decorator:加上重試與逾時
const withResilience = (client, { retries = 1, timeoutMs = 5000 } = {}) => {
  const timeout = (ms, p) => Promise.race([
    p, new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms))
  ]);

  return {
    async fetch(url, opts) {
      let lastErr;
      for (let i = 0; i <= retries; i++) {
        try {
          return await timeout(timeoutMs, client.fetch(url, opts));
        } catch (e) {
          lastErr = e;
        }
      }
      throw lastErr;
    }
  };
};

// Creator:工廠方法(非同步+快取+組態)
class HttpClientFactory {
  /**
   * @param {{ baseUrl: string; env?: 'browser'|'node'; retries?: number; timeoutMs?: number; }} cfg
   */
  constructor(cfg) {
    this.cfg = { env: this.detectEnv(), retries: 1, timeoutMs: 5000, ...cfg };
    this._cached = null;
  }

  detectEnv() {
    try { return typeof window !== 'undefined' ? 'browser' : 'node'; }
    catch { return 'node'; }
  }

  async createClient() { // 工廠方法
    if (this._cached) return this._cached;
    const { baseUrl, env, retries, timeoutMs } = this.cfg;

    const base =
      env === 'browser' ? createBrowserClient(baseUrl) : createNodeClient(baseUrl);

    const client = withResilience(base, { retries, timeoutMs });
    this._cached = client;
    return client;
  }
}

// 用法
(async () => {
  const factory = new HttpClientFactory({ baseUrl: 'https://api.example.com' });
  const http = await factory.createClient();
  const res = await http.fetch('/health');
  console.log('health status', res.status);
})();

這段展示了工廠方法在真實專案的價值:

抽象產品介面(HttpClient)。

依環境產不同產品。

Decorator 增強能力(重試/逾時)。

工廠方法非同步 + 快取,呼叫端乾淨。


總結

Factory Method 的核心,不是炫技,而是把變化點隔離:

        將「建立什麼」的決策收斂到工廠方法;

        讓「怎麼使用」的流程維持穩定;

        新增變體時不動舊碼,可靠地擴充。

在 JavaScript 世界,你可以用類別繼承實作,也能以函式工廠 + 註冊表達到同樣目的。記住幾個關鍵:穩定介面、一致 async 策略、昂貴資源快取、避免 eval、資料驅動擴充。

做到這些,你的程式碼會更耐久,也更能通過長期維護的考驗。


延伸閱讀推薦:

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

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)
較新的 較舊