本文 AI 產出,尚未審核

TypeScript – 類別裝飾器(Class Decorators)

簡介

在大型前端或 Node.js 專案中,類別的行為往往需要在宣告之外再進行額外的設定,例如自動註冊、日誌追蹤、權限驗證等。
TypeScript 提供的 裝飾器(Decorator) 機制,讓開發者可以在不改變原始類別程式碼的前提下,以宣告式的方式為類別、屬性、方法或參數加入額外功能。其中最常被使用的,就是 類別裝飾器

類別裝飾器不僅能夠改寫類別本身(例如替換建構子),還能在類別被載入時執行一次性的初始化工作。對於需要「全域註冊」或「依賴注入」的框架(如 NestJS、Angular)而言,類別裝飾器是不可或缺的基礎工具。

本文將從概念、語法、實作範例一路說明,並提供常見陷阱、最佳實踐與實務應用場景,幫助你在 TypeScript 專案中安全、有效地使用類別裝飾器。


核心概念

1. 裝飾器的基本原理

  • 裝飾器本質:在執行階段,裝飾器是一個接受目標(target)作為參數的函式。
  • 執行時機:類別裝飾器會在 類別宣告完成類別實例化前 呼叫一次。
  • 回傳值:若裝飾器回傳 一個新的建構子(constructor),則會取代原本的類別;若回傳 void,則僅對原類別產生副作用。
function MyDecorator(target: Function) {
  // target === 原始類別的建構子
}

2. 啟用裝飾器

tsconfig.json 中必須開啟以下兩個編譯選項:

{
  "experimentalDecorators": true,
  "emitDecoratorMetadata": true   // 若需要反射(metadata)支援
}

⚠️ experimentalDecorators 為實驗性功能,未來 ECMAScript 可能會正式納入標準,屆時語法可能會略有變動。

3. 類別裝飾器的簽名

type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
  • target類別的建構子new (...args: any[]) => any)。
  • 若回傳 新建構子,則會 替換原本的類別

程式碼範例

以下提供 五個實用範例,從最簡單的日誌印出到較進階的依賴注入與類別替換。

範例 1️⃣ 基礎日誌裝飾器

function LogClass(target) {
  console.log(`類別 ${target.name} 已被宣告`);
}

// 使用方式
@LogClass
class UserService {
  // ...
}

說明:只要 UserService 被載入,LogClass 立即執行,印出類別名稱。適合在開發階段快速確認模組是否正確載入。


範例 2️⃣ 為類別加入靜態屬性

function AddVersion(version) {
  return function (target) {
    // 直接在建構子上掛載屬性
    target.version = version;
  };
}

@AddVersion('1.2.3')
class ApiClient {
  // ...
}

// 之後可以這樣取值
console.log(ApiClient.version); // 1.2.3

說明:透過閉包把 version 傳入裝飾器,讓所有被標記的類別自動擁有 version 靜態屬性,常用於 API 客戶端套件版本管理


範例 3️⃣ 替換建構子(Factory Pattern)

function Singleton() {
  let instance: any;
  return function (target) {
    const original = target;

    const newConstructor: any = function (...args) {
      if (!instance) {
        instance = new original(...args);
      }
      return instance;
    };

    // 保留原型鏈
    newConstructor.prototype = original.prototype;
    return newConstructor;
  };
}

@Singleton()
class ConfigService {
  constructor(public readonly env: string) {}
}

const a = new ConfigService('dev');
const b = new ConfigService('prod');

console.log(a === b); // true
console.log(a.env);   // dev(第一次建立時的參數被保留)

說明:此裝飾器將類別改寫為 單例模式,確保整個程式執行期間只會產生一個實例。透過 newConstructor.prototype = original.prototype 保持原型鏈不被破壞,讓 instanceof 判斷仍然正確。


範例 4️⃣ 結合 Reflect Metadata 實作簡易 DI 容器

需要 reflect-metadata 套件與 emitDecoratorMetadata 設定。

npm i reflect-metadata
import 'reflect-metadata';

// 1. 標記依賴的類別
function Injectable() {
  return function (target) {
    // 只要被標記,即可放入容器
    Container.set(target, new target());
  };
}

// 2. 依賴注入裝飾器(參數裝飾器)
function Inject(token) {
  return function (target, _key, index) {
    const types = Reflect.getMetadata('design:paramtypes', target);
    types[index] = token; // 替換參數類型
  };
}

// 簡易容器
class Container {
  private static map = new Map<any, any>();
  static set(token, instance) {
    this.map.set(token, instance);
  }
  static get(token) {
    return this.map.get(token);
  }
}

// 3. 使用範例
@Injectable()
class Logger {
  log(msg) { console.log('[LOG]', msg); }
}

@Injectable()
class UserService {
  constructor(@Inject(Logger) private logger: Logger) {}

  getUser(id) {
    this.logger.log(`取得使用者 ${id}`);
    return { id, name: 'Alice' };
  }
}

// 直接從容器取得實例
const userService = Container.get(UserService);
userService.getUser(1);

說明@Injectable 把類別自動註冊到 DI 容器,@Inject 透過 參數裝飾器 取代建構子參數的型別,最後容器在建立 UserService 時自動注入 Logger。這是許多框架(如 NestJS)背後的核心概念。


範例 5️⃣ 自動註冊路由(模擬 Express)

const router = require('express').Router();

// 路由裝飾器工廠
function Controller(prefix = '') {
  return function (target) {
    // 把類別原型上所有標記過的路由方法收集起來
    const routes = Reflect.getMetadata('routes', target.prototype) || [];
    routes.forEach(({ method, path, handlerName }) => {
      router[method](`${prefix}${path}`, target.prototype[handlerName].bind(new target()));
    });
  };
}

// 方法裝飾器
function Get(path) {
  return function (target, propertyKey) {
    const routes = Reflect.getMetadata('routes', target) || [];
    routes.push({ method: 'get', path, handlerName: propertyKey });
    Reflect.defineMetadata('routes', routes, target);
  };
}

@Controller('/api')
class ProductController {
  @Get('/products')
  list(req, res) {
    res.json([{ id: 1, name: '筆記型電腦' }]);
  }
}

// 最後把 router 匯出給 Express 使用
module.exports = router;

說明:透過類別裝飾器 @Controller 結合方法裝飾器 @Get,可 自動把類別方法掛載到 Express 路由,大幅減少手動 router.get('/api/products', ...) 的樣板程式碼。實務上,這種寫法在大型 API 專案中非常常見。


常見陷阱與最佳實踐

陷阱 說明 解決方式 / 最佳實踐
裝飾器順序不明 多個裝飾器同時作用於同一類別時,執行順序是 從上到下(最外層最先執行)。 在文件或註解中明確說明每個裝飾器的責任,避免相互衝突。
失去原型鏈 替換建構子時若忘記 newConstructor.prototype = original.prototypeinstanceof 判斷會失效。 務必保留原型,或使用 Object.setPrototypeOf
參數型別遺失 若未開啟 emitDecoratorMetadata,參數的型別資訊無法透過 Reflect.getMetadata 取得,DI 會失靈。 必須同時啟用 experimentalDecoratorsemitDecoratorMetadata,並在入口檔案 import 'reflect-metadata'
副作用過大 裝飾器內部直接寫入全域變數或修改外部狀態,會增加除錯難度。 保持純粹:裝飾器只負責「註冊」或「改寫」類別本身,避免在裡面執行業務邏輯。
裝飾器無法在編譯時期檢查 TypeScript 編譯器不會對裝飾器內的程式碼做型別檢查,錯誤只能在執行時發現。 使用 單元測試 以及 lint rule(如 eslint-plugin-decorator)來捕捉常見錯誤。

最佳實踐小結

  1. 單一職責:每個裝飾器只做一件事(例如「註冊路由」或「加入日誌」),方便組合與測試。
  2. 保持可測試:把裝飾器的實作抽離成純函式,然後在測試中直接呼叫。
  3. 避免過度依賴全域:若需要共享狀態,使用 DI 容器Symbol 作為鍵值,避免直接掛在 global
  4. 文件化:在大型團隊中,建議在 README 或內部 wiki 記錄每個裝飾器的使用限制與相容性。

實際應用場景

場景 為何適合使用類別裝飾器 範例
API 框架自動路由 讓開發者只需關注業務邏輯,路由設定交給裝飾器完成。 @Controller / @Get@Post(如 NestJS)
單例或多例管理 中央化控制實例建立,避免重複建構昂貴物件(例如資料庫連線)。 @Singleton()@Scoped()
日誌與度量 在類別被實例化或方法被呼叫時自動寫入日誌或統計資訊。 @LogClass@Metric
權限驗證 把權限檢查寫成裝飾器,讓控制器或服務層保持乾淨。 @Authorized('admin')
自動註冊至 DI 容器 只要加上 @Injectable,類別即會被容器管理,減少手寫註冊程式碼。 NestJS、Angular 風格的 DI

實務建議:在新專案中,先規劃好「裝飾器的命名空間」與「共用元資料鍵(Symbol)」,避免日後因為命名衝突而導致難以維護的情況。


總結

類別裝飾器是 TypeScript 讓程式碼更具宣告性、可組合性與可維護性的關鍵工具。透過簡單的函式,我們可以在類別宣告階段自動執行日誌、註冊路由、實作單例、注入依賴等複雜行為,而不必在每個類別內部寫重複的樣板程式碼。

本文從 概念語法五個實用範例常見陷阱與最佳實踐,以及 實務應用場景 逐層說明,期望讀者在閱讀完畢後,能夠:

  1. 正確設定 tsconfig.json 以啟用裝飾器。
  2. 熟練撰寫與使用類別裝飾器(包括回傳新建構子)。
  3. 在日常開發中運用裝飾器減少樣板、提升可讀性。
  4. 避免常見的原型鏈遺失、元資料缺失等問題。

掌握了這些要點,你就可以在 前端框架、Node.js 後端服務、甚至是桌面或雲端函式 中,靈活運用類別裝飾器,寫出更 乾淨、可維護且具擴充性的程式碼。祝你在 TypeScript 的世界裡玩得開心! 🚀