TypeScript 装饰器换血了:用 Stage 3 新标准搞依赖注入和 AOP
上周重构一个 Node 中间层项目,把老的 experimentalDecorators 全部切到了 TC39 Stage 3 新标准。过程中踩了不少坑,也发现新装饰器在 DI 容器和 AOP 这两个场景下,确实比老方案干净很多。记录一下整个落地过程。
老装饰器为什么该退了
先看一段经典的老写法,用过 NestJS 或者 InversifyJS 的应该很熟:
// 老标准(experimentalDecorators: true)
function Injectable(): ClassDecorator {
return function (target: Function) {
Reflect.defineMetadata('injectable', true, target)
}
}
function Inject(token: string): ParameterDecorator {
return function (target, _key, index) {
// 把依赖信息挂到 metadata 上
const deps = Reflect.getMetadata('design:paramtypes', target) || []
deps[index] = token
Reflect.defineMetadata('dependencies', deps, target)
}
}
这套方案跑了好多年,但有几个绕不开的问题:
- 强依赖
reflect-metadata这个 polyfill,运行时多了一层 emitDecoratorMetadata编译出来的类型信息不可靠,接口类型直接丢失- 参数装饰器、属性装饰器各有各的签名,记不住
最关键的一点:这套东西从来没进过 ECMAScript 标准。TypeScript 当年实现的是 Stage 1 的提案,后来提案改了好几版,现在 Stage 3 的设计跟老版本完全不是一回事。
新装饰器长啥样
TypeScript 5.0 开始支持的新标准,不需要开任何实验性配置。tsconfig.json 里不用写 experimentalDecorators,不用写 emitDecoratorMetadata——干掉这两行就行。
新装饰器的签名统一了,核心就一个形状:
type Decorator = (
value: Function | undefined, // 被装饰的东西
context: {
kind: 'class' | 'method' | 'getter' | 'setter' | 'field' | 'accessor'
name: string | symbol
addInitializer: (fn: () => void) => void // 初始化钩子,这个很关键
// ... 其他属性
}
) => Function | void
跟老标准最大的区别:新装饰器拿不到类的原型和参数索引了,但多了 addInitializer 和 context.metadata。
这意味着老的那套基于 Reflect.defineMetadata 的元编程模式得换个写法。
搭个 DI 容器
先从最简单的依赖注入容器开始。目标:通过装饰器声明依赖关系,容器自动组装实例。
// container.ts —— 整个 DI 核心就这么点东西
const registry = new Map<string, { clazz: new (...args: any[]) => any; deps: string[] }>()
const instances = new Map<string, any>()
// 注册一个服务
export function Service(token: string) {
return function (_value: new (...args: any[]) => any, context: ClassDecoratorContext) {
// addInitializer 在类定义完成后执行
context.addInitializer(function () {
registry.set(token, {
clazz: this as any,
deps: (context.metadata?.['deps'] as string[]) ?? [],
})
})
}
}
// 声明依赖(通过 accessor 装饰器实现属性注入)
export function Inject(token: string) {
return function (_value: ClassAccessorDecoratorTarget<any, any>, context: ClassAccessorDecoratorContext) {
// 把依赖 token 记录到 metadata
const deps = ((context.metadata['deps'] as string[]) ??= [])
deps.push(token)
return {
get(this: any) {
// 懒加载:第一次访问时才去容器里拿
return Container.resolve(token)
},
} satisfies ClassAccessorDecoratorResult<any, any>
}
}
注意这里用了 accessor 关键字配合装饰器——这是 Stage 3 新加的字段类型,专门给装饰器提供 getter/setter 的拦截能力。老标准里要拦截属性访问,得自己在原型上 Object.defineProperty,又丑又脆弱。
容器的 resolve 逻辑也就几行:
// container.ts 续
export const Container = {
resolve<T = any>(token: string): T {
if (instances.has(token)) return instances.get(token)
const entry = registry.get(token)
if (!entry) throw new Error(`未注册的服务: ${token}`)
// 直接 new,依赖通过 accessor 的 getter 懒解析
const instance = new entry.clazz()
instances.set(token, instance) // 单例缓存
return instance
},
clear() {
instances.clear() // 测试用
},
}
用起来是这样的:
@Service('logger')
class Logger {
log(msg: string) { console.log(`[LOG] ${msg}`) }
}
@Service('userService')
class UserService {
// accessor + @Inject → 访问时自动从容器拿 Logger 实例
@Inject('logger') accessor logger!: Logger
createUser(name: string) {
this.logger.log(`创建用户: ${name}`)
// ... 业务逻辑
}
}
// 启动
const userService = Container.resolve<UserService>('userService')
userService.createUser('张三') // → [LOG] 创建用户: 张三
整个 DI 不到 50 行代码,没用任何第三方库,不需要 reflect-metadata。
循环依赖怎么办
accessor 的懒加载天然避开了大部分循环依赖问题——A 依赖 B、B 依赖 A,只要不在构造函数里互相调用,运行时 resolve 的时候对方已经注册过了。
但如果真碰到构造函数里的循环引用,老实说没有银弹。我的做法是加一个检测:
resolve<T = any>(token: string, resolving = new Set<string>()): T {
if (resolving.has(token)) {
throw new Error(`循环依赖: ${[...resolving, token].join(' → ')}`)
// 比如输出: 循环依赖: A → B → A
}
resolving.add(token)
// ... 后续逻辑
}
报错信息直接告诉你环在哪,比默默死循环强。
AOP 切面:方法装饰器的正经用法
DI 解决了"谁创建谁"的问题,AOP 解决的是"怎么在不改业务代码的前提下插入横切逻辑"。日志、鉴权、性能埋点、缓存——这些都是典型场景。
新标准的方法装饰器非常适合干这事:
// 日志切面
function Log(target: Function, context: ClassMethodDecoratorContext) {
const methodName = String(context.name)
return function (this: any, ...args: any[]) {
console.log(`→ ${methodName}(${args.map(a => JSON.stringify(a)).join(', ')})`)
const result = target.apply(this, args)
// 处理异步方法
if (result instanceof Promise) {
return result.then(val => {
console.log(`← ${methodName} resolved`)
return val
})
}
console.log(`← ${methodName} =`, result)
return result
}
}
一个 @Log 扔上去就完事了,不用动原方法一行代码:
class OrderService {
@Log
calculateTotal(items: { price: number; qty: number }[]) {
return items.reduce((sum, item) => sum + item.price * item.qty, 0)
}
@Log
async submitOrder(orderId: string) {
await db.save(orderId)
return { success: true }
}
}
// 调用时自动打印:
// → calculateTotal([{"price":10,"qty":2}])
// ← calculateTotal = 20
组合多个切面
装饰器可以叠加,执行顺序是从下往上(跟老标准一样):
class PaymentService {
@Log // 3. 最外层:记录日志
@Auth('admin') // 2. 中间层:检查权限
@Cache(60) // 1. 最内层:查缓存
async charge(userId: string, amount: number) {
// 实际扣款逻辑
}
}
// 执行流程:Log 包裹 Auth 包裹 Cache 包裹原方法
// 就像洋葱一样,一层一层往里走
Auth 和 Cache 的实现思路差不多,都是返回一个包装函数:
function Auth(role: string) {
return function (target: Function, context: ClassMethodDecoratorContext) {
return function (this: any, ...args: any[]) {
const currentUser = getCurrentUser() // 从上下文拿当前用户
if (!currentUser.roles.includes(role)) {
throw new Error(`需要 ${role} 权限`)
}
return target.apply(this, args)
}
}
}
function Cache(ttlSeconds: number) {
const cache = new Map<string, { value: any; expire: number }>()
return function (target: Function, context: ClassMethodDecoratorContext) {
return function (this: any, ...args: any[]) {
const key = JSON.stringify(args) // 简单用参数做 key
const cached = cache.get(key)
if (cached && Date.now() < cached.expire) return cached.value
const result = target.apply(this, args)
cache.set(key, { value: result, expire: Date.now() + ttlSeconds * 1000 })
return result
}
}
}
这个 Cache 实现比较粗糙,生产环境肯定要考虑内存上限和淘汰策略。但作为演示够了。
老项目迁移要注意的
如果你的项目正在用 experimentalDecorators,别想着一步到位全切。几个关键的差异:
参数装饰器没了。 新标准不支持装饰构造函数参数。NestJS 那套 @Inject() 打在构造函数参数上的模式,在新标准里行不通。得换成属性注入或者 accessor 注入。NestJS 目前还没迁移到新标准,所以用 NestJS 的先别动。
metadata 的获取方式变了。 老标准用 Reflect.getMetadata,新标准用 Symbol.metadata 或 context.metadata。这是个全局 Symbol,需要确认你的运行时支持。Node 22+ 和新版浏览器基本没问题。
装饰器执行时机不同。 新标准的装饰器在类定义时就执行了,而 addInitializer 里的回调在类完全定义好之后才跑。如果你在装饰器里直接操作类的原型,时机上要留意。
之前迁移的时候有个坑:老代码里有个装饰器在执行时通过 target.prototype 往类上挂方法,迁到新标准后发现 context 里拿不到 prototype。后来改成用 addInitializer 在里面通过 this 来操作,才搞定。
跟老标准的取舍
聊一下什么时候该用新标准,什么时候先别动:
| 场景 | 建议 |
|---|---|
| 新项目、没有历史包袱 | 直接用新标准 |
| 用了 NestJS / InversifyJS | 等框架官方迁移,别自己硬改 |
| 只是加点日志/缓存这类简单 AOP | 新标准很合适,代码更简洁 |
| 重度依赖参数装饰器 | 暂时别动,或者改成属性注入再迁 |
我个人倾向是:能切就切。老标准的 emitDecoratorMetadata 编译出来的类型信息本身就不靠谱,接口类型会变成 Object,联合类型也会丢。新标准虽然不提供自动类型元数据,但至少你手动声明的东西是准确的,不会出现那种"类型告诉你是 A,运行时其实是 B"的幽灵 bug。
边界在哪
新装饰器不是万能的。几个我碰到的限制:
没有参数装饰器,构造函数注入不好做。 上面说了,只能用属性注入绕过去。如果你的 DI 容器强依赖构造函数签名分析,迁移成本会比较高。
性能开销。 每一层装饰器都是一次函数包装。叠五六层切面在热路径上肯定有影响。之前压测过,单个方法叠 3 层装饰器大概增加 2~3 微秒的调用开销,大部分场景无感,但如果是每秒调用上万次的内部工具函数,建议别用装饰器。
调试不太友好。 堆栈里会多出好几层包装函数。出了问题看 stack trace 得数着跳。这点没什么好办法,给包装函数设个 displayName 或者用 Object.defineProperty(fn, 'name', ...) 稍微改善一点。
聊到这
新装饰器标准最大的意义不是语法糖层面的改进,而是它终于要进 ECMAScript 了。等各大浏览器引擎原生实现之后,连编译都可以省了——装饰器直接跑在运行时。
DI 和 AOP 只是两个最典型的用法。accessor 装饰器配合 addInitializer,能玩出很多花样:表单校验、ORM 字段映射、状态管理的响应式绑定……思路是一样的,就是在"定义"和"使用"之间插入一层自动化逻辑。
如果你的项目还在用 experimentalDecorators,可以找一个边缘模块先试着迁一下,感受一下新标准的设计思路。至少知道坑在哪,等框架跟进了不至于被动。