如果你已經能用 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 就想抽策略,看到跨元件亂傳事件就想到總線。設計模式的真正價值,是讓你在寫下第一行程式碼前,就先想好它三個月後會怎麼被修改。
互動話題:你在專案裡最常用到哪個設計模式?有沒有遇過「模式用過頭反而更難維護」的情況?歡迎在留言區分享你的重構故事,我們一起討論怎麼在「靈活」與「簡單」之間找平衡!