TypeScript装饰器完全指南:类、方法、属性与元数据实战
如果一个后端项目里已经出现Controller()、Injectable()之类的写法或者你曾在一段业务代码里反复粘贴日志、鉴权和重试逻辑那么这篇TypeScript 装饰器完全指南就是给你准备的。装饰器表面上像“给类打个标签”实际上它是一套声明位置的拦截机制你不需要在每个方法里改代码只需要在类、方法、属性、参数旁边放一个函数就能把那些“横切关注点”统一接管。这篇东西不会只贴语法示例我会把装饰器在不同版本 TypeScript 下的行为差异、五种装饰器的参数和返回值、执行顺序、元数据反射以及它怎么和异步、this、依赖注入这些实战问题交互全部梳理一遍。如果你对 TypeScript 已经有基础只是觉得装饰器像黑魔法你完全可以从这里把它拆开变成能落地的工具。1. 装饰器到底在解决什么问题先把心智模型建立起来1.1 需求场景为什么需要这种“非业务逻辑”你去看一个真实的业务方法通常第一眼看到的不是业务逻辑而是一堆和业务无关的前置后置代码。class OrderService { createOrder(userId: string, items: string[]) { console.log([OrderService.createOrder] 开始调用userId${userId}); const start Date.now(); if (!userId) throw new Error(userId 不能为空); if (!Array.isArray(items) || items.length 0) throw new Error(items 不能为空); // 真正的业务逻辑 const order { id: generateId(), userId, items, createAt: new Date() }; console.log([OrderService.createOrder] 调用结束耗时 ${Date.now() - start}ms); return order; } }这段代码的问题很明显日志、参数校验和业务逻辑揉在一起。一个方法这样写还能忍十个、上百个方法都这样写后面任何一次排查都要在噪音里翻逻辑。你想过把它们拆出去但调用方又必须经过这些检查总不能每个方法都手动调一个 helper。装饰器的价值就在这里它允许你把这些横切关注点放回声明位置让业务方法继续只写业务。1.2 装饰器不是魔法它只是一层“包装器”很多朋友第一次接触装饰器觉得它是框架层面的魔法。实际上装饰器本质就是一个普通函数只不过 TypeScript 编译器会在类定义阶段帮你调用它并把目标对象的元信息作为参数传进去。用生活里的事情类比你公司门口的安保系统就是装饰器。员工不需要每天自己记得“刷卡、签到、核对身份”只要进了大门安保系统自动完成这些动作。新的员工加入只需要在门禁系统里登记一次业务代码里的方法也只需要在声明位置装饰一次。但这层“自动”是有代价的它意味着代码在类初始化阶段就已经被修改或包装过一次而不是每次调用都重新判断。这一点后面讲执行顺序时会反复提到。1.3 能做的事和不能做的事装饰器适合做这些事给类打标记例如注册到容器、设置路由表。给方法加日志、埋点、鉴权、重试、缓存。给属性记录元数据例如“这是必填项”“这是敏感字段”。给构造函数的参数做注入标记。装饰器做不到这些事不能用在普通函数声明或箭头函数上它只针对 class 内的声明位置。不能直接读取实例在运行时才有的状态因为装饰器执行时类还没有实例化。不能替代所有 AOP面向切面编程它更接近语法级别的拦截器。先想清楚“装饰器是类定义阶段的钩子”再往下看语法就会顺很多。2. 环境配置和最小可运行示例先把实验性开关和语法跑通2.1 开启 tsconfig 里的两个关键开关TypeScript 装饰器这一套在 ECMAScript 标准里仍未完全定稿所以在编译期被标记为“experimental”。不同版本行为有差异我下面讲的内容以传统实验性装饰器为主也就是experimentalDecorators: true的场景。这是目前很多框架、工具链实际使用的模式。你需要准备一个最基础的 tsconfig{ compilerOptions: { target: ES2017, module: CommonJS, moduleResolution: node, experimentalDecorators: true, emitDecoratorMetadata: true, strict: true, esModuleInterop: true } }这里的experimentalDecorators是总开关emitDecoratorMetadata是为了生成设计期元数据。如果只写装饰器、不依赖反射元数据第二个开关可以先不开。命令行快速验证npm install typescript ts-node types/node -D npx tsc --experimentalDecorators --target ES2017 ./src/decorator-demo.ts编译时如果报错提示装饰器是实验特性检查一下是不是 tsconfig 没生效或者用了tsc时的参数覆盖。2.2 最小可运行示例类装饰器长什么样先写一个最简单的类装饰器function sealed(constructor: Function) { Object.freeze(constructor); Object.freeze(constructor.prototype); } sealed class DemoService { name demo; }sealed在类定义时被调用参数就是DemoService这个构造函数。装饰器改动的是构造函数本身不是某个实例。Object.freeze会让这个类不能再被修改也不容易被new出来的实例原型篡改。如果你的装饰器需要传参就要用“装饰器工厂”function withPrefix(prefix: string): ClassDecorator { return (constructor: Function) { (constructor as any).prefix prefix; }; } withPrefix(order) class OrderEntity {}这里的withPrefix返回的函数才是真正的装饰器。外部调用withPrefix(order)时prefix已经被闭包捕获。很多初学者在这里卡住装饰器工厂是“造装饰器的函数”真正的装饰器是返回值。2.3 一个常见困惑装饰器在哪儿执行执行多少次装饰器代码只会在类声明阶段执行一次。之后无论new多少次你给类额外定义的属性、方法描述符修改都不会再触发装饰器。比如方法装饰器里把原方法替换成带日志的新方法这个替换动作在类定义时发生后面每次调用方法时拿到的已经是替换后的版本。所以如果你在装饰器里打印“执行日志”它只会在类加载时打一次如果你想在每次调用方法时打日志必须在返回的新函数里写逻辑。这一点相当关键很多人调试时发现“装饰器里的日志怎么只打一次”就是没区分“装饰器执行”和“装饰器包装后的函数被调用”。3. 五种装饰器签名逐个拆解参数怎么来返回值去哪3.1 先看总表五种装饰器的签名、参数、返回值和常见用途我整理成一张表装饰器类型目标参数返回值常见用途类装饰器构造函数constructor新构造函数或void注册、继承扩展、冻结方法装饰器方法target、propertyKey、descriptor新descriptor或void日志、鉴权、重试、替换方法访问器装饰器getter/settertarget、propertyKey、descriptor新descriptor或void劫持属性的读写行为属性装饰器属性target、propertyKey旧规范下返回值被忽略记录元数据、打标记参数装饰器参数target、propertyKey、index返回值被忽略记录参数位置为校验提供依据target在静态成员上是类构造函数本身在实例成员上是类的原型对象。区分这两个场景很重要因为你在装饰器里存元数据时存储位置不一样。3.2 类装饰器改写类本身的入口类装饰器接收构造函数可以选择返回一个新的构造函数来替换原类。function WithTimestampT extends new (...args: any[]) any(ctor: T) { return class extends ctor { createdAt new Date(); }; } WithTimestamp class Ticket { title: string; constructor(title: string) { this.title title; } } const t new Ticket(网络故障); console.log(t.createdAt); // 有值类被替换成了匿名子类这里常见的一个坑是如果返回新构造函数就必须保证新类和原类在行为上兼容。尤其当原类被当成依赖注入的token时改掉类本身的引用会影响容器查找。如果不需要替换只是给类加个静态属性或原型方法那就不要写返回值直接在装饰器内部修改即可。3.3 方法装饰器最常用也最容易写错的场景方法装饰器参数是(target, propertyKey, descriptor)。descriptor就是Object.getOwnPropertyDescriptor(target, propertyKey)的返回值里面有value、writable、enumerable、configurable。最简单的日志包装function logMethod(target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) { const original descriptor.value; descriptor.value function (...args: any[]) { console.log(调用 ${String(propertyKey)}:, args); const result original.apply(this, args); console.log(结束 ${String(propertyKey)}); return result; }; return descriptor; } class Greeter { logMethod sayHello(name: string) { return Hello, ${name}; } }方法装饰器返回的descriptor会被编译器用来重新定义方法。但你一定要注意改写descriptor.value时新函数里必须用普通 function不要用箭头函数否则this会丢失。因为箭头函数捕获的是定义时的this而方法被调用时我们希望this指向当前实例。如果你想把方法变成async异步包装原理一样只是返回值要用await后再返回。3.4 属性装饰器只能观察不能直接改值属性装饰器的签名只有两个参数function markProperty(target: any, propertyKey: string | symbol) { Reflect.defineMetadata(design:exclude, true, target, propertyKey); } class UserModel { markProperty password?: string; }在传统实验性装饰器语法下属性装饰器的返回值会被忽略。你无法在属性装饰器里“读取属性的初始值”因为类还没有实例化更不可能在装饰器里直接return newValue。想做属性级别的响应式更新或访问拦截应该优先考虑访问器装饰器或者结合方法装饰器配合元数据使用。属性装饰器的正确姿势是记录元数据比如“这个字段是敏感信息序列化时不要输出”。3.5 访问器装饰器拦截 getter/setter 的正确入口访问器装饰器和方法装饰器类似但它操作的是descriptor里的get和set。function readonly(target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) { descriptor.writable false; return descriptor; } class BankAccount { private _balance 0; readonly get balance() { return this._balance; } }这里尤其注意同一个属性的 getter 和 setter装饰器不能同时放在两个上面。这是语法限制不是为了折磨你。应该只在其中一个访问器上放装饰器比如放在 getter 上即可。它的设计意图是你觉得属性不可写那就把descriptor.writable或set的配置改掉。3.6 参数装饰器本身做不了事但信息量很大参数装饰器的参数里没有descriptor函数签名是function requiredParam(target: any, propertyKey: string | symbol, index: number) { // 这里只拿到参数位置 }它拿不到参数的值也拿不到参数的默认值唯一能拿到的是“这一步是第几个参数”。这看起来没什么用但配合元数据就非常有用了后面参数校验案例会讲。参数装饰器的典型场景记录哪些参数是必填的、哪些参数需要从容器注入、哪些参数需要特殊校验类型。4. 求值顺序和组合顺序别被输出顺序弄晕4.1 表达式求值和装饰器调用是两件事很多人在多个装饰器叠加时看到控制台输出顺序与自己预期不一致就开始怀疑人生。这里先分清两件事装饰器表达式的求值从上到下。装饰器函数的调用应用从下到上。看代码function deco(label: string) { console.log(求值 ${label}); return () { console.log(应用 ${label}); }; } class Demo { deco(top) deco(bottom) method() {} }输出顺序是求值 top 求值 bottom 应用 bottom 应用 top先求值deco(top)返回装饰器函数再求值deco(bottom)也返回装饰器函数。但应用时编译器会先调用下面的bottom再调用上面的top。这个顺序和函数组合top(bottom(original))是一致的。靠近方法的装饰器先执行远离方法的装饰器后执行但后执行的装饰器包裹在外面。4.2 方法的调用顺序哪层装饰器先碰到调用假设你有两个方法装饰器function outerLog(target: any, key: string | symbol, descriptor: PropertyDescriptor) { const original descriptor.value; descriptor.value function (...args: any[]) { console.log(外层日志 before); const result original.apply(this, args); console.log(外层日志 after); return result; }; } function innerCheck(target: any, key: string | symbol, descriptor: PropertyDescriptor) { const original descriptor.value; descriptor.value function (...args: any[]) { console.log(内层校验 before); const result original.apply(this, args); console.log(内层校验 after); return result; }; } class PipeDemo { outerLog innerCheck run() {} }实际调用run()时先进入outerLog包装的函数再进入innerCheck包装的函数最后才是原始run。所以输出是外层日志 before 内层校验 before 内层校验 after 外层日志 after理解这个顺序很重要。如果你想在日志统计里包含校验失败的情况那么日志装饰器应该放在外层也就是离方法更远的那一行。4.3 不同成员之间的应用顺序在同一个类里不只一个成员有装饰器时顺序大概是这样先处理实例成员的装饰器。再处理静态成员的装饰器。然后处理构造函数上的参数装饰器。最后处理类装饰器。实际开发中你很少需要死记这种顺序但知道了以后遇到“类装饰器里读不到方法装饰器写入的数据”这类问题时就能推断出是不是因为执行顺序尚未完成。规则很简单依赖元数据的装饰器要确保数据源装饰器已经在更早的阶段执行。4.4 组合装饰器时的经验把两个装饰器组合使用时我建议遵守几个原则装饰器应该是“可重入”的也就是不依赖外部可变状态。如果装饰器之间有严格顺序要求最好合并成一个工厂用数组顺序控制。不要写“隐式依赖另一个装饰器执行过”的代码先在类装饰器里统一收集再统一处理。如果你发现自己的装饰器组合顺序越来越复杂说明该考虑用一个“装饰器管道”工具函数来显式描述顺序了。5. 设计期元数据emitDecoratorMetadata和自定义元数据5.1 类型信息在运行时去哪了TypeScript 编译成 JavaScript 后类型注解全部被擦除。你没法在运行时知道login(name: string)的name是string。但有些场景确实需要运行时类型信息比如做参数校验、做依赖注入、做序列化。于是就有了emitDecoratorMetadata。开启后编译器会为被装饰的类成员额外生成一组元数据调用。import reflect-metadata; class UserService { login(name: string, password: string): boolean { return name admin password 123456; } } const paramTypes Reflect.getMetadata(design:paramtypes, UserService.prototype, login); console.log(paramTypes); // [ String, String ] const returnType Reflect.getMetadata(design:returntype, UserService.prototype, login); console.log(returnType); // [Function: Boolean]这里的reflect-metadata只是补上运行时反射能力。TS 编译器生成的元数据键名是固定的design:type成员类型design:paramtypes参数类型数组design:returntype返回值类型需要注意的是如果参数类型是一个接口编译后拿到的类型可能变成Object或undefined因为接口在编译期就被擦除了。这时候想拿到准确的业务类型最好显式用参数装饰器记录。5.2 自定义元数据装饰器真正的高级玩法除了内置元数据你还可以自己存元数据。比如给一个方法记录它的限流阈值import reflect-metadata; const RATE_LIMIT_KEY Symbol(rateLimit); function RateLimit(limit: number): MethodDecorator { return (target, propertyKey, descriptor) { Reflect.defineMetadata(RATE_LIMIT_KEY, limit, target, propertyKey); }; } class PaymentService { RateLimit(3) transfer() {} } const limit Reflect.getMetadata(RATE_LIMIT_KEY, PaymentService.prototype, transfer); console.log(limit); // 3元数据存到target上propertyKey作为元数据键的一部分。Symbol类型的键可以避免字符串命名冲突比简单用字符串更安全。还有一个细节Reflect.getMetadata会沿着原型链向上查找Reflect.getOwnMetadata只查当前对象。如果你的类继承自一个被装饰过的基类想继承元数据就用getMetadata不想继承就用getOwnMetadata。5.3 元数据配合装饰器的常见坑我见过最多的坑是忘记import reflect-metadata。编译器虽然生成了Reflect.metadata调用但如果运行时没有Reflect.metadata的实现就会直接抛错。另一个坑是emitDecoratorMetadata和标准装饰器不兼容。它依赖实验性装饰器那一套执行顺序。如果你在experimentalDecorators: false的新式装饰器下硬开元数据会遇到行为不一致。所以我的建议是在一个项目里只固定用一种模式。你目前的项目如果已经依赖依赖注入框架就继续用传统模式不要混搭。6. 三个可以直接抄的装饰器日志、参数校验、依赖注入6.1 日志装饰器先解决this和异步一个能用于日常的日志装饰器function TimeLog(label?: string) { return (target: any, methodName: string | symbol, descriptor: PropertyDescriptor) { const original descriptor.value; if (typeof original ! function) { return descriptor; } descriptor.value async function (...args: any[]) { const start performance.now(); try { const result await original.apply(this, args); console.log([${label ?? String(methodName)}] 成功, 耗时 ${performance.now() - start}ms); return result; } catch (err) { console.error([${label ?? String(methodName)}] 失败, err); throw err; } }; return descriptor; }; } class PayService { TimeLog(支付) async pay(orderId: string) { // 模拟耗时操作 return { id: orderId, status: paid }; } }这里之所以用async function包装是因为await original.apply(this, args)对同步和异步方法都兼容。同步方法返回普通值await会直接当作已解决的值处理异步方法返回 Promiseawait也能拿到最终结果。如果你不想改变方法的异步性就要谨慎处理返回值。直接在包装函数里返回 Promise 而不await调用方得到的也会是 Promise但错误处理链路容易出问题。我的习惯是让包装函数和原方法保持相同的同步/异步风格。6.2 参数校验装饰器参数装饰器 方法装饰器的组合拳参数装饰器本身不能拦截调用但它可以记录“哪一个参数是必填的”。然后交给方法装饰器在校验阶段读取这条元数据。import reflect-metadata; const REQUIRED_PARAM_KEY Symbol(requiredParam); function Required(target: any, propertyKey: string | symbol, parameterIndex: number) { const existing: number[] Reflect.getOwnMetadata(REQUIRED_PARAM_KEY, target, propertyKey) ?? []; existing.push(parameterIndex); Reflect.defineMetadata(REQUIRED_PARAM_KEY, existing, target, propertyKey); } function Validate(target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) { const original descriptor.value; descriptor.value function (...args: any[]) { const requiredIndexes Reflect.getOwnMetadata(REQUIRED_PARAM_KEY, target, propertyKey) ?? []; for (const idx of requiredIndexes) { const value args[idx]; if (value null || value undefined) { throw new TypeError(参数索引 ${idx} 不能为空); } } return original.apply(this, args); }; return descriptor; } class OrderService { Validate create(Required userId: number, Required amount: number) { return { userId, amount }; } }这里的执行流程是这样的参数装饰器先跑把userId的索引0、amount的索引1存到对应方法的元数据里。然后方法装饰器Validate再执行重写方法在校验阶段读取这些索引。这个模式的优点是你不需要写复杂的校验逻辑在每个方法里只需要声明Required。缺点也很明显每增加一种校验就要定义一个新的装饰器键。所以项目里如果校验规则很多建议直接引入成熟的校验库但理解原理并不冲突。6.3 依赖注入小例子装饰器如何驱动容器依赖注入是装饰器本身很有说服力的用例。我用最简化的代码演示原理import reflect-metadata; const container new Mapstring, Function(); const INJECT_KEY Symbol(inject); function Injectable(token: string): ClassDecorator { return (target) { container.set(token, target); }; } function Inject(token: string): ParameterDecorator { return (target, propertyKey, parameterIndex) { const params: Recordnumber, string Reflect.getOwnMetadata(INJECT_KEY, target) ?? {}; params[parameterIndex] token; Reflect.defineMetadata(INJECT_KEY, params, target); }; } function createT(token: string): T { const Target container.get(token) as new (...args: any[]) T; const injectMap: Recordnumber, string Reflect.getOwnMetadata(INJECT_KEY, Target) ?? {}; const dependencies Object.keys(injectMap) .map(Number) .sort((a, b) a - b) .map((idx) create(injectMap[idx])); return new Target(...dependencies); } Injectable(logger) class Logger { log(message: string) { console.log([LOG] ${message}); } } Injectable(orderRepository) class OrderRepository { constructor(Inject(logger) private logger: Logger) {} } const repo createOrderRepository(orderRepository);这里的关键是利用参数装饰器把“构造函数第 0 个参数需要注入什么 token”记录在构造函数本身的元数据里。create函数根据元数据递归地从容器取依赖再new出目标类。这个例子很简化真正生产环境里的容器还要处理单例、作用域、循环依赖等但它证明了装饰器 元数据足以搭建一个可用的最小 DI 框架。如果你现在不理解完整实现也没关系先记住这条链路参数装饰器记录 token类装饰器注册构造函数运行时通过元数据解析依赖。7. 踩坑复盘与设计建议这些错误我全都犯过7.1 装饰器里用箭头函数包装方法导致this丢失我最早期写的日志装饰器是这样的descriptor.value (...args: any[]) { return original.apply(this, args); };问题在于箭头函数的this是定义时捕获的不是调用时动态绑定的。当你调用instance.method()时箭头函数里的this可能还是undefined在严格模式下直接报错。正确做法是使用普通function让this在调用时指向实例。这个坑不只在装饰器里任何时候你用闭包包装别人的函数碰到this都要格外小心。如果原方法里使用了this你包装时就必须把调用时的this原样传进去。7.2 属性装饰器里试图读取实例属性初始值我在做序列化工具时想在一个属性装饰器里直接拿到属性的默认值类似function Default(value: any) { return (target: any, propertyKey: string) { target[propertyKey] value; // 这样写在实例上不生效 }; }这在传统装饰器模式下无效因为属性装饰器执行时类还没有实例化。你写进原型上的东西会被所有实例共享不是一个靠谱的默认值方案。更好的做法是把默认值存在元数据里然后用一个方法装饰器或者工厂替你实现默认值注入。换句话说属性装饰器负责“声明”别想着“执行”。7.3 装饰器执行顺序不是直观的“从上到下”我见过最典型的误解以为写了Validate Transform就会先校验再转换。实际上按 4.1 说的顺序先执行的是下面的装饰器。如果你的转换逻辑把原始参数变成了对象而校验逻辑校验的还是原始参数结果就会和你预期相反。遇到“装饰器结果不对”的问题第一件事不是打断点而是先看顺序。你甚至可以临时加日志打印出来确认哪个装饰器先执行。7.4 过度使用装饰器导致代码不可读装饰器的语法很爽但它也有不可忽视的副作用。它把逻辑藏在了声明之外新人看代码时如果不知道这些装饰器做了什么会非常痛苦。我的经验是装饰器命名要动词化明确表达意图比如RequireLogin、RateLimit。单个方法上的装饰器数量控制在三个以内超过三个就考虑合并成一个命令式函数。装饰器内部不要写复杂业务逻辑只负责拦截、转发真正的能力交给普通函数。7.5 编译产物和库发布时的开关不一致这里有个特别隐蔽的问题你开发时 tsconfig 开了experimentalDecorators但如果你发的是编译后的 JavaScript 库给人用而使用者的项目没有开启同样开关并不会影响你产出的 JS 文件——因为装饰器逻辑已经编译成了普通函数调用。真正需要注意的相反场景你发布源码给上游项目期望对方继续用 TypeScript 编译那你的源码里出现装饰器就要求对方的 tsconfig 也开启experimentalDecorators。这会让你的库对使用方多一个编译配置要求。因此库项目中装饰器要么不暴露在公共 API 里要么文档里明确说明开关。7.6 不要在设计时元数据上过度依赖接口类型前面提到接口在编译后被擦除所以design:paramtypes里有接口参数时拿到的可能是Object。如果你做参数校验不能只看编译期类型还是要在运行时用真正的构造器判断或者显式记录一个字符串标记。用元数据做校验时我的原则是编译器能给的类型信息只作为兜底业务规则必须显式声明。宁可多写一个IsInt()标记也不要靠“设计时类型是 Number”去猜。最后再说一点个人体会装饰器这套语法很容易被高估也容易被低估。高估它的人觉得有了它就不需要写工具函数了什么都想一下低估它的人觉得它只是框架内部的黑魔法平时用不上。其实它就是一类普通的函数包装机制只不过借了语法糖的光。你要做的不是背下所有装饰器签名而是理解“在类声明时编译器会按特定顺序调用函数并传给你目标信息”。一旦这个心智模型建立起来后面不管看到什么框架的注解你都能很快判断它大概做了什么。这次先分享到这里。如果你在项目里正在被重复到爆炸的日志、校验、鉴权代码困扰不妨试着把其中一个横切逻辑抽成装饰器。抽的过程里你会遇到你已经看过的那些问题而你处理问题的过程才是这套语法真正学会的时刻。