TypeScript 裝飾器:屬性裝飾器(Property Decorators)
簡介
在大型前端或 Node.js 專案中,隨著需求的增長,**類別(class)**的結構往往變得越來越複雜。除了繼承與介面之外,TypeScript 於 ES7 引入的 裝飾器(Decorator) 為我們提供了一種「在不改變原始程式碼的前提下」為類別、方法、屬性、參數或存取子(accessor)「加上額外行為」的強大工具。
屬性裝飾器是四種主要裝飾器(類別、方法、屬性、參數)中最直觀、最常用的一環。它可以在屬性被定義時執行自訂邏輯,例如 自動驗證、日誌、依賴注入、序列化/反序列化 等。掌握屬性裝飾器的寫法與原理,能讓你在開發中寫出更乾淨、可維護的程式碼。
本篇文章將從概念、語法、實作範例一步一步說明,並提供常見陷阱與最佳實踐,最後列出幾個實務應用情境,幫助你快速上手屬性裝飾器。
核心概念
1. 裝飾器的基本運作原理
在 TypeScript 中,裝飾器本質上是一個 函式,它會在類別宣告階段被呼叫。對於屬性裝飾器而言,函式會收到兩個參數:
| 參數 | 說明 |
|---|---|
target |
宣告屬性的類別原型(prototype)或靜態屬性的建構子(constructor)。 |
propertyKey |
屬性的名稱(字串或 Symbol)。 |
function MyPropertyDecorator(target: any, propertyKey: string | symbol) {
// 這裡可以寫任何想要在屬性定義時執行的程式碼
}
注意:屬性裝飾器不會直接取得屬性的值,因為在宣告階段,屬性本身還未被實例化。若需要操作值,通常會結合 存取子(getter / setter) 或
Object.defineProperty來實作。
2. 基本語法與使用方式
function LogProperty(target: any, propertyKey: string) {
console.log(`屬性 ${propertyKey} 被宣告在`, target);
}
class User {
@LogProperty
name: string = 'Alice';
}
執行時會在類別定義階段印出:
屬性 name 被宣告在 { constructor: [Function: User] }
3. 為屬性加上 getter / setter 的技巧
若希望在存取屬性時自動觸發行為(例如驗證或自動轉換),可以在裝飾器內使用 Object.defineProperty 重新定義屬性:
function TrimString(target: any, propertyKey: string) {
let value: string;
const getter = function () {
return value;
};
const setter = function (newVal: string) {
value = typeof newVal === 'string' ? newVal.trim() : newVal;
};
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
enumerable: true,
configurable: true,
});
}
class Post {
@TrimString
title: string = ' Hello World ';
}
const p = new Post();
console.log(p.title); // => "Hello World"
重點:此方式會在 原型上 定義存取子,所有實例共享同一套 getter / setter,適合純屬性而非每個實例獨立的情境。
4. 參數化的屬性裝飾器
有時候需要根據外部參數決定裝飾器的行為,例如設定最大長度、必填等。這時可以先寫一個「工廠函式」返回真正的裝飾器:
function MaxLength(limit: number) {
return function (target: any, propertyKey: string) {
let value: string;
const getter = () => value;
const setter = (newVal: string) => {
if (newVal.length > limit) {
throw new Error(`屬性 ${propertyKey} 的長度不能超過 ${limit}`);
}
value = newVal;
};
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
enumerable: true,
configurable: true,
});
};
}
class Comment {
@MaxLength(200)
content: string = '';
}
5. 與 reflect-metadata 結合的高階用法
TypeScript 官方提供的 experimentalDecorators 只能取得屬性的 名稱,若想取得屬性的 型別資訊,需要配合 reflect-metadata:
import 'reflect-metadata';
function Type() {
return function (target: any, propertyKey: string) {
const type = Reflect.getMetadata('design:type', target, propertyKey);
console.log(`屬性 ${propertyKey} 的型別是 ${type.name}`);
};
}
class Product {
@Type()
price: number = 0;
}
執行結果:
屬性 price 的型別是 Number
實務提示:在需要自動序列化、驗證或依賴注入的框架(如
class-transformer、routing-controllers)中,通常會大量使用reflect-metadata來取得型別資訊。
程式碼範例
下面提供 五個 常見且實用的屬性裝飾器範例,從簡單到進階,涵蓋日誌、驗證、依賴注入與序列化。
範例 1:屬性日誌裝飾器
function LogAccess(target: any, propertyKey: string) {
const privateKey = `__${propertyKey}`;
Object.defineProperty(target, propertyKey, {
get() {
console.log(`讀取屬性 ${propertyKey},值為 =>`, this[privateKey]);
return this[privateKey];
},
set(value: any) {
console.log(`設定屬性 ${propertyKey},新值 =>`, value);
this[privateKey] = value;
},
enumerable: true,
configurable: true,
});
}
class Settings {
@LogAccess
theme: string = 'light';
}
const s = new Settings();
s.theme = 'dark'; // 設定屬性 theme,新值 => dark
console.log(s.theme); // 讀取屬性 theme,值為 => dark
適用情境:除錯或在開發階段追蹤屬性的變化。
範例 2:必填屬性驗證
function Required(target: any, propertyKey: string) {
let value: any;
const getter = () => value;
const setter = (newVal: any) => {
if (newVal === null || newVal === undefined) {
throw new Error(`屬性 ${propertyKey} 為必填`);
}
value = newVal;
};
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
enumerable: true,
configurable: true,
});
}
class Order {
@Required
orderId!: string; // 用非空斷言告訴編譯器我們會自行保證
@Required
amount!: number;
}
const o = new Order();
o.orderId = 'A001';
o.amount = 100; // 正常
// o.amount = undefined; // 會拋出 Error: 屬性 amount 為必填
適用情境:DTO(資料傳輸物件)或表單模型的必填欄位驗證。
範例 3:字串長度限制
function Length(min: number, max: number) {
return function (target: any, propertyKey: string) {
let value: string;
const getter = () => value;
const setter = (newVal: string) => {
if (typeof newVal !== 'string') {
throw new Error(`屬性 ${propertyKey} 必須是字串`);
}
if (newVal.length < min || newVal.length > max) {
throw new Error(
`屬性 ${propertyKey} 長度須在 ${min}-${max} 之間,現在是 ${newVal.length}`
);
}
value = newVal;
};
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
enumerable: true,
configurable: true,
});
};
}
class Profile {
@Length(2, 20)
nickname: string = '';
}
const p = new Profile();
p.nickname = 'Tom'; // OK
// p.nickname = 'A'; // Error: 長度須在 2-20 之間
範例 4:簡易依賴注入(DI)容器
// 1️⃣ 建立一個全域的服務容器
const ServiceContainer = new Map<string, any>();
function Service(name: string) {
return function (constructor: new (...args: any[]) => any) {
ServiceContainer.set(name, new constructor());
};
}
// 2️⃣ 建立屬性注入裝飾器
function Inject(name: string) {
return function (target: any, propertyKey: string) {
Object.defineProperty(target, propertyKey, {
get() {
return ServiceContainer.get(name);
},
enumerable: true,
configurable: true,
});
};
}
// 3️⃣ 定義服務與使用者
@Service('logger')
class Logger {
log(msg: string) {
console.log('[LOG]', msg);
}
}
class Controller {
@Inject('logger')
private logger!: Logger;
handle() {
this.logger.log('Request received');
}
}
new Controller().handle(); // [LOG] Request received
適用情境:小型專案或測試環境的手動 DI;在大型框架(NestJS)中,底層原理與此類似。
範例 5:自動序列化與反序列化(使用 reflect-metadata)
import 'reflect-metadata';
const SERIALIZE_KEY = Symbol('serialize');
function Serialize() {
return function (target: any, propertyKey: string) {
const type = Reflect.getMetadata('design:type', target, propertyKey);
const meta = Reflect.getMetadata(SERIALIZE_KEY, target) || [];
meta.push({ key: propertyKey, type });
Reflect.defineMetadata(SERIALIZE_KEY, meta, target);
};
}
function toPlain(obj: any): any {
const meta = Reflect.getMetadata(SERIALIZE_KEY, obj) || [];
const plain: any = {};
meta.forEach(({ key, type }) => {
const value = obj[key];
if (type === Date && value instanceof Date) {
plain[key] = value.toISOString();
} else {
plain[key] = value;
}
});
return plain;
}
class Event {
@Serialize()
name: string = '';
@Serialize()
date: Date = new Date();
}
const e = new Event();
e.name = 'Conference';
e.date = new Date('2025-01-01T09:00:00Z');
console.log(toPlain(e));
// { name: 'Conference', date: '2025-01-01T09:00:00.000Z' }
適用情境:在 API 開發中,將類別實例轉換成 JSON 時自動處理日期、枚舉或自訂型別。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
| 裝飾器執行時機不如預期 | 裝飾器在類別 宣告階段 執行,屬性值尚未被初始化。若在裝飾器內直接存取 this[propertyKey],往往得到 undefined。 |
使用 Object.defineProperty 重新定義 getter / setter,或在建構子內呼叫自訂初始化函式。 |
| 使用原型(prototype)導致共享狀態 | 在屬性裝飾器內直接寫 target[propertyKey] = ...,所有實例會共享同一個值。 |
透過 Object.defineProperty 在 instance(即 this)層級儲存私有欄位(如 __${key}),確保每個實例獨立。 |
缺少 reflect-metadata |
想取得型別資訊卻忘記匯入 reflect-metadata,會得到 undefined。 |
在入口檔案(如 main.ts)最上方 import 'reflect-metadata';,並在 tsconfig.json 開啟 experimentalDecorators、emitDecoratorMetadata。 |
| 過度使用裝飾器 | 裝飾器過度堆疊會讓程式碼難以追蹤,尤其在大型專案中。 | 僅在「跨切面」需求(如驗證、日誌、DI)使用,其他邏輯仍建議寫在方法或服務內。 |
| 裝飾器與類別繼承衝突 | 子類別若重新定義同名屬性,父類別的裝飾器可能不會再次執行。 | 若需要在子類別重新應用裝飾器,請在子類別上再次使用相同的裝飾器或使用 @Override(自訂)機制。 |
最佳實踐
- 保持裝飾器單一職責:每個裝飾器只負責一件事(例如「驗證」或「日誌」),避免混合多種行為。
- 使用私有 Symbol 作為儲存欄位:
const storage = Symbol('prop');可避免名稱衝突。 - 在測試環境中禁用裝飾器:若裝飾器內有副作用,使用環境變數或條件判斷避免在單元測試時觸發。
- 文件化裝飾器的行為:在程式碼註解或 README 中說明裝飾器會改變哪些屬性、何時拋錯,提升可讀性。
- 配合 lint 規則:如
eslint-plugin-decorator或自訂規則,確保裝飾器使用的一致性。
實際應用場景
| 場景 | 使用的屬性裝飾器 | 為什麼需要 |
|---|---|---|
| 表單資料驗證 | @Required、@Length、@Pattern |
前端或後端在接收 DTO 前自動檢查欄位合法性,減少冗長的手寫驗證程式。 |
| API 日誌追蹤 | @LogAccess、@LogChange |
監控關鍵屬性的讀寫,協助除錯或審計。 |
| 依賴注入容器 | @Inject |
讓類別只關心業務邏輯,容器負責提供所需服務,提升可測試性。 |
| 序列化/反序列化 | @Serialize、@Transform(配合 class-transformer) |
在與後端交換 JSON 時自動處理日期、枚舉、嵌套物件的轉換。 |
| 多語系屬性 | @LocaleString(自訂) |
根據使用者語系自動返回對應的文字,避免在 UI 中寫大量條件判斷。 |
以上場景均可在 NestJS、Angular、Vue Class Component 等框架中看到類似的應用,學會自行實作屬性裝飾器,可讓你在這些框架之外也能靈活擴充功能。
總結
屬性裝飾器是 TypeScript 裝飾器家族中最貼近「屬性」概念的工具,透過 函式、Object.defineProperty 與 reflect-metadata,我們可以在屬性宣告時自動注入日誌、驗證、依賴注入、序列化等橫切關注點。
本文從概念說明、語法結構、五個實用範例、常見陷阱與最佳實踐,最後列出真實的應用情境,提供了一條 從入門到實務 的完整學習路徑。只要遵守「單一職責」與「避免共享狀態」的原則,屬性裝飾器將成為你在大型 TypeScript 專案中提升可維護性、可測試性與開發效率的強大武器。
快把這些範例搬進自己的程式碼庫,體驗「寫得更少、錯得更少」的開發快感吧! 🚀