專案越做越大,功能之外的「橫切關注」會悄悄侵蝕可讀性:每個函式都塞 Log、try/catch、計時、快取與防抖,改一處牽動全局。Decorator 讓我們把這些關注點抽離、模組化、再以「組合」回到需要的位置。
這篇文章走務實路線:先釐清何時該用(以及何時不該用),再用三個層級示範——包物件、包函式、包方法——並提供可直接複製的範例。你會看到如何寫出不吞錯的重試、可設定 TTL 的 memoize、可撤銷的 method 裝飾、以及以 Proxy 做「一網打盡」的通用攔截。最後整理一份雷點清單與測試策略,幫你把裝飾變成可靠的結構,而不是下一個技術債起點。希望本篇文章可以幫助到需要的您。
目錄
{tocify} $title={目錄}
為什麼需要 Decorator?
裝飾者(Decorator)模式的核心精神是「不改動既有核心邏輯,透過外層包裝增添能力」。相比一股腦使用繼承拉滿子類,Decorator 提供更好的組合與彈性:
開放封閉原則(OCP):對擴充開放、對修改封閉。
細粒度職責:每個裝飾只做一件事(Log、快取、權限、重試…)。
可堆疊:根據情境決定要不要加、要加幾層、順序如何。
可回收:必要時移除裝飾,不破壞原物件。
和其他模式的界線
與繼承:Decorator 偏向組合(Composition over Inheritance);繼承容易僵化,Decorator 更彈性。
與 Proxy:Proxy 是語言層的攔截器;Decorator 是設計層的增能手法。Decorator 可以用 Proxy 實作,但不等同。
與 Middleware:Middleware(如 Express)是「管線式處理」;Decorator 更偏「針對單一物件/函式增能」。
什麼時候用?什麼時候不要用?
適用情境
為既有功能加上:快取、重試、權限、節流、防抖、記錄、計時、追蹤、輸入驗證、熔斷、限流。
動態組合需求:A/B 測試、不同租戶客製化、功能旗標(feature flag)逐步開關。
不適用情境
核心資料結構/演算法仍不穩定,Decorator 會讓可觀察面變複雜。
功能其實是策略(Strategy)切換比較合理(例如完全不同的算法版本)。
三條實作路線圖
1. 物件/類別裝飾:
包著一個具備方法的物件,覆寫或延伸少數行為(典型教科書版)。
2. 函式裝飾(高階函式):
以函式包函式,是前端/Node 最常見也最輕量的做法。
3. 方法裝飾(不靠語法糖):
針對 class instance 的某個方法進行替換包裝,支援可撤銷。
註:語法層的 @decorator(TC39 Decorators 提案)在不同工具鏈/版本有細節差異。本文不倚賴語法糖,採純 JavaScript實務寫法;若你用 TypeScript/Babel,可把本文概念直接搬過去。
物件/類別裝飾:以「電商結帳」為例
我們先定義一個最小可用的價格計算器,再以裝飾者逐層加上稅金、優惠券、運費等規則。
// 核心元件:只負責輸出「原始小計」
// 保持簡單,方便測試與覆用
class BasePrice {
constructor(amount) {
this.amount = amount; // 金額以整數(分/cent)保存較安全
}
total() {
return this.amount;
}
}
// 裝飾者抽象基類(可選)
// 所有裝飾者都持有一個 component,並預設透傳 total()
class PriceDecorator {
constructor(component) {
this.component = component;
}
total() {
return this.component.total();
}
}
// 稅金裝飾:加上稅率
class TaxDecorator extends PriceDecorator {
constructor(component, rate) {
super(component);
this.rate = rate; // 例如 0.05 代表 5%
}
total() {
const base = super.total();
return Math.round(base * (1 + this.rate));
}
}
// 優惠券裝飾:固定折抵
class CouponDecorator extends PriceDecorator {
constructor(component, discount) {
super(component);
this.discount = discount; // 正整數(分/cent)
}
total() {
const base = super.total();
return Math.max(0, base - this.discount);
}
}
// 運費裝飾:滿額免運,否則加固定運費
class ShippingDecorator extends PriceDecorator {
constructor(component, threshold, fee) {
super(component);
this.threshold = threshold;
this.fee = fee;
}
total() {
const base = super.total();
return base >= this.threshold ? base : base + this.fee;
}
}
// 使用:動態堆疊,順序可調
const order = new BasePrice(50000); // $500.00 => 50000 cents
const decorated = new ShippingDecorator(
new CouponDecorator(
new TaxDecorator(order, 0.05), // 先課稅
5000 // 再折 $50
),
40000, // 滿 $400 免運
3000 // 否則運費 $30
);
console.log(decorated.total()); // -> ?依順序而異
順序很重要(商務邏輯差很大)
先稅後扣 vs 先扣後稅,金額不同。請將順序視為需求的一部分,文件化並寫測試。
物件裝飾的優點
與教科書一致,結構清楚;每個裝飾單一責任。
與多型(Duck Typing)相容:外界只要呼叫 .total() 即可。
缺點
類別數量可能膨脹;若裝飾種類多、組合複雜,維護成本上升。
若只想改一兩個方法,寫一個類別可能有點重。
函式裝飾(高階函式):最常用也最靈活
把「裝飾」想成包一層函式,對同步與非同步函式都通用。
1. 記錄與計時裝飾(同步/非同步皆適用)
const isPromise = v => v && typeof v.then === 'function';
function withLogAndTiming(fn, { label = fn.name || 'anonymous' } = {}) {
return function wrapped(...args) {
const start = (typeof performance !== 'undefined' ? performance.now() : Date.now());
try {
const result = fn.apply(this, args);
if (isPromise(result)) {
return result.finally(() => {
const end = (typeof performance !== 'undefined' ? performance.now() : Date.now());
console.info(`[${label}] done in ${(end - start).toFixed(1)}ms`);
});
} else {
const end = (typeof performance !== 'undefined' ? performance.now() : Date.now());
console.info(`[${label}] done in ${(end - start).toFixed(1)}ms`);
return result;
}
} catch (err) {
const end = (typeof performance !== 'undefined' ? performance.now() : Date.now());
console.error(`[${label}] failed in ${(end - start).toFixed(1)}ms`, err);
throw err; // 別吞錯
}
};
}
使用:
function sum(a, b) { return a + b; }
const safeSum = withLogAndTiming(sum);
safeSum(2, 3); // 會輸出耗時資訊
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('Network');
return res.json();
}
const wrappedFetchUser = withLogAndTiming(fetchUser, { label: 'fetchUser' });
await wrappedFetchUser(42);
2. 快取/記憶化(Memoization)裝飾(含 TTL 與上限)
function memoize(fn, { key = JSON.stringify, ttl = 0, max = 1000 } = {}) {
const cache = new Map();
return function memoized(...args) {
const k = key(args);
const now = Date.now();
const hit = cache.get(k);
if (hit && (!ttl || now - hit.t <= ttl)) {
return hit.v;
}
const v = fn.apply(this, args);
if (cache.size >= max) {
// 簡單淘汰:刪最舊一筆
const first = cache.keys().next().value;
cache.delete(first);
}
cache.set(k, { v, t: now });
return v;
};
}
注意:若 fn 回傳 Promise,你可能要把 Promise 本身快取(避免重複請求),並處理失敗時移除快取條目,以免快取「錯誤狀態」。
3. 重試 + 指數退避(含非同步)
const sleep = ms => new Promise(r => setTimeout(r, ms));
function withRetry(fn, {
retries = 3,
backoff = attempt => 100 * (2 ** attempt), // 100ms, 200ms, 400ms...
retryOn = err => true, // 可按錯誤類型過濾
} = {}) {
return async function retried(...args) {
let lastErr;
for (let i = 0; i <= retries; i++) {
try {
return await fn.apply(this, args);
} catch (err) {
lastErr = err;
if (i === retries || !retryOn(err)) break;
await sleep(backoff(i));
}
}
throw lastErr;
};
}
4. 節流與防抖(UI/事件常用)
function debounce(fn, delay = 300) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function throttle(fn, interval = 200) {
let last = 0, pendingArgs = null, timer = null;
const invoke = (ctx, args) => { last = Date.now(); fn.apply(ctx, args); };
return function(...args) {
const now = Date.now();
if (now - last >= interval) {
invoke(this, args);
} else {
pendingArgs = args;
clearTimeout(timer);
timer = setTimeout(() => {
if (pendingArgs) invoke(this, pendingArgs);
pendingArgs = null;
}, interval - (now - last));
}
};
}
方法裝飾(不靠語法糖):對 class 的特定方法增能
很多團隊不想引入 @decorator 語法或工具鏈時,可用可撤銷替換法:
// 在「實例」層級包裝方法,回傳一個 undo() 用於恢復
function decorateMethod(instance, methodName, decorator) {
const original = instance[methodName];
if (typeof original !== 'function') {
throw new TypeError(`${methodName} is not a function`);
}
const wrapped = decorator(original);
Object.defineProperty(instance, methodName, {
value: wrapped,
writable: true,
configurable: true, // 允許還原
enumerable: false,
});
return () => { instance[methodName] = original; };
}
// 範例:對特定 API 方法加上重試 + 記錄
class Api {
async fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
}
const api = new Api();
const undo = decorateMethod(
api,
'fetchUser',
fn => withRetry(withLogAndTiming(fn), { retries: 2 })
);
await api.fetchUser(1); // 已套用 log + retry
undo(); // 需要時可恢復
這種做法對 單元測試特別友善(可在 before/after 中暫時裝飾)。
進階:用 Proxy 實作「通用裝飾」
當你想同時攔截多數方法(例如整個 Repository 加上計時與記錄),Proxy 會很順手:
function decorateAll(instance, { before, after, onError } = {}) {
return new Proxy(instance, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (typeof value !== 'function') return value;
return function(...args) {
before?.(prop, args, target);
try {
const result = value.apply(this, args);
if (result && typeof result.then === 'function') {
return result
.then(r => { after?.(prop, r); return r; })
.catch(e => { onError?.(prop, e); throw e; });
}
after?.(prop, result);
return result;
} catch (e) {
onError?.(prop, e);
throw e;
}
};
}
});
}
// 使用
const svc = decorateAll(new Api(), {
before: (name, args) => console.debug(`[${name}] ->`, args),
after : (name, ret) => console.debug(`[${name}] <-`, ret),
onError: (name, e) => console.error(`[${name}] !`, e),
});
注意:Proxy 於熱路徑可能有額外開銷;請以監控數據決定用或不用。
常見錯誤與雷點
1. this 綁定遺失
反例:
const obj = {
x: 10,
getX() { return this.x; }
};
const bad = (fn) => (...args) => fn(...args); // this 遺失
const wrapped = bad(obj.getX);
wrapped(); // TypeError: Cannot read properties of undefined
正解:用 apply/call 或箭頭函式保留 this。
const good = (fn) => function(...args) { return fn.apply(this, args); };
const wrapped2 = good(obj.getX);
wrapped2.call(obj); // 10
2. 忘了回傳原本的結果 / 未補上 await
反例:
const withLog = fn => async (...args) => { console.log('start'); fn(...args); };
正解:
const withLog = fn => async function(...args) {
console.log('start');
const r = await fn.apply(this, args);
console.log('end');
return r;
};
3. 裝飾順序不明確(導致金額、權限判斷錯)
請明確定義順序(如:先驗權 → 後記錄 → 再重試…),並以單元測試固定。
4. 吞錯 / 改變回傳型別
千萬別吞錯;若要包裝錯誤,至少保留原始 cause。
Decorator 應不改變呼叫者對回傳型別的期待。
5. 快取洩漏記憶體
memoize 要設 max 或 TTL,錯誤時移除快取條目。
6. 非同步重試破壞 Idempotency
對非冪等操作(扣款、下單)慎用重試;若要重試,使用請求 ID 或業務側去重。
7. 裝飾影響觀測性與資安
Log 中避免敏感資訊(token、PII);以遮罩或白名單欄位處理。
8. 直接修改被裝飾物件的狀態
優先以包裹(wrap)取代突變(mutate)。必要突變請文件化與測試。
9. 未清理資源(事件監聽器、定時器)
節流/防抖、重試、超時器都可能殘留計時器;適時提供 dispose() 或還原鉤子。
10. Proxy 攔截過度
只攔你需要的方法;避免攔 toString、valueOf 等造成奇怪副作用。
可測試性:怎麼測裝飾者?
單元測試:
對每個裝飾寫小規則的 input/output 測試(ex:Tax 5% 應加多少)。
為順序寫測試(先稅後扣 vs 先扣後稅)。
整合測試:
堆疊多個裝飾,驗證端到端行為。
測試替身:
對外部 I/O(fetch/DB)以假物件或 mock 注入,再裝飾。
可撤銷裝飾:
用 decorateMethod() 回傳 undo(),讓測試更乾淨。
問題集
Q1:Decorator 與 AOP(面向切面程式)是一樣的嗎?
A:概念相近,都是把橫切關注點(logging、metrics、auth)從業務邏輯抽離。AOP 偏框架層切入點;Decorator 更偏開發者手動包裝的細粒度做法。
Q2:在 React 裡的 HOC(Higher-Order Component)算 Decorator 嗎?
A:本質一致:以包裝加入能力(如資料注入、權限檢查)。但 React 官方近年更鼓勵 Hook,HOC 仍可用但留意可讀性與巢狀地獄。
Q3:語法層 @decorator 能不能直接用?
A:視你的工具鏈(TS/Babel/打包器版本)而定。API 細節在不同時期有差異。若你追求跨環境可攜性,本文提供的純 JS 包裝法最穩。
Q4:Express/Koa Middleware 跟 Decorator 的差別?
A:Middleware 是請求管線(多中介組合處理 request/response),範疇偏「流程」。Decorator 偏「物件/函式能力擴充」。
實戰小組合:把多個裝飾串起來
function compose(...decorators) {
return fn => decorators.reduceRight((acc, d) => d(acc), fn);
}
const withTimeout = (fn, ms = 3000) => async function(...args) {
const t = new Promise((_, rej) => setTimeout(() => rej(new Error('Timeout')), ms));
return Promise.race([fn.apply(this, args), t]);
};
// 例:retry → timeout → log(右到左包)
const robust = compose(
fn => withRetry(fn, { retries: 2 }),
fn => withTimeout(fn, 5000),
fn => withLogAndTiming(fn, { label: 'robustTask' })
);
const task = async id => { /* ...I/O heavy work... */ };
const safeTask = robust(task);
await safeTask(123);
提示:將常用組合沉澱成 compose()/pipe(),專案內部統一寫法,減少心智負擔。
結語
Decorator 不是炫技,它是把非功能性需求(logging、監控、穩定性、合規)從業務邏輯中乾淨分離的一把好刀。選擇正確的實作路線(物件/類別、函式、方法)、了解順序的重要性、避免常見雷點(this、async、吞錯、快取洩漏),你就能把系統做得既優雅又務實。
延伸閱讀推薦:
javaScript設計模式 : Factory Method (工廠方法)
javaScript設計模式 : Abstract Factory(抽象工廠)
javaScript設計模式 : Builder(建造者模式)
javaScript設計模式 : Prototype(原型)
javaScript設計模式 : Singleton (單例模式)
javaScript設計模式 : Adapter(轉接器模式)
javaScript設計模式 : Bridge( 橋接模式 )
javaScript設計模式 : Composite(組合模式)
javaScript設計模式 : Flyweight(享元模式)
javaScript設計模式 : Chain of Responsibility(責任鏈)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
javaScript設計模式 : Strategy(策略模式)
