本文 AI 產出,尚未審核

TypeScript 課程 – 裝飾器(Decorators)

主題:方法裝飾器


簡介

在大型的 TypeScript 專案中,程式碼的可讀性與可維護性 常常是決策的核心。
裝飾器(Decorator)提供了一種以聲明式方式為類別、屬性、方法或參數「貼上」額外行為的機制,讓橫切關注點(cross‑cutting concerns)如日誌、驗證、快取等可以集中管理,而不必在每個方法裡重複寫相同的程式碼。

本單元聚焦於 方法裝飾器(Method Decorator)——它允許我們在方法被呼叫前、呼叫後或取代原本實作時,插入自訂的邏輯。對於想要在 API 請求權限檢查效能監控 等場景中保持程式碼乾淨的開發者而言,方法裝飾器是一把非常實用的「瑞士軍刀」。

接下來,我們將從概念說明、實作範例、常見坑洞與最佳實踐,最後帶入真實的應用情境,讓你能在自己的專案中快速上手方法裝飾器。


核心概念

1. 方法裝飾器的簽名

在 TypeScript 中,方法裝飾器是一個 函式,其型別定義如下:

type MethodDecorator = <T>(
  target: Object,               // 方法所在的類別原型(或建構子函式,取決於是否為 static)
  propertyKey: string | symbol, // 方法名稱
  descriptor: TypedPropertyDescriptor<T> // 方法的屬性描述子
) => TypedPropertyDescriptor<T> | void;
  • target:若裝飾的是實例方法,target 為該類別的原型 (ClassName.prototype);若是 static 方法,則為建構子本身 (ClassName)。
  • propertyKey:被裝飾的方法名稱(字串或 Symbol)。
  • descriptor:描述此方法的屬性描述子,包含 value(原始函式本體)、writableenumerableconfigurable 等屬性。

⚠️ 注意:如果裝飾器回傳一個新的 descriptor,則會 取代 原本的方法實作;若回傳 void,則保持原狀。


2. 為什麼要使用 descriptor

descriptor 讓我們可以:

  • 改寫(override) 原本的實作:把原本的函式包裝在另一層函式裡,加入前置或後置邏輯。
  • 設定屬性:例如將 enumerable 設為 false,讓方法不會被 for...in 迭代。
  • 存取原始函式:透過 descriptor.value 取得原本的函式,便於「先執行前置檢查 → 再呼叫原始函式」的模式。

3. 裝飾器的執行時機

  • 宣告階段:當 TypeScript 轉譯成 JavaScript 時,裝飾器函式會被呼叫一次,傳入上述三個參數。
  • 執行階段:只有在我們 呼叫被裝飾的方法 時,才會觸發裝飾器內部所包裝的邏輯(若有包裝的話)。

程式碼範例

以下示範 5 個實用的方法裝飾器,每個範例都附有說明與使用情境。

範例 1️⃣ 記錄方法執行時間(Performance Logger)

function LogExecutionTime(): MethodDecorator {
  return function (
    target,
    propertyKey,
    descriptor: TypedPropertyDescriptor<any>
  ) {
    const originalMethod = descriptor.value; // 保存原始函式

    descriptor.value = function (...args: any[]) {
      const start = performance.now();
      const result = originalMethod.apply(this, args); // 呼叫原始方法
      const end = performance.now();
      console.log(
        `⚡ ${String(propertyKey)} 執行時間: ${(end - start).toFixed(2)} ms`
      );
      return result;
    };

    return descriptor; // 必須回傳 descriptor 才會生效
  };
}

使用方式

class MathService {
  @LogExecutionTime()
  fibonacci(n: number): number {
    if (n <= 1) return n;
    return this.fibonacci(n - 1) + this.fibonacci(n - 2);
  }
}

說明:每次呼叫 fibonacci 時,控制台會印出方法名稱與耗時,對於 效能調校 非常有幫助。


範例 2️⃣ 權限檢查(Role Guard)

function RequireRole(role: string): MethodDecorator {
  return function (target, propertyKey, descriptor) {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
      const user = (this as any).currentUser; // 假設類別有 currentUser 屬性
      if (!user || !user.roles.includes(role)) {
        throw new Error(`❌ 權限不足:需要 ${role} 角色`);
      }
      return originalMethod.apply(this, args);
    };

    return descriptor;
  };
}

使用方式

class DocumentService {
  currentUser = { name: 'Alice', roles: ['editor'] };

  @RequireRole('admin')
  deleteDocument(id: string) {
    console.log(`文件 ${id} 已被刪除`);
  }
}

說明:只有具備 admin 角色的使用者才能執行 deleteDocument,若權限不足會直接拋出錯誤。這種 授權檢查 常見於 RESTful API 或管理介面。


範例 3️⃣ 方法快取(Memoization)

function Memoize(): MethodDecorator {
  const cache = new WeakMap<any, Map<string, any>>();

  return function (target, propertyKey, descriptor) {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
      let instanceCache = cache.get(this);
      if (!instanceCache) {
        instanceCache = new Map<string, any>();
        cache.set(this, instanceCache);
      }

      const key = JSON.stringify(args);
      if (instanceCache.has(key)) {
        console.log(`🔁 從快取返回 ${String(propertyKey)}`);
        return instanceCache.get(key);
      }

      const result = originalMethod.apply(this, args);
      instanceCache.set(key, result);
      return result;
    };

    return descriptor;
  };
}

使用方式

class ApiService {
  @Memoize()
  fetchUser(id: number) {
    // 假設這裡會發出 HTTP 請求,耗時較長
    console.log(`向伺服器取得使用者 ${id}`);
    return { id, name: 'User' + id };
  }
}

說明:相同參數的呼叫會直接從快取返回,降低重複計算或重複請求的成本。適合 純函式不變資料 的情況。


範例 4️⃣ 參數驗證(Argument Validator)

function Validate(
  validator: (...args: any[]) => boolean,
  message = '參數驗證失敗'
): MethodDecorator {
  return function (target, propertyKey, descriptor) {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
      if (!validator(...args)) {
        throw new Error(`❗ ${String(propertyKey)} - ${message}`);
      }
      return originalMethod.apply(this, args);
    };

    return descriptor;
  };
}

使用方式

function isPositiveNumber(...args: any[]) {
  return args.every((arg) => typeof arg === 'number' && arg > 0);
}

class MathUtil {
  @Validate(isPositiveNumber, '所有參數必須是正數')
  multiply(...nums: number[]): number {
    return nums.reduce((a, b) => a * b, 1);
  }
}

說明:在執行 multiply 前會先檢查所有參數是否為正數,若不符合則拋出錯誤。此模式可統一 輸入驗證,避免在每個方法內寫重複的檢查程式碼。


範例 5️⃣ 事件發佈(Publish‑Subscribe)

interface EventBus {
  publish(event: string, payload?: any): void;
}

function Publish(eventName: string): MethodDecorator {
  return function (target, propertyKey, descriptor) {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
      const result = originalMethod.apply(this, args);
      // 假設類別實作了 EventBus,或透過 DI 注入
      (this as any).eventBus?.publish(eventName, result);
      return result;
    };

    return descriptor;
  };
}

使用方式

class OrderService implements EventBus {
  eventBus = {
    publish(event, payload) {
      console.log(`📣 事件 ${event} 發佈,資料:`, payload);
    },
  };

  @Publish('orderCreated')
  createOrder(data: { productId: number; qty: number }) {
    const order = { id: Date.now(), ...data };
    // 實際儲存流程省略
    return order;
  }
}

說明:每次成功建立訂單後,會自動發佈 orderCreated 事件,讓其他模組(如通知、統計)可透過 訂閱 方式即時得到資訊。此技巧常見於 CQRS事件驅動架構


常見陷阱與最佳實踐

陷阱 說明 解決方案 / 最佳實踐
忘記回傳 descriptor 若裝飾器內部改寫了 descriptor,卻未回傳,變更不會套用。 永遠在結尾 return descriptor;,或明確回傳 void 表示不需要改寫。
this 失去綁定 在裝飾器中使用 function 而非箭頭函式,可能導致 this 指向錯誤。 使用 originalMethod.apply(this, args),或在包裝函式內部使用 箭頭函式 以保持 lexical this
重複裝飾 多個裝飾器同時修改同一屬性,可能互相覆蓋。 注意執行順序(從下往上),或在每個裝飾器內部 呼叫前一個裝飾器的結果
效能問題 包裝過程若做大量運算,會在每次呼叫時重複執行。 一次性計算 放在裝飾器外部(如快取)或使用 WeakMap 儲存結果。
類別繼承時失效 子類別直接覆寫被裝飾的方法,父類別的裝飾器不會自動作用於子類別。 在子類別中重新套用必要的裝飾器,或將共通邏輯抽成 基礎類別的裝飾器

其他最佳實踐

  1. 保持裝飾器的單一職責
    • 例如 LogExecutionTime 只負責記錄時間,別同時加入權限檢查。
  2. 盡量使用 TypeScript 的型別
    • descriptortarget 加上適當的泛型,讓 IDE 能提供完整的自動完成與錯誤提示。
  3. 避免在裝飾器內部做 I/O(如 HTTP、檔案系統)
    • 裝飾器的目的是攔截包裝,真正的 I/O 應該放在原始方法內,避免意外的副作用。
  4. 將共用快取、日誌等資源抽成服務
    • 例如 LoggerServiceCacheService,在裝飾器中注入(DI)而不是直接用全域變數。

實際應用場景

1️⃣ API 請求的前置驗證與後置日誌

NestJSExpress 中,我們常用方法裝飾器來:

  • 驗證 JWT@JwtAuth()
  • 記錄請求耗時@LogExecutionTime()
class UserController {
  @JwtAuth()
  @LogExecutionTime()
  async getProfile(@Req() req) {
    // 只會在 token 合法且執行完畢後印出耗時
    return { id: req.user.id, name: 'John Doe' };
  }
}

2️⃣ 交易型服務的補償機制

在微服務或金融系統,每筆交易必須保證原子性。可以寫一個 @Transactional() 裝飾器,於方法失敗時自動呼叫補償(rollback)邏輯。

function Transactional(): MethodDecorator {
  return function (target, key, descriptor) {
    const original = descriptor.value;
    descriptor.value = async function (...args: any[]) {
      const tx = await startTransaction(); // 開啟交易
      try {
        const result = await original.apply(this, args);
        await tx.commit();
        return result;
      } catch (e) {
        await tx.rollback();
        throw e;
      }
    };
    return descriptor;
  };
}

3️⃣ UI 元件的自動快取

在前端框架(如 Angular)中,使用 @Memoize() 裝飾器為計算屬性快取結果,減少不必要的重新渲染。

class ChartComponent {
  @Memoize()
  computeData(raw: number[]) {
    // 複雜的資料處理
    return raw.map((x) => Math.sqrt(x));
  }
}

4️⃣ 事件驅動的領域模型

Domain‑Driven Design(DDD)裡,聚合根(Aggregate Root)的方法常會發佈領域事件。透過 @Publish() 裝飾器,讓事件的發布與業務邏輯解耦。

class BankAccount {
  @Publish('MoneyWithdrawn')
  withdraw(amount: number) {
    if (this.balance < amount) throw new Error('餘額不足');
    this.balance -= amount;
    return { amount, remaining: this.balance };
  }
}

總結

方法裝飾器是 TypeScript 裝飾器系統 中最常用、且最具威力的特性之一。透過 descriptor 我們可以在不改變原始程式碼的前提下,為方法加入:

  • 效能監控(LogExecutionTime)
  • 授權檢查(RequireRole)
  • 快取機制(Memoize)
  • 參數驗證(Validate)
  • 事件發布(Publish)

在實務開發中,正確使用裝飾器能夠讓 橫切關注點 集中管理,提升程式碼的可讀性、可測試性與維護成本。記得遵守以下要點:

  1. 單一職責:每個裝飾器只做一件事。
  2. 正確回傳 descriptor,確保變更生效。
  3. 保持 this 綁定,避免上下文錯亂。
  4. 利用型別,讓 IDE 幫忙捕捉錯誤。
  5. 在適當層級使用(服務層、控制器層或領域模型),讓系統結構更清晰。

掌握了這些概念與實作技巧,你就能在日常開發、微服務架構或大型企業系統中,靈活運用方法裝飾器,寫出 乾淨、可擴充且易於維護 的 TypeScript 程式碼。祝你在 TypeScript 的裝飾器世界玩得開心! 🚀