javaScript設計模式 : Decorator(裝飾者)

 


專案越做越大,功能之外的「橫切關注」會悄悄侵蝕可讀性:每個函式都塞 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設計模式 : Facade(外觀模式)

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

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

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

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

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

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

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

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

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

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

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

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

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


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