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 儲存結果。 |
| 類別繼承時失效 | 子類別直接覆寫被裝飾的方法,父類別的裝飾器不會自動作用於子類別。 | 在子類別中重新套用必要的裝飾器,或將共通邏輯抽成 基礎類別的裝飾器。 |
其他最佳實踐
- 保持裝飾器的單一職責
- 例如
LogExecutionTime只負責記錄時間,別同時加入權限檢查。
- 例如
- 盡量使用 TypeScript 的型別
- 為
descriptor、target加上適當的泛型,讓 IDE 能提供完整的自動完成與錯誤提示。
- 為
- 避免在裝飾器內部做 I/O(如 HTTP、檔案系統)
- 裝飾器的目的是攔截與包裝,真正的 I/O 應該放在原始方法內,避免意外的副作用。
- 將共用快取、日誌等資源抽成服務
- 例如
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)
在實務開發中,正確使用裝飾器能夠讓 橫切關注點 集中管理,提升程式碼的可讀性、可測試性與維護成本。記得遵守以下要點:
- 單一職責:每個裝飾器只做一件事。
- 正確回傳 descriptor,確保變更生效。
- 保持
this綁定,避免上下文錯亂。 - 利用型別,讓 IDE 幫忙捕捉錯誤。
- 在適當層級使用(服務層、控制器層或領域模型),讓系統結構更清晰。
掌握了這些概念與實作技巧,你就能在日常開發、微服務架構或大型企業系統中,靈活運用方法裝飾器,寫出 乾淨、可擴充且易於維護 的 TypeScript 程式碼。祝你在 TypeScript 的裝飾器世界玩得開心! 🚀