TypeScript 裝飾器:裝飾器組合(多重裝飾)
簡介
在大型 TypeScript 專案中,裝飾器(Decorator) 已成為一種優雅的元編程手法,讓我們能在不改變原始類別實作的前提下,為類別、屬性、方法或參數「加上」額外的行為。單一裝飾器的概念相對直觀,但在實務開發裡,往往會需要 同時套用多個裝飾器,形成所謂的「裝飾器組合」或「多重裝飾」。
多重裝飾不僅能把不同的關注點(logging、驗證、授權、快取…)分離,還能讓程式碼保持 單一職責、易於測試 與 可組合。本篇文章將帶你深入了解裝飾器組合的執行順序、實作技巧,以及在真實專案中的常見應用。
核心概念
1. 裝飾器的執行順序
在 TypeScript 中,當多個裝飾器同時套用於同一個目標時,執行順序遵循「從下往上」的原則(即先執行最靠近成員宣告的裝飾器,再往外層遞進)。舉例:
@ClassDecA
@ClassDecB
class MyClass {}
- 執行順序:
ClassDecB→ClassDecA - 返回值:若裝飾器回傳新定義,則外層裝飾器會接收到內層已被改寫的結果。
小技巧:利用這個特性,你可以先用「基礎」裝飾器(例如
@Log)記錄呼叫,再用「高階」裝飾器(例如@Authorize)檢查權限,保證日誌永遠會被寫入。
2. 組合裝飾器的實作方式
(1) 直接堆疊多個裝飾器
最直觀的方式就是在同一個目標上寫多個裝飾器:
function Log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[Log] ${propertyKey} called with`, args);
return original.apply(this, args);
};
return descriptor;
}
function Cache(ttl: number) {
const store = new Map<string, any>();
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
const key = JSON.stringify(args);
if (store.has(key)) {
console.log(`[Cache] hit for ${propertyKey}`);
return store.get(key);
}
const result = original.apply(this, args);
store.set(key, result);
setTimeout(() => store.delete(key), ttl);
return result;
};
return descriptor;
};
}
class Service {
@Log
@Cache(5000) // 5 秒快取
fetchData(id: number) {
// 假裝向遠端 API 取得資料
return { id, data: `資料 ${id}` };
}
}
重點:
@Cache先執行(因為在@Log下面),所以快取結果會先被返回,若命中快取則不會觸發Log內部的console.log。若想要先記錄再快取,只需要調換裝飾器的順序。
(2) 使用「組合裝飾器」產生器
若你常常需要同時套用 @Log、@Cache、@Authorize,可以寫一個 組合裝飾器工廠:
function Compose(...decorators: MethodDecorator[]): MethodDecorator {
return function (target, propertyKey, descriptor) {
// 依序套用每個裝飾器,注意從右至左的執行順序
return decorators.reduceRight(
(desc, deco) => deco(target, propertyKey, desc) || desc,
descriptor
);
};
}
// 例子:同時套用 Log、Cache、Authorize
function Authorize(role: string): MethodDecorator {
return function (target, propertyKey, descriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
if (!this.user?.roles.includes(role)) {
throw new Error('未授權');
}
return original.apply(this, args);
};
return descriptor;
};
}
class ServiceV2 {
@Compose(Log, Cache(3000), Authorize('admin'))
deleteUser(id: number) {
console.log('執行刪除');
return true;
}
}
Compose會把傳入的裝飾器陣列 倒序(reduceRight)套用,確保最左邊的裝飾器最後執行,和手動堆疊的效果相同。- 這樣做的好處是 可重用:只要在不同類別或方法上引用同一組合,即可保證行為一致。
(3) 裝飾器混入(Mixin)與類別層級組合
組合不只限定於方法層級,類別層級也可以混合多個裝飾器。例如:
function Singleton<T extends { new (...args: any[]): any }>(constructor: T) {
return class extends constructor {
private static _instance: any;
constructor(...args: any[]) {
super(...args);
if ((this.constructor as any)._instance) {
return (this.constructor as any)._instance;
}
(this.constructor as any)._instance = this;
}
};
}
function Timestamped<T extends { new (...args: any[]): {} }>(Base: T) {
return class extends Base {
createdAt = new Date();
updatedAt = new Date();
touch() {
this.updatedAt = new Date();
}
};
}
@Singleton
@Timestamped
class ConfigService {
// 只會有一個實例,且自動帶有時間戳記
private config = {};
}
- 這裡的
@Singleton與@Timestamped皆是 類別裝飾器,它們會依序返回新的子類別。最外層的@Timestamped會先執行,然後把結果交給@Singleton處理。 - 實務意義:在需要「全域唯一」且「自動追蹤變更時間」的服務(如設定服務、快取服務)時,組合類別裝飾器是非常乾淨的寫法。
(4) 裝飾器參數化與可組合的工廠模式
有時候你想根據環境或設定動態決定要套用哪幾個裝飾器。這時可以寫一個 工廠函式,返回適當的組合裝飾器:
function createMethodDecorators(env: 'dev' | 'prod'): MethodDecorator {
const devDecorators = [Log];
const prodDecorators = [Authorize('admin'), Cache(60000)];
const decorators = env === 'dev' ? devDecorators : prodDecorators;
return Compose(...decorators);
}
class ApiService {
@createMethodDecorators(process.env.NODE_ENV === 'development' ? 'dev' : 'prod')
getSecretData(id: string) {
// 真正的業務邏輯
return { id, secret: '***' };
}
}
- 透過 條件判斷,在開發環境只記錄日誌,正式環境則加入授權與快取,不需要重複寫多個版本的程式碼。
3. 裝飾器的返回值與覆寫行為
- 類別裝飾器:若回傳一個新構造函式,原始類別會被取代。多個類別裝飾器會依序把「新類別」傳遞給下一個裝飾器。
- 屬性/方法裝飾器:返回
PropertyDescriptor(或void)。若返回undefined,則保留原本的描述子。多個方法裝飾器的返回值會被下一個裝飾器接收,因此不要忘記回傳 descriptor,否則後面的裝飾器會得到undefined。
常見錯誤:忘記在自訂裝飾器最後
return descriptor;,導致後續裝飾器失效或拋出錯誤。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 執行順序不符預期 | 多個裝飾器的執行順序是「從下往上」;若寫錯順序會導致快取、授權、日誌的相互影響。 | 在設計時先畫出「流程圖」,或使用 Compose 明確控制順序。 |
| 裝飾器返回值遺失 | 裝飾器若未回傳 descriptor,後面的裝飾器會收到 undefined。 |
永遠在自訂裝飾器最後 return descriptor;(或 `return result |
| 副作用共享 | 多個實例共用同一個快取或狀態(例如在裝飾器外部宣告的變數),可能導致資料污染。 | 使用 WeakMap 或將狀態封裝在返回的類別/方法內。 |
| 裝飾器過度堆疊 | 堆疊過多裝飾器會使程式碼難以追蹤,且產生性能開銷。 | 把相關功能抽象成 單一組合裝飾器,避免在每個方法上寫太多。 |
| 類別層級裝飾器與繼承衝突 | 子類別繼承了父類別的裝飾器,若子類別再次裝飾同一屬性,可能產生重複行為。 | 在子類別使用 @Override(自行實作)或在父類別提供 可選的 hook。 |
最佳實踐:
- 單一職責:每個裝飾器只負責一件事(Log、Cache、Authorize…),透過組合達成複雜需求。
- 可組合性:使用
Compose或類似的工廠模式,使裝飾器可以自由排列組合。 - 明確文件:在程式碼註解或 README 中說明每個裝飾器的執行順序與副作用。
- 測試覆蓋:針對每個裝飾器寫單元測試,並測試組合後的行為,以免因順序變更破壞功能。
實際應用場景
1. REST API 控制器的統一處理
在 Express 或 NestJS 中,常見需求是 同時記錄請求日誌、驗證 JWT、以及快取 GET 結果。利用多重裝飾器可以把這三件事寫在同一行:
function JwtAuth(): MethodDecorator {
return function (target, key, descriptor) {
const original = descriptor.value;
descriptor.value = function (req: any, res: any, ...args: any[]) {
const token = req.headers.authorization?.split(' ')[1];
if (!token || !verifyJwt(token)) {
return res.status(401).send('Unauthorized');
}
return original.apply(this, [req, res, ...args]);
};
return descriptor;
};
}
// 快取裝飾器(簡易版)
function SimpleCache(ttl: number): MethodDecorator {
const cache = new Map<string, any>();
return function (target, key, descriptor) {
const original = descriptor.value;
descriptor.value = async function (req: any, res: any, ...args: any[]) {
const cacheKey = `${key}-${JSON.stringify(req.query)}`;
if (cache.has(cacheKey)) {
return res.json(cache.get(cacheKey));
}
const result = await original.apply(this, [req, res, ...args]);
cache.set(cacheKey, result);
setTimeout(() => cache.delete(cacheKey), ttl);
return result;
};
return descriptor;
};
}
class UserController {
@Compose(Log, JwtAuth(), SimpleCache(10000))
async getProfile(req: any, res: any) {
// 假設這裡會呼叫資料庫
const user = await db.findUser(req.userId);
return res.json(user);
}
}
- 優點:控制器本身只關注業務邏輯,所有跨切面需求被抽離成可重用的裝飾器。
2. 前端狀態管理(Vue/React)中的自動快取與重試
在大型前端專案,對 API 的呼叫常需要 自動快取、失敗重試、以及 開發模式下的 console.log。可用多重裝飾器封裝:
function Retry(times: number, delayMs = 200): MethodDecorator {
return function (target, key, descriptor) {
const original = descriptor.value;
descriptor.value = async function (...args: any[]) {
let attempt = 0;
while (attempt < times) {
try {
return await original.apply(this, args);
} catch (e) {
attempt++;
if (attempt >= times) throw e;
await new Promise(res => setTimeout(res, delayMs));
}
}
};
return descriptor;
};
}
// 用於服務類別
class ApiService {
@Compose(Log, Cache(120000), Retry(3, 500))
async fetchPosts() {
const resp = await fetch('/api/posts');
return resp.json();
}
}
- 實務效益:前端開發者不必在每個 API 方法裡寫重試或快取邏輯,只要在類別層級掛上對應的組合裝飾器即可。
3. 微服務間的跨域請求追蹤
在微服務架構中,常需要在每一次遠端呼叫時 自動加入 trace-id,同時 記錄呼叫耗時。以下示範如何以多重裝飾器完成:
function TraceId(): MethodDecorator {
return function (target, key, descriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
const traceId = generateTraceId();
// 假設所有 HTTP 客戶端會自動讀取此全域變數
(global as any).currentTraceId = traceId;
return original.apply(this, args);
};
return descriptor;
};
}
function Timing(): MethodDecorator {
return function (target, key, descriptor) {
const original = descriptor.value;
descriptor.value = async function (...args: any[]) {
const start = Date.now();
const result = await original.apply(this, args);
const end = Date.now();
console.log(`[Timing] ${key} took ${end - start}ms`);
return result;
};
return descriptor;
};
}
class OrderService {
@Compose(TraceId, Timing)
async placeOrder(order: any) {
// 呼叫其他微服務
await httpClient.post('/payment', order);
return { success: true };
}
}
- 結果:每一次
placeOrder都會自動帶上唯一的 trace-id,且在 console 中留下耗時紀錄,方便後續分散式追蹤。
總結
- 裝飾器組合 讓我們能把多個關注點以 可組合、可重用 的方式混合在同一個類別或方法上。
- 執行順序是「從下往上」;了解這點是避免行為衝突的關鍵。
- 透過
Compose、工廠函式或條件判斷,我們可以 動態產生 適合不同環境或需求的裝飾器組合。 - 常見陷阱包括返回值遺失、順序誤用、共享狀態污染等;遵守 單一職責、明確文件、完整測試 的最佳實踐,可讓裝飾器在大型專案中保持可維護性。
- 在 API 控制器、前端服務、微服務追蹤 等實務場景中,裝飾器組合已證明能大幅減少重複程式碼、提升可讀性與可測試性。
掌握了多重裝飾的概念與技巧,你就能在 TypeScript 項目中以 乾淨、彈性 的方式處理跨切面需求,讓程式碼更具表達力,也更易於維護。祝你寫出更優雅的 TypeScript!