從設計模式開始:用單例、觀察者與策略模式重構前端程式碼實戰指南

如果你已經能用 Vue 3 或 React 寫出功能正常的頁面,卻常在專案跑了半年後發現:一個元件塞了八百行、if-else 疊了十幾層、改一個小需求要動五個檔案——那這篇就是為你寫的。設計模式不是面試考題,而是把「別人踩過的坑」整理成可複用結構的工具。今天不談抽象理論,直接用 TypeScript 把幾個最實用的模式落進前端專案。

一、為什麼前端特別需要設計模式

後端談設計模式談了二十幾年,前端卻常被誤以為「不就是切個頁面」。但現代前端早已是重狀態、重非同步、重互動的複雜系統:一份表單可能有十種驗證規則,一個看板可能同時接三個資料來源。設計模式解決的不是「能不能跑」,而是「三個月後還改不改得動」。

  • 降低耦合:模組之間靠約定好的介面溝通,而不是互相 import 到底。
  • 擴充不動舊碼:新增一種規則就多一個檔案,而不是在舊函式裡再插一個 else if。
  • 統一團隊語彙:說「這裡用策略模式」,比說「你把那個判斷抽出去」精準十倍。

二、步驟 1:準備一個可執行的實驗環境

不需要拉整套框架,一個能跑 TypeScript 的資料夾就夠了:

mkdir fe-patterns && cd fe-patterns
npm init -y
npm i -D typescript tsx
npx tsc --init --target es2020 --module esnext --moduleResolution bundler
mkdir src

之後每個範例都用 npx tsx src/xxx.ts 直接執行,改完立刻看結果,不用等打包。

三、步驟 2:單例模式(Singleton)——全域只要一份

最典型的場景是「設定中心」或「HTTP 客戶端」:整個應用只該有一份實例,否則 token 存了兩份、快取各自失效。

// src/singleton.ts
class AppConfig {
  private static instance: AppConfig | null = null;
  private data = new Map<string, unknown>();

  private constructor() {}          // 禁止外部 new

  static getInstance(): AppConfig {
    if (!AppConfig.instance) {
      AppConfig.instance = new AppConfig();
    }
    return AppConfig.instance;
  }

  set(key: string, value: unknown) { this.data.set(key, value); }
  get<T>(key: string): T | undefined { return this.data.get(key) as T; }
}

AppConfig.getInstance().set('apiBase', '/api/v1');
console.log(AppConfig.getInstance().get<string>('apiBase')); // /api/v1

重點:在 ES Module 環境裡,模組本身就有快取,所以直接 export const config = new AppConfig() 也是一種單例。用 getInstance 的價值在於「延遲初始化」——真正第一次用到才建立,避免啟動時就去讀 localStorage。

特別注意:單例本質是全域狀態,濫用會讓單元測試互相污染。測試時記得提供一個 reset() 方法,或改用依賴注入。

四、步驟 3:觀察者模式(Observer)——手寫一個事件總線

跨層級元件通訊、非同步任務完成後通知多方,都是觀察者的主場。Vue 3 移除了內建的 EventBus,自己寫一個反而更可控:

// src/observer.ts
type Handler<T> = (payload: T) => void;

class EventBus {
  private map = new Map<string, Set<Handler<any>>>();

  on<T>(event: string, fn: Handler<T>): () => void {
    if (!this.map.has(event)) this.map.set(event, new Set());
    this.map.get(event)!.add(fn);
    return () => this.off(event, fn);   // 回傳取消訂閱函式
  }

  off<T>(event: string, fn: Handler<T>) {
    this.map.get(event)?.delete(fn);
  }

  emit<T>(event: string, payload: T) {
    this.map.get(event)?.forEach((fn) => {
      try { fn(payload); } catch (e) { console.error('[bus]', event, e); }
    });
  }
}

export const bus = new EventBus();

const stop = bus.on<number>('score', (n) => console.log('收到分數', n));
bus.emit('score', 99);   // 收到分數 99
stop();                  // 元件卸載時務必呼叫
bus.emit('score', 100);  // 沒有輸出

重點:on 回傳取消訂閱函式這個設計非常關鍵。在 Vue 裡直接 onUnmounted(bus.on(‘score’, fn)) 一行搞定,是前端記憶體洩漏最常見的破口。emit 內用 try/catch 包住,可避免一個訂閱者拋錯就中斷其餘通知。

五、步驟 4:策略模式(Strategy)——消滅 if-else 地獄

表單驗證、費用計算、匯出格式,這類「同一件事有多種做法」的需求,最容易寫成一長串 switch。策略模式把每種做法變成獨立單元:

// src/strategy.ts
type Rule = (value: string) => string | null;   // 回傳錯誤訊息或 null

const rules: Record<string, Rule> = {
  required: (v) => (v.trim() ? null : '此欄位必填'),
  email: (v) => (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v) ? null : 'Email 格式不正確'),
  mobile: (v) => (/^1[3-9]\d{9}$/.test(v) ? null : '手機號格式不正確'),
};

function validate(value: string, names: string[]): string[] {
  return names.map((n) => rules[n]?.(value)).filter(Boolean) as string[];
}

console.log(validate('', ['required']));            // [ '此欄位必填' ]
console.log(validate('a@b.com', ['required','email'])); // []

重點:新增一種驗證只要往 rules 加一個 key,validate 這個函式永遠不用改——這就是「對擴充開放、對修改封閉」的具體樣貌。要跨專案共用時,把 rules 拆成獨立檔案再用 Object.assign 合併即可。

六、步驟 5:工廠模式(Factory)——把「建立什麼」集中管理

資料看板要接後端 API、CSV 檔、WebSocket 三種來源時,呼叫方不該關心細節:

// src/factory.ts
interface DataSource { fetch(): Promise<unknown[]>; }

class ApiSource implements DataSource {
  async fetch() { return [{ from: 'api' }]; }
}
class CsvSource implements DataSource {
  async fetch() { return [{ from: 'csv' }]; }
}

export function createSource(type: 'api' | 'csv'): DataSource {
  const map = { api: ApiSource, csv: CsvSource };
  const Ctor = map[type];
  if (!Ctor) throw new Error('未知的資料來源:' + type);
  return new Ctor();
}

createSource('csv').fetch().then(console.log);

之後要加 WebSocket 來源,只需寫一個 class 並在 map 補一行,上層業務碼零改動。

七、實戰建議與常見誤區

  • 先有痛點再上模式:只有兩個分支就寫策略工廠,是把簡單問題複雜化。判斷標準是「這裡未來還會不會長出第三、第四種」。
  • 模式不是類別的專利:前端用函式與閉包實作策略、觀察者往往比 class 更輕。上面的 rules 就是純物件版策略模式。
  • 優先用框架已有能力:Pinia 本身就是狀態集中管理,Vue 的 provide/inject 就是輕量依賴注入,別重造輪子。
  • 訂閱必解除:事件總線、ResizeObserver、setInterval,凡是註冊過的都要在卸載時清掉。

把這四個模式練熟,你會發現重構時的直覺變了:看到長長的 switch 就想抽策略,看到跨元件亂傳事件就想到總線。設計模式的真正價值,是讓你在寫下第一行程式碼前,就先想好它三個月後會怎麼被修改。

互動話題:你在專案裡最常用到哪個設計模式?有沒有遇過「模式用過頭反而更難維護」的情況?歡迎在留言區分享你的重構故事,我們一起討論怎麼在「靈活」與「簡單」之間找平衡!