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

 


寫程式久了,你一定遇過那種「如果…否則如果…再否則」一路排開的判斷地獄。規則一多,維護就像在拆疊疊樂,手一抖就倒。

責任鏈(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設計模式 : Facade(外觀模式)

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

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

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

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

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

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

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

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

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

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

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

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


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