註冊表分派 side effect(Registry + Strategy Handler)
核心概念:用一張 以 key 為索引的 handler 註冊表(factory/registry)+ Strategy 介面(每個 handler 自帶判斷、去重、執行),把「一堆 closed job 各自要觸發什麼副作用」從一長串 if/else 收斂成開放封閉的分派。dispatcher 只負責查表、去重、呼叫,完全不認得任何一種 workflow 的細節。 具體案例:CarbonX / Souffle MR !1134 — closed-job toast handler framework。
我做了什麼
在 project activity indicator 的輪詢資料上,加一層通用的「closed job → 副作用」分派框架:
- 註冊表(factory)
CLOSED_JOB_HANDLERS: Record<workflowType, ClosedJobHandler>。以workflowType字串當 key 選出行為;兩個 bulk-create workflow 共用同一個 handler 實例。 - Strategy 介面
ClosedJobHandler:三個 seam 分工清楚shouldHandle(job)— 這筆該不該處理(例如COMPLETED且hasNewTransportationSegments)getDedupeKey(job)— 去重的 key(用runId)run(job, ctx)— 真正的副作用,但只能透過 ctx 的能力做事
- 能力介面(依賴反轉)
ClosedJobHandlerContext— handler 不 import 任何 React hook / toast,只拿到被注入的showTransportationSegmentsToast、showBulkCreatedActivityDataToast。handler 因此是純資料/邏輯,好測、好搬。 - dispatcher hook
useClosedJobHandlers:讀 closedJobs → 查表 →shouldHandle→ 去重 → 先 mark 再 run(擋 re-entrant 重複觸發)。 - 共用去重 infra
closedJobHandledStorage:全域 localStorage{ runId: timestamp },15 分 TTL、寫入時 prune,try/catchfail-safe。跨輪詢、跨重新整理都只觸發一次。 - presenter 各自成 hook:
useNewTransportationSegmentsToast(常駐可關、per-runId toast id、切換專案時關掉)、useBulkCreatedActivityDataToast(自動消失的成功 toast)。決策在 dispatcher、呈現在 presenter,分離。
flowchart TD poll["closedJobs(來自輪詢 store)"] --> disp subgraph disp["useClosedJobHandlers(dispatcher)"] lookup["查 CLOSED_JOB_HANDLERS[job.workflowType]"] guard{"shouldHandle(job)?"} dedupe{"hasHandledJob(dedupeKey)?"} mark["markJobHandled → handler.run(job, ctx)"] lookup --> guard guard -->|否| skip["跳過"] guard -->|是| dedupe dedupe -->|已處理| skip dedupe -->|沒有| mark end mark -->|ctx 能力| pres["presenter hook(toast)"] classDef reg fill:#e3f2fd,stroke:#1565c0,color:#0d1b2a; classDef infra fill:#fff8e1,stroke:#f9a825,color:#0d1b2a; class lookup reg; class dedupe,mark infra;
關鍵程式碼(只留骨架)
// closedJobHandlers.ts — Strategy 介面 + 註冊表(factory)
interface ClosedJobHandlerContext { // 注入能力:依賴反轉,handler 不碰 React/toast
showTransportationSegmentsToast: (runId: string) => void;
showBulkCreatedActivityDataToast: (count: number) => void;
}
interface ClosedJobHandler {
shouldHandle: (job: InFlightJobItem) => boolean; // 該不該處理
getDedupeKey: (job: InFlightJobItem) => string; // 去重 key
run: (job: InFlightJobItem, ctx: ClosedJobHandlerContext) => void; // 只透過 ctx 做事
}
const CLOSED_JOB_HANDLERS: Record<string, ClosedJobHandler> = {
[TRANSPORTATION_AGGREGATE_WORKFLOW]: {
shouldHandle: (job) => job.status === COMPLETED && job.hasNewTransportationSegments === true,
getDedupeKey: (job) => job.runId,
run: (job, ctx) => ctx.showTransportationSegmentsToast(job.runId),
},
// 兩個 bulk-create workflow 共用同一 handler 實例…
};// useClosedJobHandlers.ts — dispatcher 核心:查表 → 守衛 → 去重 → 先 mark 再 run
closedJobs.forEach((job) => {
const handler = CLOSED_JOB_HANDLERS[job.workflowType];
if (!handler?.shouldHandle(job)) return;
const key = handler.getDedupeKey(job);
if (hasHandledJob(key)) return;
markJobHandled(key); // ★ 先 mark 再 run,擋 re-entrant 重複觸發
handler.run(job, ctx);
});// closedJobHandledStorage.ts — 去重 infra:localStorage + TTL + fail-safe
export const markJobHandled = (runId: string) => {
try {
const now = Date.now();
const pruned = prune(read(), now); // 寫入時剪掉過期,storage 不會無限成長
pruned[runId] = now;
localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned));
} catch { /* best-effort:storage 掛了就降級成「去重失效」,不弄壞頁面 */ }
};優點
依賴圖說明為什麼這些優點成立:ClosedJobHandlerContext(抽象)是樞紐,handler(高層策略)和 presenter(低層細節)都指向它 → 依賴反轉;dispatcher 只依賴註冊表 + 去重 infra + 抽象,不認得任何 concrete workflow → 開放封閉。
graph TD disp["useClosedJobHandlers<br/>dispatcher"] reg["CLOSED_JOB_HANDLERS<br/>註冊表(factory)"] infra["closedJobHandledStorage<br/>去重 infra"] ctx(["ClosedJobHandlerContext<br/>«能力介面 / 抽象»"]) h["ClosedJobHandler<br/>strategy(高層策略)"] p1["useNewTransportationSegmentsToast<br/>presenter(低層細節)"] p2["useBulkCreatedActivityDataToast<br/>presenter(低層細節)"] disp --> reg disp --> infra reg --> h disp -. 建構並注入 .-> ctx h -. 只依賴抽象 .-> ctx p1 -. 實作能力 .-> ctx p2 -. 實作能力 .-> ctx classDef abs fill:#ede7f6,stroke:#5e35b1,color:#0d1b2a,stroke-width:2px; classDef hi fill:#e3f2fd,stroke:#1565c0,color:#0d1b2a; classDef lo fill:#e8f5e9,stroke:#2e7d32,color:#0d1b2a; class ctx abs; class disp,reg,infra,h hi; class p1,p2 lo;
- 開放封閉:新增一種 workflow 反應=在註冊表加一筆 + (必要時) 一個 presenter,dispatcher 迴圈完全不動。
- 關注點分離:判斷(shouldHandle)/去重(getDedupeKey)/副作用(run)/呈現(presenter)四層各管一件事。
- 依賴反轉、好測:handler 不碰 React / toast,靠 ctx 注入能力 → 測試直接 mock presenter,不用真的渲染 toast。
- 冪等內建:localStorage + TTL + 「先 mark 再 run」把「至多觸發一次」做進基礎設施,撐得住輪詢與 reload。
- fail-safe:storage 例外時吞掉、降級成「去重失效」而非整頁壞掉。
- 可複用:多個 workflow 共用同一 handler 實例。
- 測試涵蓋完整(去重、TTL、專案切換關 toast、count=0 不觸發…)。
缺點 / 取捨
- 能力介面會膨脹 → 詳見 共用能力 context 會隨副作用種類膨脹:每加一種副作用就撐大共用的
ClosedJobHandlerContext,dispatcher 又受 rules of hooks 限制必須無條件備齊。改善方案見 用 reaction hook 讓副作用各自帶依賴。 - handler 難以吃 runtime 狀態:handler 是 module-level 靜態物件,任何動態值(enterprise 設定、feature flag)都得先在 hook 裡塞進 ctx 才拿得到。
- 隱性跨服務不變式:15 分 TTL 必須 > 後端
CLOSED_DISPLAY_WINDOW_MS(5 分),這條契約只寫在註解裡。後端一改,前端可能悄悄重複觸發或漏觸發。 - 1 個 workflowType 只能對 1 個 handler:同一 workflow 想有多種反應得手動組合。
- 全域單一 localStorage key、未依 enterprise/project 分租:runId 是 UUID 幾乎不撞,但多分頁併發寫入時 prune 的 last-write-wins 有輕微 race,且無跨分頁鎖。
- 呈現策略微微外洩:哪種 toast 切換專案要關(只有 transportation 記進
shownRunIdsRef)這個政策寫在 dispatcher 裡。
之後可以怎麼擴充
- 新增反應(最常見):加一個
WORKFLOW常數 → 註冊表加一筆 handler →(若需新副作用)擴 ctx + 新 presenter。既有 dispatcher 不動。 - 一對多 handler:把
Record<string, ClosedJobHandler>改成Record<string, ClosedJobHandler[]>並迭代,支援同 workflow 多反應。 - 讓副作用各自帶依賴 → 詳見 用 reaction hook 讓副作用各自帶依賴:改成每個反應是一個自帶依賴的 hook,解決 ctx bloat 與靜態限制。
- 拆掉跨服務隱性契約:把 close-window 從後端 DTO 帶出來,或加一條 contract test,把 15 分 vs 5 分的耦合顯性化。
- 去重 key 泛化:
getDedupeKey已是 seam,可改成runId + segmentType之類更細的粒度。
相關
- 註冊表本身是 module-level 的 JS 中的 Singleton(查表即單例)。
- 這個 pattern 的最大取捨:共用能力 context 會隨副作用種類膨脹(問題)↔ 用 reaction hook 讓副作用各自帶依賴(解法)。
- 對照:
useAggregationCompletionFactorToast因為要非同步 diff recommendations(unsetCount),不是單純的 closed-job 旗標,所以刻意留在框架外——提醒「不是所有觸發都該塞進同一個註冊表」。