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.prototype,instanceof 判斷會失效。 |
務必保留原型,或使用 Object.setPrototypeOf。 |
| 參數型別遺失 | 若未開啟 emitDecoratorMetadata,參數的型別資訊無法透過 Reflect.getMetadata 取得,DI 會失靈。 |
必須同時啟用 experimentalDecorators 與 emitDecoratorMetadata,並在入口檔案 import 'reflect-metadata'。 |
| 副作用過大 | 裝飾器內部直接寫入全域變數或修改外部狀態,會增加除錯難度。 | 保持純粹:裝飾器只負責「註冊」或「改寫」類別本身,避免在裡面執行業務邏輯。 |
| 裝飾器無法在編譯時期檢查 | TypeScript 編譯器不會對裝飾器內的程式碼做型別檢查,錯誤只能在執行時發現。 | 使用 單元測試 以及 lint rule(如 eslint-plugin-decorator)來捕捉常見錯誤。 |
最佳實踐小結
- 單一職責:每個裝飾器只做一件事(例如「註冊路由」或「加入日誌」),方便組合與測試。
- 保持可測試:把裝飾器的實作抽離成純函式,然後在測試中直接呼叫。
- 避免過度依賴全域:若需要共享狀態,使用 DI 容器 或 Symbol 作為鍵值,避免直接掛在
global。 - 文件化:在大型團隊中,建議在 README 或內部 wiki 記錄每個裝飾器的使用限制與相容性。
實際應用場景
| 場景 | 為何適合使用類別裝飾器 | 範例 |
|---|---|---|
| API 框架自動路由 | 讓開發者只需關注業務邏輯,路由設定交給裝飾器完成。 | @Controller / @Get、@Post(如 NestJS) |
| 單例或多例管理 | 中央化控制實例建立,避免重複建構昂貴物件(例如資料庫連線)。 | @Singleton()、@Scoped() |
| 日誌與度量 | 在類別被實例化或方法被呼叫時自動寫入日誌或統計資訊。 | @LogClass、@Metric |
| 權限驗證 | 把權限檢查寫成裝飾器,讓控制器或服務層保持乾淨。 | @Authorized('admin') |
| 自動註冊至 DI 容器 | 只要加上 @Injectable,類別即會被容器管理,減少手寫註冊程式碼。 |
NestJS、Angular 風格的 DI |
實務建議:在新專案中,先規劃好「裝飾器的命名空間」與「共用元資料鍵(Symbol)」,避免日後因為命名衝突而導致難以維護的情況。
總結
類別裝飾器是 TypeScript 讓程式碼更具宣告性、可組合性與可維護性的關鍵工具。透過簡單的函式,我們可以在類別宣告階段自動執行日誌、註冊路由、實作單例、注入依賴等複雜行為,而不必在每個類別內部寫重複的樣板程式碼。
本文從 概念、語法、五個實用範例、常見陷阱與最佳實踐,以及 實務應用場景 逐層說明,期望讀者在閱讀完畢後,能夠:
- 正確設定
tsconfig.json以啟用裝飾器。 - 熟練撰寫與使用類別裝飾器(包括回傳新建構子)。
- 在日常開發中運用裝飾器減少樣板、提升可讀性。
- 避免常見的原型鏈遺失、元資料缺失等問題。
掌握了這些要點,你就可以在 前端框架、Node.js 後端服務、甚至是桌面或雲端函式 中,靈活運用類別裝飾器,寫出更 乾淨、可維護且具擴充性的程式碼。祝你在 TypeScript 的世界裡玩得開心! 🚀