本文 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(原始函式本體)、writable、enumerable、configurable 等屬性。

⚠️ 注意:如果裝飾器回傳一個新的 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 的型別
    • 為 descriptor、target 加上適當的泛型,讓 IDE 能提供完整的自動完成與錯誤提示。
  3. 避免在裝飾器內部做 I/O(如 HTTP、檔案系統)
    • 裝飾器的目的是攔截與包裝,真正的 I/O 應該放在原始方法內,避免意外的副作用。
  4. 將共用快取、日誌等資源抽成服務
    • 例如 LoggerService、CacheService,在裝飾器中注入(DI)而不是直接用全域變數。

實際應用場景

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

在 NestJS 或 Express 中,我們常用方法裝飾器來:

  • 驗證 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 的裝飾器世界玩得開心! 🚀