ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TypeScript属性与参数装饰器:执行时机、元数据与依赖注入实战

TypeScript属性与参数装饰器:执行时机、元数据与依赖注入实战 属性装饰器和参数装饰器在 TypeScript 装饰器体系里一直属于“文档看过就忘”的角色。类装饰器有Module方法装饰器有Get属性装饰器呢好像就只能在表单验证里加个IsNotEmpty()的样子。参数装饰器更惨很多人连它在运行时到底做了什么都说不上来。但这俩恰恰是 NestJS 依赖注入、TypeORM 字段映射、Angular 依赖注入这套体系的底层地基。这篇文章不按官方文档复述我直接把这两个装饰器的执行时机、参数签名、返回值语义以及它们和反射元数据配合后的真实用法拆开讲全程附带能做 Demo 的代码和踩过的坑。我最早做后端接口时也被这俩装饰器搞晕过属性装饰器的返回值为什么无效参数装饰器只能拿到一个parameterIndex它能干点什么正经事后来自己在业务里封装了一层简易版依赖注入和字段校验才把这两个“配角”的运转逻辑彻底摸透。下面的内容适合对 TypeScript 装饰器有基本了解、但想要真正在项目里用起来的同学也适合想搞懂框架底层原理的人。1. 属性装饰器和参数装饰器到底在什么时机执行1.1 属性装饰器只在类定义时触发一次先把最容易被忽略的结论放在前面属性装饰器在类定义阶段执行一次之后每次实例化类都不会再触发它。function LogProperty(target: Object, propertyKey: string | symbol) { console.log(属性装饰器触发${String(propertyKey)}); } class User { LogProperty name default; } // 输出属性装饰器触发namename字段在类声明时被装饰器捕获你创建多少个new User()控制台也只会打印一次。这个“只定义时执行一次”的行为决定了它天然适合做元数据收集和静态描述符配置不适合做“每次实例化时都要执行的逻辑”。这点和类装饰器、方法装饰器不一样。类装饰器返回的新构造函数会影响后续所有实例方法装饰器返回的PropertyDescriptor可以重写方法行为但属性装饰器的返回值——官方文档写得很清楚——会被直接忽略。它不是缺陷而是设计约束属性装饰器执行的时候实例还不存在根本没有办法对某个具体的实例字段做拦截。1.2 参数装饰器构造参数在实例化时方法参数在定义时参数装饰器的触发时机要看它在哪类参数上修饰构造函数参数时在类被new实例化时执行而且执行在构造函数内部逻辑之前。修饰实例方法参数时在类定义阶段执行且执行顺序是从右往左。修饰静态方法参数时同样在类定义阶段执行。function LogParam(target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) { console.log(参数装饰器触发${String(propertyKey)} 的第 ${parameterIndex} 个参数); } class Demo { constructor(LogParam name: string) { console.log(构造函数执行); } print(LogParam msg: string) { console.log(msg); } } console.log(--- 定义阶段 ---); new Demo(hello); // 输出 // --- 定义阶段 --- // 参数装饰器触发undefined 的第 0 个参数 // 构造函数执行上面print方法的参数装饰器如果没有被调用说明它和属性装饰器一样在类定义那一刻就定了。构造参数的装饰器却不一样它会在每次new时都跑一次因为只有实例化时你才有“真实参数值”可以被收集。1.3 两个装饰器都不在“调用时”执行这才是和 AOP 的边界这一点必须想明白属性装饰器和参数装饰器都不是围绕“调用”做切面的工具。方法装饰器能做的登录鉴权、日志切片、重试逻辑这俩都做不了因为它们拿不到方法的执行上下文。它们真正擅长的是在对象建立之前把描述信息、依赖关系、字段规则钉到类本身的结构上。后面那些框架能把Inject()写到构造函数参数上就是依赖了这种“实例化前收集元数据”的能力。理解了这个边界你就不会拿属性装饰器硬做中间件自然也不会拿参数装饰器去拦截参数值。2. 属性装饰器能做的事从字段标记到描述符重写2.1 最基础的用法给字段打元数据标签业务中我会优先用属性装饰器做“配置类标注”典型就是把字段和某个字典、某个数据库列名、某个权限码绑定。import reflect-metadata; const COLUMN_KEY entity:column; function Column(name?: string) { return function (target: Object, propertyKey: string | symbol) { Reflect.defineMetadata(COLUMN_KEY, name ?? String(propertyKey), target, propertyKey); }; } class Product { Column(product_name) name!: string; Column() price!: number; } function getColumnName(target: any, key: string) { return Reflect.getMetadata(COLUMN_KEY, target, key); } console.log(getColumnName(Product.prototype, name)); // product_name console.log(getColumnName(Product.prototype, price)); // price属性装饰器里的target需要特别注意修饰实例属性时target是类的原型对象Product.prototype修饰静态属性时target才是构造函数Product本身。所以元数据的读写要统一好载体不然很容易出现“写进去了但读不出来”。2.2 返回值为空是特性不是缺陷重写描述符要借 Object.defineProperty很多人写属性装饰器时尝试返回一个PropertyDescriptor结果发现完全不生效。比如下面的代码运行时装饰器返回的writable: false一点用都没有function ReadonlyProperty() { return { writable: false, }; } class User { // 这样写不会生效 ReadonlyProperty() name default; }原因是属性装饰器执行时还没进入实例化阶段TS 也没有把返回的 descriptor 接住并应用到属性上。想真正限制属性的只读、可枚举、可配置必须自己调用Object.definePropertyfunction ReadOnly(target: Object, propertyKey: string | symbol) { Object.defineProperty(target, propertyKey, { writable: false, }); } class User { ReadOnly name default; } const user new User(); user.name changed; // TypeError: Cannot assign to read only property name这里有个细节从类定义阶段到实例字段初始化之间TS 编译结果会决定defineProperty的生效路径。在target: ES5这类编译配置下TS 会把字段初始化编译成赋值语句访问器可以正常拦截而在target: ES2022、useDefineForClassFields: true时类字段的初始化采用原生类字段语义行为会有差异这一点后面第 5 节单独讲是个非常典型的坑。2.3 静态属性与实例属性target 指向要分清不看target指向就会出现元数据乱飞的问题。function MarkKey(target: Object, propertyKey: string | symbol) { console.log(target MyService); // 静态属性时 true console.log(target MyService.prototype); // 实例属性时 true } class MyService { MarkKey static version 1.0; MarkKey instanceField 1; }比如你要做一个“扫描所有注册接口”的工具静态属性上的标记需要存到构造函数本身实例字段上的标记要存到构造函数.prototype。读取时也必须对应用constructor或constructor.prototype去取否则会踩到元数据丢失的坑。我之前在一个网关项目里就是把实例字段的元数据存到了函数对象上结果某次版本升级后所有实例字段的标记全查不到了。2.4 配合 design:type 拿到字段的运行时类型TypeScript 开启emitDecoratorMetadata后属性装饰器还能间接拿到字段编译期类型。import reflect-metadata; function TypeInspector(target: Object, propertyKey: string | symbol) { const type Reflect.getMetadata(design:type, target, propertyKey); console.log(${String(propertyKey)} 的类型是 ${type?.name}); } class Order { TypeInspector total!: number; TypeInspector createdAt!: Date; TypeInspector tags!: string[]; } // total 的类型是 Number // createdAt 的类型是 Date // tags 的类型是 Array注意design:type是编译期推导的构造器引用对于string、number这类原始类型拿到的是装箱构造器String、Number。你要做基础类型校验得再包一层映射。这个特性在属性装饰器手里很有用因为你可以统一扫描所有字段自动判断它们的类型并生成表单校验规则。3. 参数装饰器它改不了参数却能撑起依赖注入3.1 参数装饰器签名中那个常被忽略的 undefined参数装饰器的签名是function ParamDecorator(target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) {}当装饰器作用在构造函数参数上时propertyKey是undefined。这不是 bug而是 TS 的设计——构造函数本身没有方法名所以你需要靠parameterIndex区分参数位置。如果作用在普通方法参数上propertyKey是方法名parameterIndex是参数索引。我见过不少新手在写构造注入时把propertyKey当成字段名去存元数据结果存了个undefined排查半天。3.2 用参数索引做校验与日志方法级别如何区分最直观的用法是参数索引级别的方法调用日志。比如某个方法不允许第二个参数为空可以在装饰器里记录索引位置在方法装饰器里配合实现参数校验。参数装饰器本身很难直接修改参数值但可以结合方法装饰器对入参做前置检查。type NonNullMeta { methodKey: string; index: number }[]; const NOT_NULL_KEY validate:notNull; function NotNull(target: Object, propertyKey: string | undefined, parameterIndex: number) { if (propertyKey undefined) return; const existing: NonNullMeta Reflect.getOwnMetadata(NOT_NULL_KEY, target.constructor, propertyKey) ?? []; existing.push({ methodKey: propertyKey, index: parameterIndex }); Reflect.defineMetadata(NOT_NULL_KEY, existing, target.constructor, propertyKey); } function Validate(target: any, propertyKey: string, descriptor: PropertyDescriptor) { const original descriptor.value; descriptor.value function (...args: any[]) { const rules: NonNullMeta Reflect.getOwnMetadata(NOT_NULL_KEY, target.constructor, propertyKey) ?? []; for (const rule of rules) { if (rule.index args.length (args[rule.index] null || args[rule.index] undefined)) { throw new Error(${propertyKey} 的第 ${rule.index} 个参数不能为空); } } return original.apply(this, args); }; } class UserService { Validate create(NotNull name: string, NotNull age: number) { console.log(创建用户${name}${age} 岁); } } new UserService().create(alice, 18); // 正常 // new UserService().create(undefined as any, 18); // 抛错这个例子里参数装饰器只负责记录“哪几个位置不能为空”方法装饰器负责在运行时拦截并执行校验逻辑职责清晰。这正是参数装饰器的最佳使用姿势作为元数据标记工具而不是行为拦截工具。3.3 反射元数据与 design:paramtypes真正让参数装饰器在大型框架里发光的是design:paramtypes元数据。开启emitDecoratorMetadata后构造函数的参数类型会被记录到Reflect.getMetadata(design:paramtypes, 构造函数)上。import reflect-metadata; class Logger { log(msg: string) { console.log([LOG] ${msg}); } } class ProductService { constructor(private logger: Logger) {} } const paramTypes Reflect.getMetadata(design:paramtypes, ProductService); console.log(paramTypes[0].name); // Logger框架可以根据这个类型数组自动推断依赖而不必让你每个参数都手动写Inject。这也是 NestJS 的默认做法只要构造函数参数的类型是一个可注入的类容器就能自动解析自定义 token 才需要参数装饰器注明。3.4 为什么依赖注入框架都偏爱参数装饰器一句话参数装饰器能精准地把“依赖点”定位到构造函数参数上容器可以在实例化之前收集全部依赖关系从而一次性完成依赖图的构建。相比属性注入构造注入用参数装饰器实现后对象的创建流程是完全确定的先解析构造参数再执行构造函数实例创建出来就是完整可用的状态。属性注入则必须等实例创建完再补填字段容易半初始化。所以你在 NestJS 里看到的Inject(REDIS_OPTIONS)本质就是一个参数装饰器 元数据记录在 Angular 里看到的Inject(HTTP_CLIENT)同理。它们从来不试图改参数只是把“这个位置需要什么依赖”记下来等容器来读。4. 组合实战用属性装饰器和参数装饰器实现一个迷你注入容器4.1 需求设计与方案取舍下面我们做一个精简版依赖注入容器同时用到属性装饰器和参数装饰器Injectable(userService)标记类可被容器管理。Inject(logger)修饰构造函数参数告诉容器这个参数位需要什么依赖。InjectProperty(logger)修饰实例属性字段告诉容器这个字段需要在实例化后注入什么依赖。选择这种实现而不是直接依赖design:paramtypes自动推断是为了把两种装饰器的作用都展示清楚。真实的框架会优先走自动推断然后在推断不出来的场景如接口 token、字符串 token再用参数装饰器显式指定。4.2 完整实现代码import reflect-metadata; const CONTAINER new Mapstring, any(); const INJECT_PARAM_KEY di:injectParam; const INJECT_PROPERTY_KEY di:injectProperty; function Injectable(token?: string) { return function (target: Function) { const name token ?? target.name; Reflect.defineMetadata(di:token, name, target); }; } function Inject(token: string) { return function (target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) { if (propertyKey ! undefined) { // 方法参数场景简单忽略 return; } const existing Reflect.getOwnMetadata(INJECT_PARAM_KEY, target) ?? []; existing.push({ index: parameterIndex, token }); Reflect.defineMetadata(INJECT_PARAM_KEY, existing, target); }; } function InjectProperty(token: string) { return function (target: Object, propertyKey: string | symbol) { Reflect.defineMetadata(INJECT_PROPERTY_KEY, token, target, propertyKey); }; } function registerT(token: string, instance: T) { CONTAINER.set(token, instance); } function resolveT(token: string): T { if (!CONTAINER.has(token)) { throw new Error(容器中找不到 token: ${token}); } return CONTAINER.get(token); } function createT(cls: new (...args: any[]) T): T { const injectParams (Reflect.getOwnMetadata(INJECT_PARAM_KEY, cls) ?? []) as Array{ index: number; token: string; }; const args: any[] []; for (const injectParam of injectParams) { args[injectParam.index] resolve(injectParam.token); } const instance new cls(...args); // 属性注入根据属性装饰器记录的 token 回填 const proto cls.prototype; const propertyNames Object.getOwnPropertyNames(proto); for (const key of propertyNames) { const token Reflect.getOwnMetadata(INJECT_PROPERTY_KEY, proto, key); if (token) { (instance as any)[key] resolve(token); } } return instance; }4.3 核心流程拆解与验证定义两个业务类class Logger { log(msg: string) { console.log([LOG] ${msg}); } } class Database { query(sql: string) { console.log([DB] ${sql}); } } Injectable(userService) class UserService { InjectProperty(logger) private logger!: Logger; InjectProperty(database) private database!: Database; constructor(Inject(logger) logger: Logger) { this.logger logger; } getAllUsers() { this.database.query(select * from users); this.logger.log(用户列表查询完成); } } register(logger, new Logger()); register(database, new Database()); const service create(UserService); service.getAllUsers(); // 输出 // [DB] select * from users // [LOG] 用户列表查询完成流程分成两个阶段类定义阶段两个属性装饰器把logger、database的 token 写到UserService.prototype的元数据上构造参数装饰器把“index 0 需要 logger”写到UserService本身上。实例化阶段create先从构造函数元数据里解析构造参数new出实例后再扫描原型上的属性注入标记逐一从容器里取出对应实例回填。4.4 手动实现时的执行顺序细节createUserService(UserService)执行时构造参数注入的数据取自UserService构造函数的元数据属性注入的数据取自UserService.prototype的元数据。这两层元数据载体不能混混了就出现 2.3 说的那种“存得进去读不出来”。更关键的是顺序必须先构造参数完成new再属性回填。反过来构造过程需要的依赖还没解析或者属性回填发生在构造参数之前都会导致this.logger或this.database暂时为空。框架设计里构造注入之所以比属性注入更受推荐很大程度就是因为这个顺序天然保证“实例一出来就完整”。4.5 你可以继续扩展的方向这个迷你容器虽然小但完整复刻了 IoC 容器的骨架照着下面的思路可以继续加功能基于design:paramtypes的自动推断写一个autowire(cls)读取构造函数参数类型自动去容器里查类名对应的 token。单例管理register时如果指定singleton: true则create后缓存实例后续resolve直接返回缓存。循环依赖检测依赖解析时把当前解析链传入递归函数发现重复 token 就抛异常。标签分组给Inject增加可选参数optional容器里没有 provider 时返回undefined而不是直接抛错。5. 踩坑清单编译目标、字段初始化顺序与参数求值顺序5.1 target: ES2022 与 useDefineForClassFields 的兼容性陷阱这是我实际踩过的一个坑。早期用target: ES5写属性装饰器在装饰器里Object.defineProperty一个访问器字段初始化时 TS 编译成赋值语句会老老实实走 setter。后来把配置升级到target: ES2022属性装饰器包出来的访问器突然不生效了。原因在于原生类字段采用[[DefineOwnProperty]]语义实例字段会直接定义成自有数据属性等同于在原型上直接创建了一个新的own property从而遮蔽掉了原型上的访问器而不是像老编译目标那样通过赋值调用 setter。规避方式有三种保持useDefineForClassFields: false向后兼容老的赋值语义。不在原型上用defineProperty搞拦截改用WeakMap存储实例的真实值实现真正跨编译目标稳定的“私有状态”效果。在类中不对被装饰字段做初始化声明改由构造函数里显式赋值保证访问器始终有机会拦截。const values new WeakMapobject, unknown(); function HiddenValue(target: Object, propertyKey: string | symbol) { Object.defineProperty(target, propertyKey, { get(this: object) { return values.get(this); }, set(this: object, v: unknown) { values.set(this, v); }, }); } class Config { HiddenValue env production; }但注意env production的初始化在原生类字段语义下仍会创建自有属性把访问器遮蔽掉。如果一定要跨编译目标统一最稳妥的组合是装饰器只负责定义访问器类内部不初始化该字段构造函数里再赋值class Config { HiddenValue env!: string; constructor() { this.env production; // 赋值走 setter不会创建遮蔽 } }这类问题在写通用库时尤其致命因为你的用户编译目标五花八门一定要在 README 里明确测试矩阵。5.2 字段初始化顺序问题装饰器执行时字段还是空的属性装饰器执行时类字段尚未初始化所以装饰器内访问不到字段的值。function InspectDefault(target: Object, propertyKey: string | symbol) { // 此时拿不到实例更不能读取“默认值” console.log(target); // User.prototype非实例 }如果要在装饰器里做默认值相关的逻辑正确的做法是把逻辑延迟到实例化后。我常用的方案是结合构造函数在实例new出来时统一扫描元数据并处理。记住一个原则属性装饰器是做静态结构的动态行为的注册要放到构造环节或单独初始化方法里。5.3 参数装饰器从右到左求值参数装饰器在方法上的执行顺序是从右往左。如果事项逻辑依赖“先处理左侧参数”这种顺序容易造成意外。比如你标记一个方法有三个参数三个位置各挂一个Inject装饰器执行时先跑参数 2再跑参数 1最后跑参数 0。这种从右到左的顺序和 JS 函数实参求值顺序一致也是求值表达式时的自然顺序。写元数据合并逻辑时不要假设从左到右。5.4 emitDecoratorMetadata 的隐性依赖没有它就凉一半design:type、design:paramtypes、design:returntype都依赖emitDecoratorMetadata编译选项。很多人写属性装饰器时发现Reflect.getMetadata(design:type)总是undefined第一反应是 Reflect 库没引第二反应才是tsconfig.json里没开这个选项。{ compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true } }当年我把装饰器从旧项目迁移到新工程时漏开了emitDecoratorMetadata结果一堆依赖注入全部变成undefined注入报错信息毫无提示。这个选项直接影响框架行为生产项目里建议作为必开项。5.5 不要在生产环境对装饰器返回值的有效性做假设最后补充一个和Injectable这类类装饰器的差异只有类装饰器可以合理返回一个新构造函数并替换原类属性装饰器和参数装饰器的返回值都会被 TypeScript 默默丢弃。如果你看到某个代码库在属性装饰器里 return 什么东西那大概率是在做兼容处理或者根本没意识到这个行为。我们在封装基础库时约定所有属性、参数装饰器一律不写返回值避免任何人产生误解。6. 装饰器组合执行顺序这些顺序决定了你能怎么组合6.1 同一个元素的装饰器按从下往上执行function First(target: Object) { console.log(first); } function Second(target: Object) { console.log(second); } First Second class Demo {} // 输出 second 然后 first类上的多个装饰器是从下往上执行的。属性、方法同理。这在实际组合中意味着基础能力放下面扩展能力放上面。比如你有一个Validation()负责做参数校验一个Throttle()做限流想先限流再校验就要把Validation放下面让它在装饰器执行顺序中更早注册。6.2 类中不同元素的执行顺序参数装饰器先于方法装饰器在同一段代码中参数装饰器会比它所在的方法装饰器先执行。这也是 3.2 参数校验案例能成立的前提参数装饰器先把NotNull元数据写到target.constructor上方法装饰器再去读取并包一层校验逻辑。如果顺序反了方法装饰器读取时元数据还没落库校验就会失效。完整顺序大致是类内字段的属性装饰器按代码顺序执行方法装饰器会先执行其中参数装饰器再执行方法装饰器本身方法的多个参数装饰器从右往左执行类的多个装饰器从下往上执行6.3 元数据合并时注意引用共享用Reflect.getOwnMetadata取数组元数据后直接push再defineMetadata会出现一种隐患多个 target 之间如果原型链存在继承关系getMetadata会沿着原型链向上找可能把父类的数组合并到子类里。const existing Reflect.getOwnMetadata(KEY, target) ?? [];一定要用getOwnMetadata而不是getMetadata否则父类已有的元数据会被子类意外继承。我早期写容器时就因为这个问题子类方法莫名继承了父类的参数校验规则线上排查了一整天。7. 一套真正可落地的实践模板7.1 在业务代码中哪些场景我会真用属性装饰器数据库实体字段映射Column(user_name)做字段到数据库列的映射属性装饰器把列名写入元数据ORM 层统一读取。配置中心绑定Config(redis.host)把类字段自动绑定到配置文件的某个 key实例创建时统一从配置中心拉值填充。事件订阅标记Event(order.created)标记某个类字段是一个事件处理器需要注入的消费者对象。这些场景有一个共同点都是静态结构信息和实例生命周期无关。如果你发现自己要在属性装饰器里做“实例创建后才该做的事”多半选错了工具。7.2 参数装饰器在业务代码中的推荐落点参数装饰器最强的用途永远是依赖注入其次是参数校验。业务代码里我不建议写太多自定义参数装饰器因为有现成的 class-validator、zod 这类校验库更成熟。但如果项目已经引入装饰器体系又不愿意额外加库按照 3.2 的方案自造一个轻量校验器完全可行成本很低。7.3 一套推荐的 tsconfig 配置{ compilerOptions: { target: ES2021, experimentalDecorators: true, emitDecoratorMetadata: true, useDefineForClassFields: false, strict: true } }这个配置是目前我做装饰器相关开发时的最常用组合。target定为 ES2021useDefineForClassFields显式设为false尽量绕开 5.1 的原生字段语义坑。如果团队确定要上原生类字段语义那就得全面采用 WeakMap 方案并且要给属性注入类框架做一轮完整回归测试。7.4 从入门到放弃的三个判断标准如果出现下面三种情况就不要强行用属性装饰器或参数装饰器需要提前知道属性值本身。去装饰器里读实例字段值是反模式改用构造器注入或初始化方法。需要对参数做运行时变更。参数装饰器无权重写参数值必须配合方法装饰器包一层。项目完全不用反射元数据。没有reflect-metadata参数装饰器和属性装饰器基本退化成“只能打日志”。满足这三点时直接写普通函数或者基类抽象反而更靠谱。装饰器的价值在于声明式表达而不是万能魔法。我自己现在写类库时的习惯是属性装饰器只做元数据的“写”参数装饰器也只做元数据的“写”所有“读”和“执行”统一放到create、resolve这类工厂函数里。这个分工让代码调试非常舒服遇到问题只需要在工厂函数里打点不用去猜装饰器内部哪一步出了岔子。如果你刚接触这两个装饰器建议也先按这个纪律写一版跑通后再考虑往上加花活。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进