一、 初识装饰器:它到底是什么?
想象一下,你有一份文档(比如一个类),你想给它加个漂亮的封面,或者在某些重点句子下面划上线,又或者想给每一页都盖上“机密”的印章。你不会去直接修改原文内容,而是通过“附加”一些东西来实现。装饰器干的就是这个活儿。
在TypeScript里,装饰器本质上就是一个函数,它会在你的代码被定义(不是执行)的时候被调用。它可以接收被装饰目标的信息,然后让你有机会去“装饰”它——比如修改它的行为、添加一些元数据、或者包装它。
技术栈声明:本文所有示例均基于 TypeScript 与 Node.js 环境。
为了让例子跑起来,我们需要先配置一下。在你的tsconfig.json文件中,确保开启了实验性的装饰器支持:
{
"compilerOptions": {
"target": "ES2015",
"experimentalDecorators": true,
"emitDecoratorMetadata": true // 这个可选,用于反射元数据
}
}
二、 装饰器家族:都能装饰谁?
装饰器可以应用在好几个地方,每种都有它独特的签名和用途。我们一个一个来看。
1. 类装饰器
这是最直接的一种,它直接装饰整个类。它的参数是类的构造函数。
// 示例:一个简单的日志装饰器,记录类的创建
function LogClass(constructor: Function) {
console.log(`[日志] 类被创建: ${constructor.name}`);
// 这里可以给类原型添加属性或方法
constructor.prototype.creationTime = new Date();
}
@LogClass
class UserService {
getUser() {
console.log('获取用户信息...');
}
}
// 当代码被加载时,控制台会立即输出: [日志] 类被创建: UserService
const service = new UserService();
console.log((service as any).creationTime); // 输出创建时间
2. 方法装饰器
这是最常用的一种,用于装饰类的方法。它接收三个参数:类的原型(或构造函数)、方法名、方法的属性描述符。属性描述符是个关键对象,它包含了方法的value(函数本身)、writable(是否可写)等信息,允许我们直接修改或替换方法。
// 示例:一个测量方法执行时间的装饰器
function MeasureTime(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
// 保存原始方法
const originalMethod = descriptor.value;
// 用新的函数替换原方法
descriptor.value = function (...args: any[]) {
console.time(`[耗时] ${propertyKey}`);
// 执行原始方法,并绑定正确的`this`上下文
const result = originalMethod.apply(this, args);
console.timeEnd(`[耗时] ${propertyKey}`);
return result;
};
}
class DataProcessor {
@MeasureTime
processData(data: number[]): number {
// 模拟一个耗时操作
let sum = 0;
for (let i = 0; i < 1000000; i++) {
sum += data.reduce((a, b) => a + b, 0);
}
return sum;
}
}
const processor = new DataProcessor();
processor.processData([1, 2, 3, 4, 5]);
// 控制台输出类似: [耗时] processData: 125.456ms
3. 访问器装饰器
装饰getter和setter,用法和方法装饰器几乎一样。
// 示例:确保年龄是正数
function PositiveNumber(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalSet = descriptor.set;
if (originalSet) {
descriptor.set = function (value: number) {
if (value <= 0) {
throw new Error(`${propertyKey} 必须是正数`);
}
// 调用原始的setter
originalSet.call(this, value);
};
}
}
class Person {
private _age: number = 0;
@PositiveNumber
set age(value: number) {
this._age = value;
}
get age(): number {
return this._age;
}
}
const person = new Person();
person.age = 25; // 正常
console.log(person.age); // 25
// person.age = -5; // 抛出错误: age 必须是正数
4. 属性装饰器
装饰类的属性。它只能观察到属性被定义了,但不能直接修改属性的值或描述符(在TypeScript的标准实现中)。它常用于添加元数据。
import "reflect-metadata"; // 需要安装这个库来使用反射元数据
// 示例:标记一个属性为需要格式化的字段
function FormatField(format: string) {
return function (target: any, propertyKey: string) {
// 使用 reflect-metadata 库将元数据存储到目标上
Reflect.defineMetadata("format", format, target, propertyKey);
};
}
class Product {
@FormatField("YYYY-MM-DD")
createdAt: Date = new Date();
@FormatField("currency:CNY")
price: number = 99.99;
}
// 后续可以通过反射读取这些元数据
const product = new Product();
const formatForCreatedAt = Reflect.getMetadata("format", product, "createdAt");
const formatForPrice = Reflect.getMetadata("format", product, "price");
console.log(formatForCreatedAt); // 输出: YYYY-MM-DD
console.log(formatForPrice); // 输出: currency:CNY
5. 参数装饰器
装饰方法或构造函数的参数。它通常也用于记录元数据,比如这个参数是否是必须的、它的类型是什么。
import "reflect-metadata";
// 示例:标记某个参数为需要验证的“用户ID”
function ValidateUserId(target: any, propertyKey: string, parameterIndex: number) {
// 为这个方法创建一个元数据数组(如果还没有的话),记录需要验证的参数索引
const existingRequiredParameters: number[] = Reflect.getOwnMetadata("validate:userIds", target, propertyKey) || [];
existingRequiredParameters.push(parameterIndex);
Reflect.defineMetadata("validate:userIds", existingRequiredParameters, target, propertyKey);
}
class UserController {
getUserInfo(@ValidateUserId userId: string, someOtherParam: number) {
console.log(`获取用户 ${userId} 的信息`);
}
}
// 后续可以在一个拦截器或中间件中读取这些元数据,并进行统一验证
const controller = new UserController();
const indicesToValidate = Reflect.getMetadata("validate:userIds", controller, "getUserInfo");
console.log(indicesToValidate); // 输出: [0] ,表示第一个参数需要验证
三、 装饰器工厂与执行顺序
有时候,我们想给装饰器传参数,比如@FormatField("YYYY-MM-DD")。这就需要用到装饰器工厂。它就是一个返回装饰器函数的函数。
// 装饰器工厂:根据传入的日志级别决定是否记录
function Log(level: 'info' | 'warn' | 'error') {
// 这是一个装饰器工厂,它返回真正的装饰器函数
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
if (level === 'info') {
console.log(`[INFO] 调用方法: ${propertyKey},参数:`, args);
}
// 其他级别处理...
return originalMethod.apply(this, args);
};
};
}
class ApiService {
@Log('info')
fetchData(url: string) {
// 模拟网络请求
console.log(`请求: ${url}`);
}
}
执行顺序很重要。当多个装饰器应用在同一个目标上时:
- 从上到下执行装饰器工厂(求值,得到装饰器函数)。
- 从下到上应用装饰器(执行装饰器函数本身)。
- 对于类成员(方法、属性),先应用参数装饰器,然后是方法/访问器/属性装饰器,最后是类装饰器。
四、 高级玩法:元编程与依赖注入
装饰器真正强大的地方在于元编程——编写能操作其他代码(或其自身)的代码。结合reflect-metadata库,我们可以实现一些非常高级的模式。
场景:一个极简的依赖注入容器
依赖注入是后端框架(如Angular、NestJS)的核心。我们来看看它的核心思想如何用装饰器实现。
import "reflect-metadata";
// 1. 定义一个服务类,并用 @Injectable 装饰器标记
function Injectable() {
return function (constructor: Function) {
// 标记这个类是可注入的,这里可以做更多事情,比如注册到容器
console.log(`[容器] 注册服务: ${constructor.name}`);
};
}
// 2. 使用 @Inject 装饰器来声明需要注入的依赖
function Inject(token: string) {
return function (target: any, propertyKey: string, parameterIndex: number) {
// 将需要注入的依赖信息(token)存储为元数据
const injections = Reflect.getMetadata("injections", target) || [];
injections[parameterIndex] = token;
Reflect.defineMetadata("injections", injections, target);
};
}
// 3. 服务类
@Injectable()
class DatabaseService {
connect() {
console.log('连接到数据库...');
}
}
@Injectable()
class LoggerService {
log(message: string) {
console.log(`[日志] ${message}`);
}
}
// 4. 需要依赖的类(如控制器)
class UserController {
private dbService: DatabaseService;
private logger: LoggerService;
// 在构造函数中声明依赖
constructor(
@Inject('DatabaseService') dbService: DatabaseService,
@Inject('LoggerService') logger: LoggerService
) {
this.dbService = dbService;
this.logger = logger;
}
createUser() {
this.logger.log('开始创建用户');
this.dbService.connect();
// ... 创建用户逻辑
this.logger.log('用户创建完成');
}
}
// 5. 一个极简的容器(用于演示原理)
class Container {
private services = new Map<string, any>();
register(token: string, service: any) {
this.services.set(token, service);
}
resolve(target: any): any {
// 获取目标构造函数
const constructor = target;
// 从元数据中读取需要注入的参数token
const injections = Reflect.getMetadata("injections", constructor) || [];
// 根据token从容器中获取实例
const args = injections.map((token: string) => this.services.get(token));
// 使用获取到的实例作为参数,创建目标对象
return new constructor(...args);
}
}
// 6. 使用容器
const container = new Container();
container.register('DatabaseService', new DatabaseService());
container.register('LoggerService', new LoggerService());
// 容器自动解析 UserController 的依赖并创建实例
const userController = container.resolve(UserController);
userController.createUser();
// 输出:
// [容器] 注册服务: DatabaseService
// [容器] 注册服务: LoggerService
// [日志] 开始创建用户
// 连接到数据库...
// [日志] 用户创建完成
这个例子虽然简单,但清晰地展示了装饰器如何与元数据结合,实现自动化的依赖管理。在实际框架中,容器会更复杂,支持生命周期管理、配置读取等。
五、 应用场景、优缺点与注意事项
应用场景:
- 日志与性能监控:像我们前面写的
@MeasureTime,可以无侵入地监控任何方法的性能。 - 验证与格式化:自动验证参数(
@IsEmail())、格式化输出字段(@FormatField())。 - 访问控制与权限:
@RequireLogin、@HasRole('admin'),在Web框架中非常常见。 - 依赖注入:现代Node.js框架(NestJS)的基石,让代码更松耦合、更易测试。
- API定义:像
@Get('/api/users')、@Post()这样的装饰器,用于快速定义路由和接口。 - 数据库ORM映射:
@Entity()、@Column()装饰器将类与数据库表、属性与表字段关联起来(TypeORM)。
技术优点:
- 声明式编程:代码意图更清晰,比如看到
@Authorized()就知道需要权限。 - 关注点分离:将横切关注点(日志、验证、事务)从业务逻辑中剥离,使业务类更纯粹。
- 代码复用:通用功能被封装成装饰器,可以在任何地方轻松复用。
- 元编程能力:为构建框架和高级库提供了强大的工具。
技术缺点与注意事项:
- 实验性特性:在TypeScript中,装饰器长期处于实验阶段,虽然稳定,但标准尚未完全定案(ECMAScript提案阶段3)。
- 理解成本:对新手来说,这种“隐式”的行为修改可能让调试和理解代码流程变得困难。
- 性能影响:装饰器在运行时可能会引入额外的函数调用和元数据操作,虽然通常很微小,但在极致性能场景需考虑。
- 过度使用:滥用装饰器会让代码变得“魔法”十足,难以追踪。只在确实能提升代码结构和可读性时使用。
- 反射依赖:高级用法(如依赖注入)严重依赖
reflect-metadata库和相应的元数据提案。
六、 总结
TypeScript装饰器是一把锋利的瑞士军刀。从简单的日志记录到复杂的依赖注入框架,它通过一种优雅的语法,让我们能够进行声明式的元编程。理解它的原理——本质是函数,在定义时被调用,可以修改目标——是掌握它的关键。
学习装饰器的最佳路径是:先从方法装饰器开始,尝试写一些实用的工具装饰器(如日志、防抖)。然后理解装饰器工厂和执行顺序。当你对元编程感兴趣时,再深入研究reflect-metadata和参数/属性装饰器,探索依赖注入等高级模式。
记住,能力越大,责任越大。合理使用装饰器,它能让你代码的架构水平提升一个档次;滥用它,则可能制造出一团难以维护的“魔法”。希望这篇剖析能帮助你不仅学会如何使用装饰器,更能理解何时使用它。
评论