TypeScript 装饰器换血了:用 Stage 3 新标准搞依赖注入和 AOP

7 阅读1分钟

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

跟老标准最大的区别:新装饰器拿不到类的原型和参数索引了,但多了 addInitializercontext.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.metadatacontext.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,可以找一个边缘模块先试着迁一下,感受一下新标准的设计思路。至少知道坑在哪,等框架跟进了不至于被动。