探索 TypeScript 装饰器的强大功能,用于元数据编程、面向切面编程,并通过声明式模式增强代码。一份面向全球开发者的综合指南。
TypeScript 装饰器:掌握元数据编程模式以构建稳健的应用程序
在现代软件开发的广阔领域中,维护清晰、可扩展且易于管理的代码库至关重要。TypeScript 凭借其强大的类型系统和高级功能,为开发者提供了实现这一目标的工具。其中最引人入胜和最具变革性的功能之一就是装饰器 (Decorators)。尽管在撰写本文时,装饰器仍是一项实验性功能(ECMAScript 的 Stage 3 提案),但它们已在 Angular 和 TypeORM 等框架中被广泛使用,从根本上改变了我们处理设计模式、元数据编程和面向切面编程 (AOP) 的方式。
本综合指南将深入探讨 TypeScript 装饰器,探索其工作机制、各种类型、实际应用和最佳实践。无论你是在构建大规模企业应用、微服务,还是客户端 Web 界面,理解装饰器都将使你能够编写出更具声明性、可维护性且功能更强大的 TypeScript 代码。
理解核心概念:什么是装饰器?
从本质上讲,装饰器是一种特殊的声明,可以附加到类声明、方法、访问器、属性或参数上。装饰器是函数,它们为其所装饰的目标返回一个新值(或修改现有值)。其主要目的是添加元数据或更改其所附加声明的行为,而无需直接修改底层代码结构。这种外部的、声明式地增强代码的方式非常强大。
你可以将装饰器看作是应用于代码某些部分的注解或标签。这些标签随后可以被应用程序的其他部分或框架读取或执行操作,通常在运行时提供额外的功能或配置。
装饰器的语法
装饰器以 @
符号为前缀,后跟装饰器函数的名称。它们紧邻其所装饰的声明之前。
@MyDecorator
class MyClass {
@AnotherDecorator
myMethod() {
// ...
}
}
在 TypeScript 中启用装饰器
在使用装饰器之前,你必须在你的 tsconfig.json
文件中启用 experimentalDecorators
编译器选项。此外,为了实现高级的元数据反射功能(框架常用),你还需要 emitDecoratorMetadata
和 reflect-metadata
polyfill。
// tsconfig.json
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
你还需要安装 reflect-metadata
:
npm install reflect-metadata --save
# 或
yarn add reflect-metadata
并在你的应用程序入口文件(例如 main.ts
或 app.ts
)的顶部导入它:
import "reflect-metadata";
// 你的应用程序代码紧随其后
装饰器工厂:触手可及的定制化
虽然基本的装饰器是一个函数,但通常你需要向装饰器传递参数来配置其行为。这可以通过使用装饰器工厂来实现。装饰器工厂是返回实际装饰器函数的函数。当你应用装饰器工厂时,你用参数调用它,然后它会返回 TypeScript 应用于你的代码的装饰器函数。
创建一个简单的装饰器工厂示例
让我们为一个 Logger
装饰器创建一个工厂,该装饰器可以用不同的前缀记录消息。
function Logger(prefix: string) {
return function (target: Function) {
console.log(`[${prefix}] Class ${target.name} has been defined.`);
};
}
@Logger("APP_INIT")
class ApplicationBootstrap {
constructor() {
console.log("Application is starting...");
}
}
const app = new ApplicationBootstrap();
// 输出:
// [APP_INIT] Class ApplicationBootstrap has been defined.
// Application is starting...
在此示例中,Logger("APP_INIT")
是装饰器工厂的调用。它返回实际的装饰器函数,该函数接收 target: Function
(类构造函数)作为其参数。这允许对装饰器的行为进行动态配置。
TypeScript 中的装饰器类型
TypeScript 支持五种不同类型的装饰器,每种都适用于特定类型的声明。装饰器函数的签名根据其应用的上下文而有所不同。
1. 类装饰器
类装饰器应用于类声明。装饰器函数接收类的构造函数作为其唯一参数。类装饰器可以观察、修改甚至替换一个类定义。
签名:
function ClassDecorator(target: Function) { ... }
返回值:
如果类装饰器返回一个值,它将用提供的构造函数替换类声明。这是一个强大的功能,常用于 mixin 或类增强。如果没有返回值,则使用原始类。
使用场景:
- 在依赖注入容器中注册类。
- 向类应用 mixin 或附加功能。
- 框架特定的配置(例如,Web 框架中的路由)。
- 向类添加生命周期钩子。
类装饰器示例:注入服务
想象一个简单的依赖注入场景,你希望将一个类标记为“可注入的”,并可选择性地为其在容器中提供一个名称。
const InjectableServiceRegistry = new Map<string, Function>();
function Injectable(name?: string) {
return function<T extends { new(...args: any[]): {} }>(constructor: T) {
const serviceName = name || constructor.name;
InjectableServiceRegistry.set(serviceName, constructor);
console.log(`Registered service: ${serviceName}`);
// 可选地,你可以在这里返回一个新类来增强行为
return class extends constructor {
createdAt = new Date();
// 为所有注入的服务添加额外的属性或方法
};
};
}
@Injectable("UserService")
class UserDataService {
getUsers() {
return [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];
}
}
@Injectable()
class ProductDataService {
getProducts() {
return [{ id: 101, name: "Laptop" }, { id: 102, name: "Mouse" }];
}
}
console.log("--- Services Registered ---");
console.log(Array.from(InjectableServiceRegistry.keys()));
const userServiceConstructor = InjectableServiceRegistry.get("UserService");
if (userServiceConstructor) {
const userServiceInstance = new userServiceConstructor();
console.log("Users:", userServiceInstance.getUsers());
// console.log("User Service Created At:", userServiceInstance.createdAt); // 如果使用了返回的类
}
此示例演示了类装饰器如何注册一个类,甚至修改其构造函数。Injectable
装饰器使该类可被理论上的依赖注入系统发现。
2. 方法装饰器
方法装饰器应用于方法声明。它们接收三个参数:目标对象(对于静态成员,是构造函数;对于实例成员,是类的原型)、方法名和方法的属性描述符。
签名:
function MethodDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }
返回值:
方法装饰器可以返回一个新的 PropertyDescriptor
。如果返回了,这个描述符将用于定义该方法。这允许你修改或替换原始方法的实现,使其在 AOP 中非常强大。
使用场景:
- 记录方法调用及其参数/结果。
- 缓存方法结果以提高性能。
- 在方法执行前应用授权检查。
- 测量方法执行时间。
- 对方法调用进行防抖或节流。
方法装饰器示例:性能监控
让我们创建一个 MeasurePerformance
装饰器来记录方法的执行时间。
function MeasurePerformance(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
const start = process.hrtime.bigint();
const result = originalMethod.apply(this, args);
const end = process.hrtime.bigint();
const duration = Number(end - start) / 1_000_000;
console.log(`Method "${propertyKey}" executed in ${duration.toFixed(2)} ms`);
return result;
};
return descriptor;
}
class DataProcessor {
@MeasurePerformance
processData(data: number[]): number[] {
// 模拟一个复杂、耗时的操作
for (let i = 0; i < 1_000_000; i++) {
Math.sin(i);
}
return data.map(n => n * 2);
}
@MeasurePerformance
fetchRemoteData(id: string): Promise<string> {
return new Promise(resolve => {
setTimeout(() => {
resolve(`Data for ID: ${id}`);
}, 500);
});
}
}
const processor = new DataProcessor();
processor.processData([1, 2, 3]);
processor.fetchRemoteData("abc").then(result => console.log(result));
MeasurePerformance
装饰器用计时逻辑包装了原始方法,打印出执行时长,而不会使方法内部的业务逻辑变得混乱。这是面向切面编程 (AOP) 的一个经典示例。
3. 访问器装饰器
访问器装饰器应用于访问器(get
和 set
)声明。与方法装饰器类似,它们接收目标对象、访问器的名称及其属性描述符。
签名:
function AccessorDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }
返回值:
访问器装饰器可以返回一个新的 PropertyDescriptor
,该描述符将用于定义访问器。
使用场景:
- 对设置属性进行验证。
- 在设置值之前或检索值之后对其进行转换。
- 控制属性的访问权限。
访问器装饰器示例:缓存 Getter
让我们创建一个装饰器,用于缓存一个昂贵的 getter 计算结果。
function CachedGetter(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalGetter = descriptor.get;
const cacheKey = `_cached_${String(propertyKey)}`;
if (originalGetter) {
descriptor.get = function() {
if (this[cacheKey] === undefined) {
console.log(`[Cache Miss] Computing value for ${String(propertyKey)}`);
this[cacheKey] = originalGetter.apply(this);
} else {
console.log(`[Cache Hit] Using cached value for ${String(propertyKey)}`);
}
return this[cacheKey];
};
}
return descriptor;
}
class ReportGenerator {
private data: number[];
constructor(data: number[]) {
this.data = data;
}
// 模拟一个昂贵的计算
@CachedGetter
get expensiveSummary(): number {
console.log("Performing expensive summary calculation...");
return this.data.reduce((sum, current) => sum + current, 0) / this.data.length;
}
}
const generator = new ReportGenerator([10, 20, 30, 40, 50]);
console.log("First access:", generator.expensiveSummary);
console.log("Second access:", generator.expensiveSummary);
console.log("Third access:", generator.expensiveSummary);
此装饰器确保 expensiveSummary
getter 的计算只运行一次,后续调用将返回缓存的值。这种模式对于优化涉及大量计算或外部调用的属性访问性能非常有用。
4. 属性装饰器
属性装饰器应用于属性声明。它们接收两个参数:目标对象(对于静态成员,是构造函数;对于实例成员,是类的原型)和属性的名称。
签名:
function PropertyDecorator(target: Object, propertyKey: string | symbol) { ... }
返回值:
属性装饰器不能返回任何值。它们的主要用途是注册关于属性的元数据。它们不能在装饰时直接更改属性的值或其描述符,因为在属性装饰器运行时,属性的描述符尚未完全定义。
使用场景:
- 为序列化/反序列化注册属性。
- 对属性应用验证规则。
- 为属性设置默认值或配置。
- ORM (对象关系映射) 的列映射(例如,TypeORM 中的
@Column()
)。
属性装饰器示例:必填字段验证
让我们创建一个装饰器来将属性标记为“必需”,然后在运行时进行验证。
interface ValidationRule {
property: string | symbol;
validate: (value: any) => boolean;
message: string;
}
const validationRules: Map<Function, ValidationRule[]> = new Map();
function Required(target: Object, propertyKey: string | symbol) {
const rules = validationRules.get(target.constructor) || [];
rules.push({
property: propertyKey,
validate: (value: any) => value !== null && value !== undefined && value !== "",
message: `${String(propertyKey)} is required.`
});
validationRules.set(target.constructor, rules);
}
function validate(instance: any): string[] {
const classRules = validationRules.get(instance.constructor) || [];
const errors: string[] = [];
for (const rule of classRules) {
if (!rule.validate(instance[rule.property])) {
errors.push(rule.message);
}
}
return errors;
}
class UserProfile {
@Required
firstName: string;
@Required
lastName: string;
age?: number;
constructor(firstName: string, lastName: string, age?: number) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
}
const user1 = new UserProfile("John", "Doe", 30);
console.log("User 1 validation errors:", validate(user1)); // []
const user2 = new UserProfile("", "Smith");
console.log("User 2 validation errors:", validate(user2)); // ["firstName is required."]
const user3 = new UserProfile("Alice", "");
console.log("User 3 validation errors:", validate(user3)); // ["lastName is required."]
Required
装饰器只是将验证规则注册到一个中央的 validationRules
map 中。一个单独的 validate
函数然后使用这些元数据在运行时检查实例。这种模式将验证逻辑与数据定义分开,使其可重用且清晰。
5. 参数装饰器
参数装饰器应用于类构造函数或方法中的参数。它们接收三个参数:目标对象(对于静态成员,是构造函数;对于实例成员,是类的原型)、方法名(对于构造函数参数则为 undefined
)以及参数在函数参数列表中的序数索引。
签名:
function ParameterDecorator(target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) { ... }
返回值:
参数装饰器不能返回任何值。与属性装饰器一样,它们的主要作用是添加关于参数的元数据。
使用场景:
- 为依赖注入注册参数类型(例如,Angular 中的
@Inject()
)。 - 对特定参数应用验证或转换。
- 在 Web 框架中提取有关 API 请求参数的元数据。
参数装饰器示例:注入请求数据
让我们模拟一下 Web 框架如何使用参数装饰器将特定数据(例如来自请求的用户 ID)注入到方法参数中。
interface ParameterMetadata {
index: number;
key: string | symbol;
resolver: (request: any) => any;
}
const parameterResolvers: Map<Function, Map<string | symbol, ParameterMetadata[]>> = new Map();
function RequestParam(paramName: string) {
return function (target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) {
const targetKey = propertyKey || "constructor";
let methodResolvers = parameterResolvers.get(target.constructor);
if (!methodResolvers) {
methodResolvers = new Map();
parameterResolvers.set(target.constructor, methodResolvers);
}
const paramMetadata = methodResolvers.get(targetKey) || [];
paramMetadata.push({
index: parameterIndex,
key: targetKey,
resolver: (request: any) => request[paramName]
});
methodResolvers.set(targetKey, paramMetadata);
};
}
// 一个假设的框架函数,用于使用解析后的参数调用方法
function executeWithParams(instance: any, methodName: string, request: any) {
const classResolvers = parameterResolvers.get(instance.constructor);
if (!classResolvers) {
return (instance[methodName] as Function).apply(instance, []);
}
const methodParamMetadata = classResolvers.get(methodName);
if (!methodParamMetadata) {
return (instance[methodName] as Function).apply(instance, []);
}
const args: any[] = Array(methodParamMetadata.length);
for (const meta of methodParamMetadata) {
args[meta.index] = meta.resolver(request);
}
return (instance[methodName] as Function).apply(instance, args);
}
class UserController {
getUser(@RequestParam("id") userId: string, @RequestParam("token") authToken?: string) {
console.log(`Fetching user with ID: ${userId}, Token: ${authToken || "N/A"}`);
return { id: userId, name: "Jane Doe" };
}
deleteUser(@RequestParam("id") userId: string) {
console.log(`Deleting user with ID: ${userId}`);
return { status: "deleted", id: userId };
}
}
const userController = new UserController();
// 模拟一个传入的请求
const mockRequest = {
id: "user123",
token: "abc-123",
someOtherProp: "xyz"
};
console.log("\n--- Executing getUser ---");
executeWithParams(userController, "getUser", mockRequest);
console.log("\n--- Executing deleteUser ---");
executeWithParams(userController, "deleteUser", { id: "user456" });
这个例子展示了参数装饰器如何收集有关所需方法参数的信息。然后,框架可以在方法被调用时使用这些收集到的元数据来自动解析并注入适当的值,从而显著简化控制器或服务的逻辑。
装饰器组合与执行顺序
装饰器可以以各种组合方式应用,理解它们的执行顺序对于预测行为和避免意外问题至关重要。
单个目标上的多个装饰器
当多个装饰器应用于单个声明(例如,一个类、方法或属性)时,它们的求值顺序是特定的:从下到上,或从右到左。然而,它们的结果是以相反的顺序应用的。
@DecoratorA
@DecoratorB
class MyClass {
// ...
}
在这里,DecoratorB
将首先被求值,然后是 DecoratorA
。如果它们修改了类(例如,通过返回一个新的构造函数),那么来自 DecoratorA
的修改将包装或应用于来自 DecoratorB
的修改之上。
示例:链式方法装饰器
考虑两个方法装饰器:LogCall
和 Authorization
。
function LogCall(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[LOG] Calling ${String(propertyKey)} with args:`, args);
const result = originalMethod.apply(this, args);
console.log(`[LOG] Method ${String(propertyKey)} returned:`, result);
return result;
};
return descriptor;
}
function Authorization(roles: string[]) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const currentUserRoles = ["admin"]; // 模拟获取当前用户角色
const authorized = roles.some(role => currentUserRoles.includes(role));
if (!authorized) {
console.warn(`[AUTH] Access denied for ${String(propertyKey)}. Required roles: ${roles.join(", ")}`);
throw new Error("Unauthorized access");
}
console.log(`[AUTH] Access granted for ${String(propertyKey)}`);
return originalMethod.apply(this, args);
};
return descriptor;
};
}
class SecureService {
@LogCall
@Authorization(["admin"])
deleteSensitiveData(id: string) {
console.log(`Deleting sensitive data for ID: ${id}`);
return `Data ID ${id} deleted.`;
}
@Authorization(["user"])
@LogCall // 这里顺序改变了
fetchPublicData(query: string) {
console.log(`Fetching public data with query: ${query}`);
return `Public data for query: ${query}`;
}
}
const service = new SecureService();
try {
console.log("\n--- Calling deleteSensitiveData (Admin User) ---");
service.deleteSensitiveData("record123");
} catch (error: any) {
console.error(error.message);
}
try {
console.log("\n--- Calling fetchPublicData (Non-Admin User) ---");
// 模拟一个非管理员用户尝试访问需要 'user' 角色的 fetchPublicData
const mockUserRoles = ["guest"]; // 这将导致授权失败
// 要使其动态化,你需要一个 DI 系统或用于当前用户角色的静态上下文。
// 为简单起见,我们假设 Authorization 装饰器可以访问当前用户上下文。
// 让我们调整 Authorization 装饰器,为了演示目的总是假设为 'admin',
// 这样第一个调用成功,第二个失败,以显示不同的路径。
// 为了使 fetchPublicData 成功,用 user 角色重新运行。
// 想象 Authorization 中的 currentUserRoles 变为:['user']
// 在这个例子中,我们保持简单,只展示顺序效应。
service.fetchPublicData("search term"); // 这将执行 Auth -> Log
} catch (error: any) {
console.error(error.message);
}
/* deleteSensitiveData 的预期输出:
[AUTH] Access granted for deleteSensitiveData
[LOG] Calling deleteSensitiveData with args: [ 'record123' ]
Deleting sensitive data for ID: record123
[LOG] Method deleteSensitiveData returned: Data ID record123 deleted.
*/
/* fetchPublicData 的预期输出 (如果用户有 'user' 角色):
[LOG] Calling fetchPublicData with args: [ 'search term' ]
[AUTH] Access granted for fetchPublicData
Fetching public data with query: search term
[LOG] Method fetchPublicData returned: Public data for query: search term
*/
注意顺序:对于 deleteSensitiveData
,Authorization
(底部)首先运行,然后 LogCall
(顶部)包装它。Authorization
的内部逻辑首先执行。对于 fetchPublicData
,LogCall
(底部)首先运行,然后 Authorization
(顶部)包装它。这意味着 LogCall
切面将在 Authorization
切面之外。对于日志记录或错误处理等横切关注点,这种差异至关重要,因为执行顺序会显著影响行为。
不同目标的执行顺序
当一个类、其成员和参数都有装饰器时,执行顺序是明确定义的:
- 参数装饰器首先被应用,针对每个参数,从最后一个参数到第一个。
- 然后,方法、访问器或属性装饰器被应用于每个成员。
- 最后,类装饰器被应用于类本身。
在每个类别中,同一目标上的多个装饰器从下到上(或从右到左)应用。
示例:完整执行顺序
function log(message: string) {
return function (target: any, propertyKey: string | symbol | undefined, descriptorOrIndex?: PropertyDescriptor | number) {
if (typeof descriptorOrIndex === 'number') {
console.log(`参数装饰器: ${message} on parameter #${descriptorOrIndex} of ${String(propertyKey || "constructor")}`);
} else if (typeof propertyKey === 'string' || typeof propertyKey === 'symbol') {
if (descriptorOrIndex && 'value' in descriptorOrIndex && typeof descriptorOrIndex.value === 'function') {
console.log(`方法/访问器装饰器: ${message} on ${String(propertyKey)}`);
} else {
console.log(`属性装饰器: ${message} on ${String(propertyKey)}`);
}
} else {
console.log(`类装饰器: ${message} on ${target.name}`);
}
return descriptorOrIndex; // 为方法/访问器返回描述符,其他情况返回 undefined
};
}
@log("Class Level D")
@log("Class Level C")
class MyDecoratedClass {
@log("Static Property A")
static staticProp: string = "";
@log("Instance Property B")
instanceProp: number = 0;
@log("Method D")
@log("Method C")
myMethod(
@log("Parameter Z") paramZ: string,
@log("Parameter Y") paramY: number
) {
console.log("Method myMethod executed.");
}
@log("Getter/Setter F")
get myAccessor() {
return "";
}
set myAccessor(value: string) {
//...
}
constructor() {
console.log("Constructor executed.");
}
}
new MyDecoratedClass();
// 调用方法以触发方法装饰器
new MyDecoratedClass().myMethod("hello", 123);
/* 预测的输出顺序 (近似值,取决于具体的 TypeScript 版本和编译):
参数装饰器: Parameter Y on parameter #1 of myMethod
参数装饰器: Parameter Z on parameter #0 of myMethod
属性装饰器: Static Property A on staticProp
属性装饰器: Instance Property B on instanceProp
方法/访问器装饰器: Getter/Setter F on myAccessor
方法/访问器装饰器: Method C on myMethod
方法/访问器装饰器: Method D on myMethod
类装饰器: Class Level C on MyDecoratedClass
类装饰器: Class Level D on MyDecoratedClass
Constructor executed.
Method myMethod executed.
*/
确切的控制台日志时间可能会根据构造函数或方法何时被调用而略有不同,但装饰器函数本身被执行(以及它们的副作用或返回值被应用)的顺序遵循上述规则。
装饰器的实际应用和设计模式
装饰器,特别是与 reflect-metadata
polyfill 结合使用时,开启了一个元数据驱动编程的新领域。这允许强大的设计模式,可以抽象掉样板代码和横切关注点。
1. 依赖注入 (DI)
装饰器最突出的用途之一是在依赖注入框架中(如 Angular 的 @Injectable()
、@Component()
等,或 NestJS 对 DI 的广泛使用)。装饰器允许你直接在构造函数或属性上声明依赖关系,使框架能够自动实例化并提供正确的服务。
示例:简化的服务注入
import "reflect-metadata"; // 对 emitDecoratorMetadata 至关重要
const INJECTABLE_METADATA_KEY = Symbol("injectable");
const INJECT_METADATA_KEY = Symbol("inject");
function Injectable() {
return function (target: Function) {
Reflect.defineMetadata(INJECTABLE_METADATA_KEY, true, target);
};
}
function Inject(token: any) {
return function (target: Object, propertyKey: string | symbol, parameterIndex: number) {
const existingInjections: any[] = Reflect.getOwnMetadata(INJECT_METADATA_KEY, target, propertyKey) || [];
existingInjections[parameterIndex] = token;
Reflect.defineMetadata(INJECT_METADATA_KEY, existingInjections, target, propertyKey);
};
}
class Container {
private static instances = new Map<any, any>();
static resolve<T>(target: { new (...args: any[]): T }): T {
if (Container.instances.has(target)) {
return Container.instances.get(target);
}
const isInjectable = Reflect.getMetadata(INJECTABLE_METADATA_KEY, target);
if (!isInjectable) {
throw new Error(`Class ${target.name} is not marked as @Injectable.`);
}
// 获取构造函数参数的类型 (需要 emitDecoratorMetadata)
const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", target) || [];
const explicitInjections: any[] = Reflect.getMetadata(INJECT_METADATA_KEY, target) || [];
const dependencies = paramTypes.map((paramType, index) => {
// 如果提供了显式的 @Inject 令牌,则使用它,否则推断类型
const token = explicitInjections[index] || paramType;
if (token === undefined) {
throw new Error(`Cannot resolve parameter at index ${index} for ${target.name}. It might be a circular dependency or primitive type without explicit @Inject.`);
}
return Container.resolve(token);
});
const instance = new target(...dependencies);
Container.instances.set(target, instance);
return instance;
}
}
// 定义服务
@Injectable()
class DatabaseService {
connect() {
console.log("Connecting to database...");
return "DB Connection";
}
}
@Injectable()
class AuthService {
private db: DatabaseService;
constructor(db: DatabaseService) {
this.db = db;
}
login() {
console.log(`AuthService: Authenticating using ${this.db.connect()}`);
return "User logged in";
}
}
@Injectable()
class UserService {
private authService: AuthService;
private dbService: DatabaseService; // 通过自定义装饰器或框架功能注入属性的示例
constructor(@Inject(AuthService) authService: AuthService,
@Inject(DatabaseService) dbService: DatabaseService) {
this.authService = authService;
this.dbService = dbService;
}
getUserProfile() {
this.authService.login();
this.dbService.connect();
console.log("UserService: Fetching user profile...");
return { id: 1, name: "Global User" };
}
}
// 解析主服务
console.log("--- Resolving UserService ---");
const userService = Container.resolve(UserService);
console.log(userService.getUserProfile());
console.log("\n--- Resolving AuthService (should be cached) ---");
const authService = Container.resolve(AuthService);
authService.login();
这个复杂的例子演示了 @Injectable
和 @Inject
装饰器如何与 reflect-metadata
结合,允许一个自定义的 Container
自动解析并提供依赖。TypeScript 自动发出的 design:paramtypes
元数据(当 emitDecoratorMetadata
为 true 时)在这里至关重要。
2. 面向切面编程 (AOP)
AOP 专注于模块化横切关注点(例如,日志、安全、事务),这些关注点贯穿多个类和模块。装饰器非常适合在 TypeScript 中实现 AOP 概念。
示例:使用方法装饰器进行日志记录
再次回到 LogCall
装饰器,它是 AOP 的一个完美例子。它向任何方法添加日志记录行为,而无需修改方法的原始代码。这分离了“做什么”(业务逻辑)和“如何做”(日志记录、性能监控等)。
function LogMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[LOG AOP] Entering method: ${String(propertyKey)} with args:`, args);
try {
const result = originalMethod.apply(this, args);
console.log(`[LOG AOP] Exiting method: ${String(propertyKey)} with result:`, result);
return result;
} catch (error: any) {
console.error(`[LOG AOP] Error in method ${String(propertyKey)}:`, error.message);
throw error;
}
};
return descriptor;
}
class PaymentProcessor {
@LogMethod
processPayment(amount: number, currency: string) {
if (amount <= 0) {
throw new Error("Payment amount must be positive.");
}
console.log(`Processing payment of ${amount} ${currency}...`);
return `Payment of ${amount} ${currency} processed successfully.`;
}
@LogMethod
refundPayment(transactionId: string) {
console.log(`Refunding payment for transaction ID: ${transactionId}...`);
return `Refund initiated for ${transactionId}.`;
}
}
const processor = new PaymentProcessor();
processor.processPayment(100, "USD");
try {
processor.processPayment(-50, "EUR");
} catch (error: any) {
console.error("Caught error:", error.message);
}
这种方法使 PaymentProcessor
类纯粹专注于支付逻辑,而 LogMethod
装饰器处理日志记录这一横切关注点。
3. 验证与转换
装饰器在直接于属性上定义验证规则或在序列化/反序列化期间转换数据方面非常有用。
示例:使用属性装饰器进行数据验证
之前的 @Required
示例已经演示了这一点。这里是另一个带有数值范围验证的例子。
interface FieldValidationRule {
property: string | symbol;
validator: (value: any) => boolean;
message: string;
}
const fieldValidationRules = new Map<Function, FieldValidationRule[]>();
function addValidationRule(target: Object, propertyKey: string | symbol, validator: (value: any) => boolean, message: string) {
const rules = fieldValidationRules.get(target.constructor) || [];
rules.push({ property: propertyKey, validator, message });
fieldValidationRules.set(target.constructor, rules);
}
function IsPositive(target: Object, propertyKey: string | symbol) {
addValidationRule(target, propertyKey, (value: number) => value > 0, `${String(propertyKey)} must be a positive number.`);
}
function MaxLength(maxLength: number) {
return function (target: Object, propertyKey: string | symbol) {
addValidationRule(target, propertyKey, (value: string) => value.length <= maxLength, `${String(propertyKey)} must be at most ${maxLength} characters long.`);
};
}
class Product {
@MaxLength(50)
name: string;
@IsPositive
price: number;
constructor(name: string, price: number) {
this.name = name;
this.price = price;
}
static validate(instance: any): string[] {
const errors: string[] = [];
const rules = fieldValidationRules.get(instance.constructor) || [];
for (const rule of rules) {
if (!rule.validator(instance[rule.property])) {
errors.push(rule.message);
}
}
return errors;
}
}
const product1 = new Product("Laptop", 1200);
console.log("Product 1 errors:", Product.validate(product1)); // []
const product2 = new Product("Very long product name that exceeds fifty characters limit for testing purpose", 50);
console.log("Product 2 errors:", Product.validate(product2)); // ["name must be at most 50 characters long."]
const product3 = new Product("Book", -10);
console.log("Product 3 errors:", Product.validate(product3)); // ["price must be a positive number."]
这种设置允许你在模型属性上声明性地定义验证规则,使你的数据模型在其约束方面具有自描述性。
最佳实践与注意事项
虽然装饰器功能强大,但应谨慎使用。滥用它们可能导致代码更难调试或理解。
何时使用装饰器(以及何时不使用)
- 何时使用:
- 横切关注点: 日志、缓存、授权、事务管理。
- 元数据声明: 为 ORM 定义模式、验证规则、DI 配置。
- 框架集成: 在构建或使用利用元数据的框架时。
- 减少样板代码: 抽象重复的代码模式。
- 避免使用:
- 简单的函数调用: 如果一个普通的函数调用可以清晰地达到同样的效果,优先选择它。
- 业务逻辑: 装饰器应该增强而非定义核心业务逻辑。
- 过度复杂化: 如果使用装饰器使代码可读性降低或更难测试,请重新考虑。
性能影响
装饰器在编译时(或者如果转译,则在 JavaScript 运行时的定义时)执行。转换或元数据收集发生在类/方法定义时,而不是每次调用时。因此,*应用*装饰器的运行时性能影响很小。然而,你装饰器*内部的逻辑*可能会有性能影响,特别是如果它们在每次方法调用时执行昂贵的操作(例如,方法装饰器内的复杂计算)。
可维护性与可读性
装饰器使用得当,可以通过将样板代码移出主逻辑来显著提高可读性。然而,如果它们执行复杂、隐藏的转换,调试可能会变得具有挑战性。确保你的装饰器有良好的文档并且其行为是可预测的。
实验性状态与装饰器的未来
需要重申的是,TypeScript 装饰器基于 TC39 的 Stage 3 提案。这意味着该规范在很大程度上是稳定的,但在成为官方 ECMAScript 标准之前仍可能发生微小变化。像 Angular 这样的框架已经拥抱了它们,押注于它们最终会标准化。这意味着存在一定程度的风险,但鉴于其广泛采用,发生重大破坏性变化的可能性不大。
TC39 提案已经发展。TypeScript 的当前实现基于该提案的旧版本。存在“旧版装饰器”与“标准装饰器”的区别。当官方标准落地时,TypeScript 可能会更新其实现。对于大多数使用框架的开发者来说,这一过渡将由框架本身管理。对于库作者来说,理解旧版和未来标准装饰器之间的细微差别可能变得必要。
emitDecoratorMetadata
编译器选项
当在 tsconfig.json
中设置为 true
时,此选项指示 TypeScript 编译器将某些设计时类型元数据发出到编译后的 JavaScript 中。这些元数据包括构造函数参数的类型 (design:paramtypes
)、方法的返回类型 (design:returntype
) 和属性的类型 (design:type
)。
这些发出的元数据不是标准 JavaScript 运行时的一部分。它通常由 reflect-metadata
polyfill 使用,然后通过 Reflect.getMetadata()
函数使其可访问。这对于像依赖注入这样的高级模式是绝对关键的,因为容器需要知道一个类需要哪些依赖的类型,而无需显式配置。
使用装饰器的高级模式
装饰器可以组合和扩展以构建更复杂的模式。
1. 装饰装饰器(高阶装饰器)
你可以创建修改或组合其他装饰器的装饰器。这不太常见,但展示了装饰器的函数式特性。
// 一个确保方法被记录并且需要管理员角色的装饰器
function AdminAndLoggedMethod() {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// 首先应用 Authorization (内部)
Authorization(["admin"])(target, propertyKey, descriptor);
// 然后应用 LogCall (外部)
LogCall(target, propertyKey, descriptor);
return descriptor; // 返回修改后的描述符
};
}
class AdminPanel {
@AdminAndLoggedMethod()
deleteUserAccount(userId: string) {
console.log(`Deleting user account: ${userId}`);
return `User ${userId} deleted.`;
}
}
const adminPanel = new AdminPanel();
adminPanel.deleteUserAccount("user007");
/* 预期输出 (假设为管理员角色):
[AUTH] Access granted for deleteUserAccount
[LOG] Calling deleteUserAccount with args: [ 'user007' ]
Deleting user account: user007
[LOG] Method deleteUserAccount returned: User user007 deleted.
*/
在这里,AdminAndLoggedMethod
是一个返回装饰器的工厂,在该装饰器内部,它应用了另外两个装饰器。这种模式可以封装复杂的装饰器组合。
2. 使用装饰器实现 Mixin
虽然 TypeScript 提供了其他实现 mixin 的方法,但装饰器可以用来以声明式的方式向类中注入功能。
function ApplyMixins(constructors: Function[]) {
return function (derivedConstructor: Function) {
constructors.forEach(baseConstructor => {
Object.getOwnPropertyNames(baseConstructor.prototype).forEach(name => {
Object.defineProperty(
derivedConstructor.prototype,
name,
Object.getOwnPropertyDescriptor(baseConstructor.prototype, name) || Object.create(null)
);
});
});
};
}
class Disposable {
isDisposed: boolean = false;
dispose() {
this.isDisposed = true;
console.log("Object disposed.");
}
}
class Loggable {
log(message: string) {
console.log(`[Loggable] ${message}`);
}
}
@ApplyMixins([Disposable, Loggable])
class MyResource implements Disposable, Loggable {
// 这些属性/方法由装饰器注入
isDisposed!: boolean;
dispose!: () => void;
log!: (message: string) => void;
constructor(public name: string) {
this.log(`Resource ${this.name} created.`);
}
cleanUp() {
this.dispose();
this.log(`Resource ${this.name} cleaned up.`);
}
}
const resource = new MyResource("NetworkConnection");
console.log(`Is disposed: ${resource.isDisposed}`);
resource.cleanUp();
console.log(`Is disposed: ${resource.isDisposed}`);
这个 @ApplyMixins
装饰器动态地将方法和属性从基构造函数复制到派生类的原型上,有效地“混入”功能。
结论:赋能现代 TypeScript 开发
TypeScript 装饰器是一个强大且富有表现力的功能,它开启了元数据驱动和面向切面编程的新范式。它们允许开发者增强、修改和为类、方法、属性、访问器和参数添加声明式行为,而无需改变其核心逻辑。这种关注点分离导致了更清晰、更易于维护和高度可重用的代码。
从简化依赖注入、实现强大的验证系统,到添加日志和性能监控等横切关注点,装饰器为许多常见的开发挑战提供了优雅的解决方案。虽然它们的实验性状态值得注意,但它们在主流框架中的广泛采用标志着它们的实用价值和未来相关性。
通过掌握 TypeScript 装饰器,你在你的工具库中获得了一个重要的工具,使你能够构建更稳健、可扩展和智能的应用程序。负责任地拥抱它们,理解其工作机制,并在你的 TypeScript 项目中解锁一个新的声明式能力水平。