深入理解 TypeScript Decorator 裝飾器
前言
我第一次在工作裡接觸到 Decorator,是團隊後端從 Koa 技術轉型到 Nest.js 的那段期間。剛開始使用 Nest.js 時,其實我完全不清楚 Decorator 的原理,大多都是文件怎麼寫,我就怎麼寫——這裡放 @Controller、那裡加 @Injectable,照著文件寫能跑起來再說。但這些東西在 TypeScript 裡究竟是怎麼運作?背後為什麼要這樣設計?我其實完全沒有概念,一直停留在「照著範例抄」的階段。
最近想花點時間把 Decorator 的底層原理釐清,所以整理了這份筆記。內容包含我如何理解 Decorator、為什麼會需要它、以及我自己在實作過程中觀察到的行為,希望能把這些想法整理成一篇清楚的文章。
什麼是 Decorator?
Decorator 的本質
我們可以把 Decorator 想成是:「在不改原本程式邏輯的前提下,替類別或方法貼上一些標籤(metadata),後續的程式碼或框架可以根據這些標籤做事」。這樣的方式可以把業務邏輯跟額外的關注點(像是日誌、權限、快取)分開來看,程式更好維護也比較乾淨。
為什麼需要 Decorator?
在沒有 Decorator 之前,開發者們常常需要在每個方法內重複撰寫記錄日誌、權限檢查這類程式碼:
// 沒有 Decorator 的世界
class UserService {
createUser(name: string) {
// 手動加上日誌
console.log("開始創建用戶:", name);
// 手動檢查權限
if (!this.hasPermission()) {
throw new Error("沒有權限");
}
// 實際的業務邏輯
return { name, id: Math.random() };
}
updateUser(id: number, name: string) {
// 又要重複寫一次日誌
console.log("開始更新用戶:", id, name);
// 又要重複檢查權限
if (!this.hasPermission()) {
throw new Error("沒有權限");
}
// 實際的業務邏輯
return { id, name };
}
}
造成的結果是:
- 🔴 重複程式碼太多:每個方法都要寫日誌、檢查權限
- 🔴 業務邏輯不清晰:真正的邏輯被這些「雜事」淹沒了
- 🔴 難以維護:如果要改日誌格式,每個方法都要改
在有了 Decorator 之後,我們可以把程式改寫如下:
// 有 Decorator 的世界
class UserService {
@Log
@CheckPermission
createUser(name: string) {
// 只專注在業務邏輯
return { name, id: Math.random() };
}
@Log
@CheckPermission
updateUser(id: number, name: string) {
// 只專注在業務邏輯
return { id, name };
}
}
有 Decorator 輔助的好處顯而易見:
- ✅ 程式碼乾淨:業務邏輯一目了然
- ✅ 可重用:
@Log和@CheckPermission可以用在任何方法上 - ✅ 易維護:要改日誌格式只需要改一個地方
Decorator 的基礎語法
Decorator 使用 @expression 的形式,其中 expression 必須是一個函數:
@DecoratorName
class MyClass {}
@DecoratorName
method() {}
@DecoratorName
property: string;
如果需要傳入參數,通常會寫成「裝飾器工廠」(Decorator Factory):先呼叫一個函式傳參數,再回傳真正的裝飾器函式。
@DecoratorName("參數1", "參數2")
class MyClass {}
多個 Decorator 疊加的執行順序
一個方法可以有多個 Decorator,它們的執行順序是由下往上:
class Example {
@First
@Second
@Third
method() {}
}
// 執行順序:Third → Second → First
TypeScript Decorator 的五種類型
TypeScript 提供了五種 Decorator,分別可以裝飾不同的目標:
| Decorator 類型 | 裝飾目標 | 使用時機 |
|---|---|---|
Class Decorator | 類別 | 為類別添加元數據、修改建構函數 |
Method Decorator | 方法 | 日誌、性能測量、權限檢查 |
Property Decorator | 屬性 | 資料驗證、序列化配置 |
Accessor Decorator | getter/setter | 攔截屬性存取、快取 |
Parameter Decorator | 方法參數 | 參數驗證、依賴注入 |
接下來,這篇文章會一個一個深入介紹。
必要的 tsconfig.json 配置
由於 Decorator 是 TypeScript 的實驗性功能,預設是關閉的。我們必須在 tsconfig.json 中啟用它:
{
"compilerOptions": {
"experimentalDecorators": true, // 必須設為 true
"target": "ES2020", // 建議 ES2015 以上
"module": "commonjs"
}
}
由於 Decorator 目前還在 TC39(JavaScript 標準委員會)的提案階段。TypeScript 提前實現了這個功能,但語法可能會隨著標準演進而改變。不過也不用擔心,主流框架(Angular、NestJS、TypeORM)都在使用,並且已經相當 穩定。
- 如果沒有啟用
experimentalDecorators,使用 Decorator 語法會報錯 - 對於 Parameter Decorator,還需要安裝
reflect-metadata套件(稍後會詳細說明)
Class Decorator
基本概念
Class Decorator 是應用在類別聲明上的函數。它接收類別的建構函數作為參數,可以觀察、修改或替換類別定義。
function ClassDecorator(constructor: Function) {
// constructor 就是被裝飾的類別
}
範例 1:觀察類別
最簡單的 Class Decorator,只是記錄類別的資訊:
function LogClass(constructor: Function) {
console.log("類別名稱:", constructor.name);
console.log("建構函數:", constructor);
}
@LogClass
class User {
name = "Bosh";
}
// 輸出:
// 類別名稱: User
// 建構函數: [class User]
- Decorator 是在類別定義時執行,不是在創建實例時
- 這意味著上面的
console.log會在程式編譯時就執行,而不是new User()時
範例 2:Decorator Factory(接受參數)
如果你想要傳入參數來自訂 Decorator 的行為,就需要使用 Decorator Factory:
function Component(name: string) {
// 這是 Factory,返回真正的 Decorator
return function (constructor: Function) {
console.log(`Component: ${name}`);
// 在 prototype 上添加屬性
(constructor.prototype as any).componentName = name;
};
}
@Component("LoginButton")
class Button {
render() {
return "Button";
}
}
const button = new Button();
console.log((button as any).componentName); // 'LoginButton'
範例 3:擴展類別
Decorator 可以返回一個新的建構函數來替換原類別,這樣就能為類別添加新的屬性或方法:
function Timestamped<T extends { new (...args: any[]): {} }>(constructor: T) {
// 返回一個新的類別,繼承原類別
return class extends constructor {
createdAt = new Date();
};
}
@Timestamped
class Article {
title = "Hello Decorator";
}
const article = new Article() as any;
console.log(article.title); // 'Hello Decorator'
console.log(article.createdAt); // 當前時間
@Timestamped裝飾器接收 Article 類別- 返回一個新類別,這個新類別繼承自 Article
- 新類別多了一個 createdAt 屬性
- TypeScript 用這個新類別替換原本的 Article
Class Decorator 實務應用場景
Class Decorator 在實務上常見的應用:
- 依賴注入(Angular、NestJS)
@Injectable()
class UserService {}
- ORM 實體定義(TypeORM)
@Entity()
class User {
@Column()
name: string;
}
- 自動註冊
@AutoRegister
class MyPlugin {}
Method Decorator
基本概念
Method Decorator 是應用在方法上的函數。它可以觀察、修改或替換方法的定義。
function MethodDecorator(
target: any, // 類別的 prototype(實例方法)或建構函數(靜態方法)
propertyKey: string, // 方法的名稱
descriptor: PropertyDescriptor // 方法的屬性描述符
) {
// descriptor.value 就是方法本身
}
理解 PropertyDescriptor
在深入範例前,我們需要先理解 PropertyDescriptor。它是一個物件,描述了屬性的特徵:
{
value: any, // 屬性的值(對方法來說就是函數本身)
writable: boolean, // 是否 可以被重新賦值
enumerable: boolean, // 是否可以被列舉(for...in、Object.keys)
configurable: boolean // 是否可以被刪除或重新定義
}
如果你熟悉 JavaScript 的 Object.defineProperty,你可能會覺得這個結構很眼熟。沒錯,它們本質上是同一個東西!
Object.defineProperty 讓我們可以精確控制物件屬性的行為:
// Object.defineProperty 的用法
Object.defineProperty(obj, "propertyName", {
value: "some value",
writable: true,
enumerable: true,
configurable: true,
});
在 Method Decorator 中,TypeScript 讓你直接拿到這個 descriptor,修改它,然後返回。這跟你手動呼叫 Object.defineProperty 是一樣的效果:
// Decorator 做的事
function MyDecorator(target, propertyKey, descriptor) {
descriptor.value = newFunction; // 修改 descriptor
return descriptor; // 返回修改後的 descriptor
}
// 等同於
Object.defineProperty(target, propertyKey, {
value: newFunction,
writable: descriptor.writable,
enumerable: descriptor.enumerable,
configurable: descriptor.configurable,
});
所以 Decorator 其實是讓你用更優雅的方式來操作物件的屬性定義。
範例 1:日誌記錄
最常見的應用就是自動記錄方法的呼叫:
function Log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log(`@Log 裝飾 ${propertyKey} 方法`);
// 保存原始方法
const originalMethod = descriptor.value;
// 替換為新方法
descriptor.value = function (...args: any[]) {
console.log(`調用: ${propertyKey}(${args.join(", ")})`);
// 呼叫原始方法
const result = originalMethod.apply(this, args);
console.log(`返回:`, result);
return result;
};
return descriptor;
}
class Calculator {
@Log
add(a: number, b: number): number {
return a + b;
}
}
const calc = new Calculator();
calc.add(5, 3);
// 輸出:
// @Log 裝飾 add 方法
// 調用: add(5, 3)
// 返回: 8
範例 2:執行時間測量
想知道某個方法執行了多久也可以用 Decorator 輕鬆搞定:
function Measure(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
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(`${propertyKey} 執行時間: ${(end - start).toFixed(2)}ms`);
return result;
};
return descriptor;
}
class DataProcessor {
@Measure
processLargeArray() {
let sum = 0;
for (let i = 0; i < 1000000; i++) {
sum += i;
}
return sum;
}
}
const processor = new DataProcessor();
processor.processLargeArray();
// processLargeArray 執行時間: 2.34ms
範例 3:錯誤處理
統一處理方法中的錯誤:
function CatchError(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
try {
return originalMethod.apply(this, args);
} catch (error: any) {
console.error(`${propertyKey} 發生錯誤: ${error.message}`);
throw error; // 重新拋出,讓呼叫方決定如何處理
}
};
return descriptor;
}
class Calculator {
@CatchError
divide(a: number, b: number): number {
if (b === 0) throw new Error("除數不能為 0");
return a / b;
}
}
const calc = new Calculator();
calc.divide(10, 2); // 正常執行
calc.divide(10, 0); // divide 發生錯誤: 除數不能為 0
重要觀念解析: 為什麼要用 apply(this, args) 而不是直接呼叫原方法?
❌ 錯誤做法:
descriptor.value = function (...args: any[]) {
// 錯誤:直接呼叫,this 會丟失!
const result = originalMethod(...args);
return result;
};
當你直接呼叫 originalMethod(...args) 時,方法內部的 this 會變成 undefined(嚴格模式)或全域物件(非嚴格模式)。
這跟 JavaScript 的 this 綁定規則有關,在 JavaScript 中,this 的值取決於函數如何被呼叫,而不是在哪裡定義:
class Person {
name = "Bosh";
greet() {
console.log(this.name);
}
}
const person = new Person();
// 情況 1: 作為方法呼叫
person.greet(); // 'Bosh' ✅ this = person
// 情況 2: 把方法拿出來單獨呼叫
const greetFunc = person.greet;
greetFunc(); // undefined 或錯誤 ❌ this 丟失!
在 Decorator 中,當你這樣寫:
const result = originalMethod(...args); // ❌
這等同於「情況 2」,方法被單獨呼叫,沒有跟任何物件綁定,所以:
- 嚴格模式下(TypeScript 預設):
this是undefined - 非嚴格模式下:
this是全域物件(瀏覽器的window或 Node.js 的global)
✅ 正確做法:
descriptor.value = function (...args: any[]) {
// 正確:使用 apply 綁定 this
const result = originalMethod.apply(this, args);
return result;
};
apply(this, args) 做了兩件事:
- 將當前函數的
this(也就是實例本身)傳遞給原方法 - 將參數陣列展開傳入
其他寫法:
// 方法 1: 使用 apply(推薦)
const result = originalMethod.apply(this, args);
// 方法 2: 使用 call(參數較少時)
const result = originalMethod.call(this, arg1, arg2);
// 方法 3: 使用 bind(較少用)
const boundMethod = originalMethod.bind(this);
const result = boundMethod(...args);
Method Decorator 實務應用場景
- 權限檢查
@RequireAuth
@RequireRole('admin')
deleteUser(id: number) {}
- 快取
@Cache(3600) // 快取 1 小時
getExpensiveData() {}
- Transaction 管理
@Transaction
async updateUserAndOrder() {}