本文 AI 產出,尚未審核

TypeScript 裝飾器:裝飾器組合(多重裝飾)


簡介

在大型 TypeScript 專案中,裝飾器(Decorator) 已成為一種優雅的元編程手法,讓我們能在不改變原始類別實作的前提下,為類別、屬性、方法或參數「加上」額外的行為。單一裝飾器的概念相對直觀,但在實務開發裡,往往會需要 同時套用多個裝飾器,形成所謂的「裝飾器組合」或「多重裝飾」。

多重裝飾不僅能把不同的關注點(logging、驗證、授權、快取…)分離,還能讓程式碼保持 單一職責易於測試可組合。本篇文章將帶你深入了解裝飾器組合的執行順序、實作技巧,以及在真實專案中的常見應用。


核心概念

1. 裝飾器的執行順序

在 TypeScript 中,當多個裝飾器同時套用於同一個目標時,執行順序遵循「從下往上」的原則(即先執行最靠近成員宣告的裝飾器,再往外層遞進)。舉例:

@ClassDecA
@ClassDecB
class MyClass {}
  • 執行順序ClassDecBClassDecA
  • 返回值:若裝飾器回傳新定義,則外層裝飾器會接收到內層已被改寫的結果。

小技巧:利用這個特性,你可以先用「基礎」裝飾器(例如 @Log)記錄呼叫,再用「高階」裝飾器(例如 @Authorize)檢查權限,保證日誌永遠會被寫入。


2. 組合裝飾器的實作方式

(1) 直接堆疊多個裝飾器

最直觀的方式就是在同一個目標上寫多個裝飾器:

function Log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`[Log] ${propertyKey} called with`, args);
    return original.apply(this, args);
  };
  return descriptor;
}

function Cache(ttl: number) {
  const store = new Map<string, any>();
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const original = descriptor.value;
    descriptor.value = function (...args: any[]) {
      const key = JSON.stringify(args);
      if (store.has(key)) {
        console.log(`[Cache] hit for ${propertyKey}`);
        return store.get(key);
      }
      const result = original.apply(this, args);
      store.set(key, result);
      setTimeout(() => store.delete(key), ttl);
      return result;
    };
    return descriptor;
  };
}

class Service {
  @Log
  @Cache(5000) // 5 秒快取
  fetchData(id: number) {
    // 假裝向遠端 API 取得資料
    return { id, data: `資料 ${id}` };
  }
}

重點@Cache 先執行(因為在 @Log 下面),所以快取結果會先被返回,若命中快取則不會觸發 Log 內部的 console.log。若想要先記錄再快取,只需要調換裝飾器的順序。


(2) 使用「組合裝飾器」產生器

若你常常需要同時套用 @Log@Cache@Authorize,可以寫一個 組合裝飾器工廠

function Compose(...decorators: MethodDecorator[]): MethodDecorator {
  return function (target, propertyKey, descriptor) {
    // 依序套用每個裝飾器,注意從右至左的執行順序
    return decorators.reduceRight(
      (desc, deco) => deco(target, propertyKey, desc) || desc,
      descriptor
    );
  };
}

// 例子:同時套用 Log、Cache、Authorize
function Authorize(role: string): MethodDecorator {
  return function (target, propertyKey, descriptor) {
    const original = descriptor.value;
    descriptor.value = function (...args: any[]) {
      if (!this.user?.roles.includes(role)) {
        throw new Error('未授權');
      }
      return original.apply(this, args);
    };
    return descriptor;
  };
}

class ServiceV2 {
  @Compose(Log, Cache(3000), Authorize('admin'))
  deleteUser(id: number) {
    console.log('執行刪除');
    return true;
  }
}
  • Compose 會把傳入的裝飾器陣列 倒序reduceRight)套用,確保最左邊的裝飾器最後執行,和手動堆疊的效果相同。
  • 這樣做的好處是 可重用:只要在不同類別或方法上引用同一組合,即可保證行為一致。

(3) 裝飾器混入(Mixin)與類別層級組合

組合不只限定於方法層級,類別層級也可以混合多個裝飾器。例如:

function Singleton<T extends { new (...args: any[]): any }>(constructor: T) {
  return class extends constructor {
    private static _instance: any;
    constructor(...args: any[]) {
      super(...args);
      if ((this.constructor as any)._instance) {
        return (this.constructor as any)._instance;
      }
      (this.constructor as any)._instance = this;
    }
  };
}

function Timestamped<T extends { new (...args: any[]): {} }>(Base: T) {
  return class extends Base {
    createdAt = new Date();
    updatedAt = new Date();
    touch() {
      this.updatedAt = new Date();
    }
  };
}

@Singleton
@Timestamped
class ConfigService {
  // 只會有一個實例,且自動帶有時間戳記
  private config = {};
}
  • 這裡的 @Singleton@Timestamped 皆是 類別裝飾器,它們會依序返回新的子類別。最外層的 @Timestamped 會先執行,然後把結果交給 @Singleton 處理。
  • 實務意義:在需要「全域唯一」且「自動追蹤變更時間」的服務(如設定服務、快取服務)時,組合類別裝飾器是非常乾淨的寫法。

(4) 裝飾器參數化與可組合的工廠模式

有時候你想根據環境或設定動態決定要套用哪幾個裝飾器。這時可以寫一個 工廠函式,返回適當的組合裝飾器:

function createMethodDecorators(env: 'dev' | 'prod'): MethodDecorator {
  const devDecorators = [Log];
  const prodDecorators = [Authorize('admin'), Cache(60000)];
  const decorators = env === 'dev' ? devDecorators : prodDecorators;
  return Compose(...decorators);
}

class ApiService {
  @createMethodDecorators(process.env.NODE_ENV === 'development' ? 'dev' : 'prod')
  getSecretData(id: string) {
    // 真正的業務邏輯
    return { id, secret: '***' };
  }
}
  • 透過 條件判斷,在開發環境只記錄日誌,正式環境則加入授權與快取,不需要重複寫多個版本的程式碼

3. 裝飾器的返回值與覆寫行為

  • 類別裝飾器:若回傳一個新構造函式,原始類別會被取代。多個類別裝飾器會依序把「新類別」傳遞給下一個裝飾器。
  • 屬性/方法裝飾器:返回 PropertyDescriptor(或 void)。若返回 undefined,則保留原本的描述子。多個方法裝飾器的返回值會被下一個裝飾器接收,因此不要忘記回傳 descriptor,否則後面的裝飾器會得到 undefined

常見錯誤:忘記在自訂裝飾器最後 return descriptor;,導致後續裝飾器失效或拋出錯誤。


常見陷阱與最佳實踐

陷阱 說明 解決方式
執行順序不符預期 多個裝飾器的執行順序是「從下往上」;若寫錯順序會導致快取、授權、日誌的相互影響。 在設計時先畫出「流程圖」,或使用 Compose 明確控制順序。
裝飾器返回值遺失 裝飾器若未回傳 descriptor,後面的裝飾器會收到 undefined 永遠在自訂裝飾器最後 return descriptor;(或 `return result
副作用共享 多個實例共用同一個快取或狀態(例如在裝飾器外部宣告的變數),可能導致資料污染。 使用 WeakMap 或將狀態封裝在返回的類別/方法內。
裝飾器過度堆疊 堆疊過多裝飾器會使程式碼難以追蹤,且產生性能開銷。 把相關功能抽象成 單一組合裝飾器,避免在每個方法上寫太多。
類別層級裝飾器與繼承衝突 子類別繼承了父類別的裝飾器,若子類別再次裝飾同一屬性,可能產生重複行為。 在子類別使用 @Override(自行實作)或在父類別提供 可選的 hook

最佳實踐

  1. 單一職責:每個裝飾器只負責一件事(Log、Cache、Authorize…),透過組合達成複雜需求。
  2. 可組合性:使用 Compose 或類似的工廠模式,使裝飾器可以自由排列組合。
  3. 明確文件:在程式碼註解或 README 中說明每個裝飾器的執行順序與副作用。
  4. 測試覆蓋:針對每個裝飾器寫單元測試,並測試組合後的行為,以免因順序變更破壞功能。

實際應用場景

1. REST API 控制器的統一處理

在 Express 或 NestJS 中,常見需求是 同時記錄請求日誌、驗證 JWT、以及快取 GET 結果。利用多重裝飾器可以把這三件事寫在同一行:

function JwtAuth(): MethodDecorator {
  return function (target, key, descriptor) {
    const original = descriptor.value;
    descriptor.value = function (req: any, res: any, ...args: any[]) {
      const token = req.headers.authorization?.split(' ')[1];
      if (!token || !verifyJwt(token)) {
        return res.status(401).send('Unauthorized');
      }
      return original.apply(this, [req, res, ...args]);
    };
    return descriptor;
  };
}

// 快取裝飾器(簡易版)
function SimpleCache(ttl: number): MethodDecorator {
  const cache = new Map<string, any>();
  return function (target, key, descriptor) {
    const original = descriptor.value;
    descriptor.value = async function (req: any, res: any, ...args: any[]) {
      const cacheKey = `${key}-${JSON.stringify(req.query)}`;
      if (cache.has(cacheKey)) {
        return res.json(cache.get(cacheKey));
      }
      const result = await original.apply(this, [req, res, ...args]);
      cache.set(cacheKey, result);
      setTimeout(() => cache.delete(cacheKey), ttl);
      return result;
    };
    return descriptor;
  };
}

class UserController {
  @Compose(Log, JwtAuth(), SimpleCache(10000))
  async getProfile(req: any, res: any) {
    // 假設這裡會呼叫資料庫
    const user = await db.findUser(req.userId);
    return res.json(user);
  }
}
  • 優點:控制器本身只關注業務邏輯,所有跨切面需求被抽離成可重用的裝飾器。

2. 前端狀態管理(Vue/React)中的自動快取與重試

在大型前端專案,對 API 的呼叫常需要 自動快取失敗重試、以及 開發模式下的 console.log。可用多重裝飾器封裝:

function Retry(times: number, delayMs = 200): MethodDecorator {
  return function (target, key, descriptor) {
    const original = descriptor.value;
    descriptor.value = async function (...args: any[]) {
      let attempt = 0;
      while (attempt < times) {
        try {
          return await original.apply(this, args);
        } catch (e) {
          attempt++;
          if (attempt >= times) throw e;
          await new Promise(res => setTimeout(res, delayMs));
        }
      }
    };
    return descriptor;
  };
}

// 用於服務類別
class ApiService {
  @Compose(Log, Cache(120000), Retry(3, 500))
  async fetchPosts() {
    const resp = await fetch('/api/posts');
    return resp.json();
  }
}
  • 實務效益:前端開發者不必在每個 API 方法裡寫重試或快取邏輯,只要在類別層級掛上對應的組合裝飾器即可。

3. 微服務間的跨域請求追蹤

在微服務架構中,常需要在每一次遠端呼叫時 自動加入 trace-id,同時 記錄呼叫耗時。以下示範如何以多重裝飾器完成:

function TraceId(): MethodDecorator {
  return function (target, key, descriptor) {
    const original = descriptor.value;
    descriptor.value = function (...args: any[]) {
      const traceId = generateTraceId();
      // 假設所有 HTTP 客戶端會自動讀取此全域變數
      (global as any).currentTraceId = traceId;
      return original.apply(this, args);
    };
    return descriptor;
  };
}

function Timing(): MethodDecorator {
  return function (target, key, descriptor) {
    const original = descriptor.value;
    descriptor.value = async function (...args: any[]) {
      const start = Date.now();
      const result = await original.apply(this, args);
      const end = Date.now();
      console.log(`[Timing] ${key} took ${end - start}ms`);
      return result;
    };
    return descriptor;
  };
}

class OrderService {
  @Compose(TraceId, Timing)
  async placeOrder(order: any) {
    // 呼叫其他微服務
    await httpClient.post('/payment', order);
    return { success: true };
  }
}
  • 結果:每一次 placeOrder 都會自動帶上唯一的 trace-id,且在 console 中留下耗時紀錄,方便後續分散式追蹤。

總結

  • 裝飾器組合 讓我們能把多個關注點以 可組合、可重用 的方式混合在同一個類別或方法上。
  • 執行順序是「從下往上」;了解這點是避免行為衝突的關鍵。
  • 透過 Compose、工廠函式或條件判斷,我們可以 動態產生 適合不同環境或需求的裝飾器組合。
  • 常見陷阱包括返回值遺失、順序誤用、共享狀態污染等;遵守 單一職責、明確文件、完整測試 的最佳實踐,可讓裝飾器在大型專案中保持可維護性。
  • API 控制器、前端服務、微服務追蹤 等實務場景中,裝飾器組合已證明能大幅減少重複程式碼、提升可讀性與可測試性。

掌握了多重裝飾的概念與技巧,你就能在 TypeScript 項目中以 乾淨、彈性 的方式處理跨切面需求,讓程式碼更具表達力,也更易於維護。祝你寫出更優雅的 TypeScript!