寫程式久了,你一定遇過那種「如果…否則如果…再否則」一路排開的判斷地獄。規則一多,維護就像在拆疊疊樂,手一抖就倒。
責任鏈(Chain of Responsibility)換個思路:把每個規則做成獨立小節點,照順序串起來,該處理就處理,不行就丟給下一棒,直到有人接住為止。
好處是邏輯鬆耦合、順序能調、節點能替換,變更不再牽一髮動全身。本文用 JavaScript 從同步到非同步、從表單驗證到 API 中介層,一步步帶你落地這個模式,還會提醒常見雷點:短路沒設好、next() 誤用、異步沒 await、順序擺錯等。讀完你就能把原本一坨的 if-else,拆成清爽可測的流程線,專注在規則本身,而不是跟結構拔河。希望本篇文章能夠幫助到需要的您。
目錄
{tocify} $title={目錄}
什麼是責任鏈?為何需要它
定義:責任鏈(Chain of Responsibility, CoR)將一連串「處理者(Handler)」按順序串起來。當請求到來時,會沿著鏈逐一傳遞,直到某個處理者決定處理或中止,其餘處理者無需知道彼此的細節。這使得發送者與接收者之間鬆耦合,可在不改動呼叫端的情況下,動態替換、重排或擴充處理節點。
動機:
規則/步驟會持續變動(像審批、驗證、風控、清洗資料),不想把判斷全部塞進 if-else 地獄。
希望不同團隊/模組能獨立迭代自己的處理節點。
需要短路(short-circuit):一旦滿足條件就停止後續節點(如快取命中)。
需要可觀測與可測試的處理管線。
模式結構與關鍵術語
[Client] → [HandlerA] → [HandlerB] → [HandlerC] → ...
| | |
canHandle? canHandle? canHandle?
| | |
do do do
↓ ↓ ↓
stop/next stop/next stop/next
Handler(處理者):負責決定是否處理當前請求;可選擇終止或傳遞給下一個處理者。
next:指向下一個處理者的引用或函式。
短路:當某節點處理完成並決定終止傳遞,鏈路即刻結束。
可組態/可插拔:節點可新增、移除、重排,不影響呼叫端。
何時該用;何時不該用
適用情境
規則多且會變(表單驗證、折扣審批、內容過濾、資料清洗、日誌路由)。
需要根據條件早停(快取命中、權限不符、風險攔截)。
需要分層責任,讓不同團隊維護不同節點。
不適用情境
僅有 1~2 個固定規則,責任鏈成本大於收益。
節點之間存在大量強耦合共享狀態(會造成難以觀測的副作用)。
需要並行處理(CoR 是線性序列;若要並行,改用工作流/事件流或 Pipe & Filter with fan-out)。
JavaScript 核心實作
1. 物件導向(OOP)版本
class Handler {
setNext(next) {
this.next = next;
return next; // 方便鏈式呼叫
}
handle(request) {
if (this.next) return this.next.handle(request);
return null; // 或丟錯、或回傳預設值
}
}
class CacheHandler extends Handler {
constructor(cache) {
super();
this.cache = cache;
}
handle(request) {
const hit = this.cache.get(request.key);
if (hit) return { source: 'cache', data: hit }; // 短路
return super.handle(request);
}
}
class ApiHandler extends Handler {
constructor(api) {
super();
this.api = api;
}
handle(request) {
const data = this.api.fetchSync(request.key); // 假裝同步
if (data) return { source: 'api', data };
return super.handle(request);
}
}
class FallbackHandler extends Handler {
handle(request) {
return { source: 'fallback', data: null };
}
}
// 建鏈
const cache = new Map([['user:1', { id: 1, name: 'Ada' }]]);
const api = { fetchSync: (k) => (k === 'user:2' ? { id: 2, name: 'Bob' } : null) };
const chain = new CacheHandler(cache);
chain
.setNext(new ApiHandler(api))
.setNext(new FallbackHandler());
// 使用
console.log(chain.handle({ key: 'user:1' })); // 命中快取 → 早停
console.log(chain.handle({ key: 'user:2' })); // 走到 API
console.log(chain.handle({ key: 'user:3' })); // 落到 Fallback
2. 函式式(Functional)版本:中介/管線風格
const composeChain = (handlers) => (context) => {
let index = -1;
const dispatch = (i) => {
if (i <= index) throw new Error('next() called multiple times');
index = i;
const handler = handlers[i];
if (!handler) return context; // 鏈尾預設回傳
return handler(context, () => dispatch(i + 1));
};
return dispatch(0);
};
// 範例節點
const required = (ctx, next) => {
if (!ctx.value) return { ok: false, reason: 'required' }; // 短路
return next();
};
const isEmail = (ctx, next) => {
const re = /\S+@\S+\.\S+/;
if (!re.test(ctx.value)) return { ok: false, reason: 'format' };
return next();
};
const ok = (ctx) => ({ ok: true, value: ctx.value });
// 建鏈與呼叫
const validateEmail = composeChain([required, isEmail, ok]);
console.log(validateEmail({ value: 'a@b.com' })); // { ok: true, value: ... }
console.log(validateEmail({ value: '' })); // { ok: false, reason: 'required' }
重點:next() 代表傳遞;若直接回傳結果即為短路。此寫法貼近 Express/Koa 的中介層心智模型。
非同步版本:async/await 與錯誤傳遞
多數實務需要 I/O(API、DB、唯一性檢查…),責任鏈就要支援 async。
const composeAsync = (handlers) => async (ctx) => {
let index = -1;
const dispatch = async (i) => {
if (i <= index) throw new Error('next() called multiple times');
index = i;
const handler = handlers[i];
if (!handler) return ctx;
return handler(ctx, () => dispatch(i + 1));
};
return dispatch(0);
};
// 非同步驗證:檢查信箱是否已存在
const uniqueEmail = async (ctx, next) => {
const exists = await ctx.repo.existsEmail(ctx.value);
if (exists) return { ok: false, reason: 'duplicated' };
return next();
};
// 組合
const asyncValidateEmail = composeAsync([required, isEmail, uniqueEmail, ok]);
(async () => {
const repo = { existsEmail: async (v) => v === 'used@site.com' };
console.log(await asyncValidateEmail({ value: 'new@site.com', repo })); // ok
console.log(await asyncValidateEmail({ value: 'used@site.com', repo })); // duplicated
})();
錯誤處理:
鏈內若拋出錯誤,建議在最外層包一個「錯誤攔截器」節點,統一打點與回應轉換。
前端場景:表單驗證管線
1. 同步+非同步混搭
const minLen = (n) => (ctx, next) =>
(ctx.value?.length ?? 0) >= n ? next() : { ok: false, reason: 'minlen', n };
const checkBannedWords = (banned) => (ctx, next) =>
banned.some(w => ctx.value.includes(w)) ? { ok: false, reason: 'banned' } : next();
const remotePolicy = async (ctx, next) => {
const pass = await ctx.api.checkPolicy(ctx.value); // e.g. 內容審核
if (!pass) return { ok: false, reason: 'policy' };
return next();
};
const validateNickname = composeAsync([
required,
minLen(3),
checkBannedWords(['admin', 'root']),
remotePolicy,
ok,
]);
UX 建議:
鏈內同步規則先跑,立刻給回饋;非同步規則用 Debounce 或失焦觸發,避免每字一請求。
將 reason 轉為可在 UI 顯示的訊息表。
後端場景
1. 日誌分級(同步示例)
class LevelHandler extends Handler {
constructor(level, writer) {
super();
this.level = level;
this.writer = writer;
}
handle({ level, message }) {
if (level >= this.level) {
this.writer.write(`[${new Date().toISOString()}] ${message}`);
// 不 return super.handle(...) → 在此短路(單路由)
return true;
}
return super.handle({ level, message });
}
}
// writer 可是檔案/主控台/遠端
2. API 中介層(Express/Koa 風格)
const timing = async (ctx, next) => {
const t0 = Date.now();
try {
await next();
} finally {
ctx.res.setHeader('X-RTT', Date.now() - t0);
}
};
const auth = async (ctx, next) => {
const token = ctx.req.headers['authorization'];
if (!token) { ctx.res.statusCode = 401; ctx.res.end('Unauthorized'); return; } // 短路
ctx.user = await ctx.auth.verify(token);
return next();
};
const rateLimit = async (ctx, next) => {
if (!(await ctx.limiter.allow(ctx.user.id))) {
ctx.res.statusCode = 429; ctx.res.end('Too Many Requests'); return; // 短路
}
return next();
};
// 最終處理器
const controller = async (ctx) => {
ctx.res.end(JSON.stringify({ ok: true, user: ctx.user.id }));
};
const app = composeAsync([timing, auth, rateLimit, controller]);
設定驅動(Config-Driven)責任鏈與動態擴充
當規則常變,改碼太重。可用註冊表 + 工廠按設定產生鏈。
const registry = {
required,
isEmail,
uniqueEmail,
ok,
// ...更多節點
};
function buildChainFromConfig(names) {
const handlers = names.map(n => {
const h = registry[n];
if (!h) throw new Error(`unknown handler: ${n}`);
return h;
});
return composeAsync(handlers);
}
// 來自資料庫或遠端設定
const config = ['required', 'isEmail', 'uniqueEmail', 'ok'];
const chain = buildChainFromConfig(config);
加值點:
可對節點做版本化(uniqueEmail@v2);
以權限/租戶動態決定節點集合;
搭配優先權排序(根據成本由低到高排序,把短路機率高且便宜的放前面)。
觀測性與效能
1. 短路與順序
將快速拒絕與快取命中放在最前面。
觀察每個節點的「命中率」與「平均耗時」,定期重排。
2. 追蹤與打點
在外層包「追蹤節點」,為每次請求生成 traceId,記錄每個節點的開始/結束/結果。
鏈失敗時,輸出完整的節點軌跡(方便除錯)。
const trace = async (ctx, next) => {
ctx.traceId ??= crypto.randomUUID?.() || String(Date.now());
const t0 = Date.now();
try {
return await next();
} finally {
console.log(`[trace ${ctx.traceId}] cost=${Date.now() - t0}ms`);
}
};
3. 重試與熔斷(進階)
在容易短暫失敗的節點(例如外部 API)包「重試節點」與「熔斷器」。
留意冪等性(idempotency):重試需可安全重入。
與其他模式的比較
| 模式 | 類似點 | 差異點 | 何時選它 |
|---|---|---|---|
| Chain of Responsibility | 串行處理、可短路 | 節點逐一嘗試、可早停 | 規則多且變動、需要早停 |
| Decorator | 一層層包裹 | 不改變責任分派,重在「增強」 | 功能疊加(快取、記錄、驗證)但不需早停 |
| Strategy | 可替換 | 單一策略一次選定 | 只需「選一個策略」而非多節點串行 |
| Middleware(Express/Koa) | 幾乎就是函式式 CoR | 框架化慣用型 | Web 伺服器/應用層處理管線 |
| Pipe & Filter | 由多個步驟構成管線 | 強調資料「轉換」;常可並行 | 資料清洗/批處理/串流加工 |
測試策略與可維護性
單元測試
每個 Handler 都是純函式或盡量最小副作用,易測。
用 Spy 檢查 next() 是否被呼叫、被呼叫幾次。
對錯誤/短路路徑設計覆蓋。
test('required should short-circuit when empty', async () => {
const next = vi.fn();
const res = await required({ value: '' }, next);
expect(res).toEqual({ ok: false, reason: 'required' });
expect(next).not.toHaveBeenCalled();
});
整合測試
用 composeAsync([...]) 建立實際鏈,餵入假資料庫/API。
驗證順序、早停、邊界(空值、極長字串、時間到期)。
維護建議
節點以單一職責命名與實作(checkPermission、validateSchema)。
給每個節點文件註解與契約(輸入/輸出、錯誤碼)。
用資料夾分層:/handlers, /pipelines, /registry.
常見錯誤與雷點
1. 過度一般化:將所有規則塞進單一「萬用節點」,變相成為上帝物件。
2. 沒有明確「鏈尾」行為:鏈走完後回傳 undefined,導致呼叫端行為不定。
3. next() 多次呼叫:同一節點重入導致難以查錯(示範程式有防呆)。
4. 異步未 await:鏈提早結束、錯誤逸出。
5. 吞錯:節點 try/catch 後不拋出或不回傳失敗,造成消失的錯誤。
6. 迴圈引用:A→B→C→A,導致無窮遞迴。建鏈時需檢查或保持不可變陣列。
7. 狀態共享不當:節點透過全域物件互相耦合,測試與重用困難。
8. 順序不佳:高成本節點在前、低命中短路節點在後,拖慢整體吞吐量。
9. 沒有觀測性:無 traceId、無打點,生產事故很痛。
10. 缺少版本化:新規則上線覆蓋舊行為,回溯困難。
11. 責任鏈濫用:只有單一步驟也硬用鏈,增加複雜度。
12. 不處理併發:需要並行的情境誤用線性鏈,性能不佳。
13. 重試不冪等:重試造成重複寫入或重複扣款。
14. 錯誤碼/原因不一致:前端無法對應訊息表。
15. 配置不校驗:Config 驅動時未檢查未知節點或順序衝突。
問題集
Q1:責任鏈和中介層(middleware)是一樣的嗎?
A:在 Web 世界(Express/Koa)中,middleware 幾乎就是責任鏈的函式式實作。差別主要是語境與框架約定。
Q2:如何在鏈中同時需要「全部都跑」與「可早停」?
A:拆成兩段:第一段允許早停(快篩),第二段強制全跑(完整校驗)。或改用事件/並行機制處理「全部都跑」。
Q3:鏈節點需要回傳同一資料結構嗎?
A:強烈建議。使用 ctx 物件傳遞與累積狀態,結果統一由最後節點或短路節點輸出。
Q4:可以在鏈中動態插入節點嗎?
A:可以。用註冊表+工廠根據條件建鏈,或使用代理層在 runtime 插入。
Q5:如何避免規則散落、難以搜尋?
A:統一在 /handlers 目錄,每個檔案一個節點,檔名即語意;建立索引與自動載入(globbing)。
延伸閱讀推薦:
javaScript設計模式 : Factory Method (工廠方法)
javaScript設計模式 : Abstract Factory(抽象工廠)
javaScript設計模式 : Builder(建造者模式)
javaScript設計模式 : Prototype(原型)
javaScript設計模式 : Singleton (單例模式)
javaScript設計模式 : Adapter(轉接器模式)
javaScript設計模式 : Bridge( 橋接模式 )
javaScript設計模式 : Composite(組合模式)
javaScript設計模式 : Decorator(裝飾者)
javaScript設計模式 : Flyweight(享元模式)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
javaScript設計模式 : Strategy(策略模式)
