TypeScript 装饰器:标准语法与旧版迁移

系列导航:TypeScript 7 现代开发指南 · 上一篇
技术基线:TypeScript 7.0.2

TypeScript 现在存在两套名称相同、语义却不兼容的装饰器:TypeScript 5.0 起默认支持的 ECMAScript Stage 3 标准装饰器,以及通过 experimentalDecorators 开启的旧版实验性装饰器。两者的函数签名、执行模型、元数据能力和可装饰位置都不同。

本篇以标准装饰器为主线,使用现代 (value, context) 签名讲解类、方法、字段和自动访问器装饰器;旧版语法只放在单独的迁移章节中,不能把旧版 target / propertyKey / descriptor 示例当成现代写法。

一、先分清两套装饰器

对比项 Stage 3 标准装饰器 旧版 TypeScript 装饰器
TypeScript 开关 TS 5.0 起默认支持,不开启 experimentalDecorators experimentalDecorators: true
典型签名 (value, context) (target, propertyKey, descriptor?)
方法替换 返回新方法 修改或返回 PropertyDescriptor
字段处理 接收 undefined,可返回字段初始化函数 接收原型/构造器和属性名
参数装饰器 不支持 支持
emitDecoratorMetadata 不兼容 可与旧版装饰器配合
标准状态 ECMAScript Stage 3 提案语义 TypeScript 历史实验实现

标准模式不需要额外开关:

{
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "strict": true
  }
}

关键点是不要把 experimentalDecorators 设为 true。这个选项不是“让标准装饰器功能更完整”,而是切换到另一套旧语义。

二、现代装饰器的统一模型:valuecontext

标准装饰器函数通常接收两个参数:

  1. value:被装饰的类或类元素。方法会收到原函数,自动访问器会收到 { get, set };字段在类定义阶段还没有实例值,因此收到 undefined
  2. context:描述装饰位置和能力的上下文对象。

TypeScript 提供了对应的上下文类型:

  • ClassDecoratorContext
  • ClassMethodDecoratorContext
  • ClassFieldDecoratorContext
  • ClassAccessorDecoratorContext
  • getter / setter 还分别有 ClassGetterDecoratorContextClassSetterDecoratorContext

成员上下文常见信息包括:

context.kind       // "class"、"method"、"field"、"accessor" 等
context.name       // string | symbol;类名还可能是 undefined
context.static     // 是否为静态成员
context.private    // 是否为私有成员
context.access     // has/get/set 等安全访问能力,具体成员可用项不同
context.addInitializer(initializer)

addInitializer() 注册的函数会在合适的初始化阶段执行。它适合绑定方法、登记实例、完成类定义后的初始化等工作,避免旧版装饰器直接猜测原型和字段赋值时机。

三、方法装饰器:返回替代函数

下面的装饰器记录方法耗时,并完整保留 this、参数与返回值类型:

function logged<This, Args extends unknown[], Result>(
  originalMethod: (this: This, ...args: Args) => Result,
  context: ClassMethodDecoratorContext<
    This,
    (this: This, ...args: Args) => Result
  >,
) {
  const methodName = String(context.name)

  return function (this: This, ...args: Args): Result {
    const startedAt = performance.now()

    try {
      return originalMethod.call(this, ...args)
    } finally {
      const elapsed = performance.now() - startedAt
      console.log(`${methodName} 耗时 ${elapsed.toFixed(1)}ms`)
    }
  }
}

class Calculator {
  @logged
  sum(values: number[]): number {
    return values.reduce((total, value) => total + value, 0)
  }
}

标准方法装饰器不接收 PropertyDescriptor。要包装方法,就返回一个兼容的新函数;没有返回值则保留原方法。

这个同步示例的 finally 只统计函数返回 Promise 之前的同步时间。若要统计异步任务,应单独设计只接受 Promise 返回值的异步装饰器,并 await 原方法。

四、addInitializer():为实例绑定方法

把类方法作为回调传递时可能丢失 this。方法装饰器可以在每个实例初始化时完成绑定:

function bound<This, Args extends unknown[], Result>(
  originalMethod: (this: This, ...args: Args) => Result,
  context: ClassMethodDecoratorContext<
    This,
    (this: This, ...args: Args) => Result
  >,
): void {
  if (context.private) {
    throw new Error(`@bound 不能用于私有方法 ${String(context.name)}`)
  }

  context.addInitializer(function () {
    const method = context.access.get(this)

    Object.defineProperty(this, context.name, {
      value: method.bind(this),
      configurable: true,
      writable: true,
    })
  })
}

class ButtonController {
  constructor(private readonly label: string) {}

  @bound
  handleClick(): void {
    console.log(this.label)
  }
}

const controller = new ButtonController('保存')
const callback = controller.handleClick
callback() // 保存

与在构造器里手写 this.handleClick = this.handleClick.bind(this) 相比,装饰器把重复行为集中到一个可复用边界。是否值得使用仍取决于团队可读性与运行时成本。

五、字段装饰器:返回初始化函数

标准字段装饰器执行时还没有类实例,因此第一个参数是 undefined。它可以返回一个初始化函数,转换每个实例的初始字段值:

function trim<This>(
  _value: undefined,
  context: ClassFieldDecoratorContext<This, string>,
) {
  if (context.static) {
    throw new Error('@trim 仅用于实例字段')
  }

  return function (this: This, initialValue: string): string {
    return initialValue.trim()
  }
}

class Profile {
  @trim
  displayName = '  Ada Lovelace  '
}

console.log(new Profile().displayName)
// "Ada Lovelace"

字段装饰器不是旧版的“拿到原型和字段名后调用 Object.defineProperty”。它通过初始化函数处理每个实例,因而不会把多个实例的字段值错误地共享在原型闭包中。

字段初始化函数的实际调用约定由标准装饰器转换负责;TypeScript 的 ClassFieldDecoratorContext<This, Value> 会检查返回函数是否接收并返回正确的字段值类型。

六、自动访问器装饰器:包装 accessor

标准装饰器支持 accessor 声明。自动访问器拥有隐藏存储,并向装饰器提供原始 get / set

function clamp(min: number, max: number) {
  return function <This>(
    target: ClassAccessorDecoratorTarget<This, number>,
    context: ClassAccessorDecoratorContext<This, number>,
  ) {
    const normalize = (value: number) =>
      Math.min(max, Math.max(min, value))

    return {
      get(this: This) {
        return target.get.call(this)
      },
      set(this: This, value: number) {
        target.set.call(this, normalize(value))
      },
      init(value: number) {
        console.log(`初始化 ${String(context.name)}`)
        return normalize(value)
      },
    }
  }
}

class Player {
  @clamp(0, 100)
  accessor score = 120
}

const player = new Player()
console.log(player.score) // 100

player.score = -10
console.log(player.score) // 0

访问器装饰器可以返回新的 getsetinitinit 转换初始值,set 处理后续赋值。它比把普通字段改写成原型访问器更符合标准字段初始化模型。

已有的 get value()set value() 也可以分别装饰,对应上下文类型为 ClassGetterDecoratorContextClassSetterDecoratorContext

七、类装饰器:观察或替换类

类装饰器接收构造器和 ClassDecoratorContext。下面在类定义完成后封闭构造器及其原型:

function sealed(
  value: Function,
  context: ClassDecoratorContext,
): void {
  context.addInitializer(function () {
    Object.seal(value)
    Object.seal(value.prototype)
  })
}

@sealed
class Session {
  constructor(readonly id: string) {}
}

类装饰器也可以返回兼容的新类来替换原类,但要谨慎:私有字段、静态成员、继承关系和构造签名都会增加复杂度,而且装饰器在运行时添加的实例成员不会自动出现在类的静态类型上。若目标只是为对象增加明确能力,普通工厂函数、继承或组合通常更容易理解。

八、装饰器工厂

装饰器工厂是“返回装饰器的函数”,适合接收配置。前面的 clamp(0, 100) 就是工厂:外层函数接收范围,内层函数才接收 valuecontext

再看一个可配置的方法装饰器:

function trace(prefix: string) {
  return function <This, Args extends unknown[], Result>(
    originalMethod: (this: This, ...args: Args) => Result,
    context: ClassMethodDecoratorContext<
      This,
      (this: This, ...args: Args) => Result
    >,
  ) {
    return function (this: This, ...args: Args): Result {
      console.log(`${prefix}:${String(context.name)}`, args)
      return originalMethod.call(this, ...args)
    }
  }
}

class Repository {
  @trace('repository')
  findById(id: string) {
    return { id }
  }
}

工厂参数在类定义时求值,不是每次调用方法时求值。不要在工厂求值阶段放入依赖请求上下文的状态。

九、求值顺序与应用顺序

多个装饰器叠加时要区分两个阶段:

  1. 装饰器表达式或工厂从上到下求值;
  2. 得到的装饰器函数从下到上应用,类似函数组合。
function mark(label: string) {
  console.log(`求值 ${label}`)

  return function <This, Args extends unknown[], Result>(
    value: (this: This, ...args: Args) => Result,
    context: ClassMethodDecoratorContext<
      This,
      (this: This, ...args: Args) => Result
    >,
  ) {
    console.log(`应用 ${label} 到 ${String(context.name)}`)
    return value
  }
}

class Demo {
  @mark('A')
  @mark('B')
  run() {}
}

定义类时输出顺序是:

求值 A
求值 B
应用 B 到 run
应用 A 到 run

如果多个装饰器都会包装返回函数,最靠近方法的装饰器先应用,外层装饰器随后包住它。组合日志、缓存、重试与权限检查时,顺序可能改变实际语义,应写测试固定行为。

十、一个完整的现代示例

下面把四类标准装饰器放在同一个类中。所有签名都是现代 (value, context) 模型:

@sealed
class Account {
  @trim
  owner = '  Grace Hopper  '

  @clamp(0, 1_000_000)
  accessor balance = 100

  @logged
  deposit(amount: number): number {
    this.balance += amount
    return this.balance
  }

  @bound
  print(): void {
    console.log(`${this.owner}: ${this.balance}`)
  }
}

const account = new Account()
account.deposit(50)

const print = account.print
print() // Grace Hopper: 150

这里没有 target.prototype、属性描述符或参数索引。字段初值由字段初始化函数转换,访问器通过 get / set / init 包装,方法通过返回函数替换,绑定逻辑则由 addInitializer() 在实例初始化阶段完成。

十一、旧版 experimentalDecorators 只用于遗留生态

旧项目或依赖旧装饰器的框架可能包含下面这种签名:

// 这是旧版示意,不是标准装饰器写法
function legacyLog(
  target: object,
  propertyKey: string | symbol,
  descriptor: PropertyDescriptor,
): void {
  console.log(target, propertyKey, descriptor)
}

它依赖以下配置:

{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}

如果旧框架还读取设计类型元数据,可能同时要求:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

emitDecoratorMetadata 是 TypeScript 的旧实验能力,常与 reflect-metadata 及特定框架约定配合。它不属于 Stage 3 标准装饰器,也不能用于标准装饰器模式。标准装饰器上下文中可能出现的标准元数据机制与旧的 design:typedesign:paramtypes 发射并不是同一套协议,不能当作无缝替代。

十二、迁移为什么不能只改函数参数名

旧版与标准版存在实质差异:

  • 旧方法装饰器操作 PropertyDescriptor;标准方法装饰器接收原函数并返回替代函数;
  • 旧字段装饰器接收原型或构造器;标准字段装饰器接收 undefined 并返回初始化函数;
  • 标准装饰器用 addInitializer() 表达初始化时机;旧版通常直接修改原型或描述符;
  • 标准装饰器不支持参数装饰器;
  • 标准装饰器不能与 emitDecoratorMetadata 配合;
  • 第三方框架可能依赖旧版参数索引、反射元数据或特定发射结果。

因此,一个按旧签名实现的装饰器通常不能直接在标准模式下使用,同一函数也很难在两种模式中保持可靠语义。

建议按以下步骤迁移:

  1. 盘点项目中的装饰器来源、使用位置、参数装饰器和元数据读取;
  2. 查阅框架是否明确支持标准装饰器,不要仅依据“TypeScript 5+ 可编译”判断;
  3. 先迁移自研、无元数据依赖的方法或字段装饰器;
  4. 使用标准上下文类型重新实现,并为求值、应用、初始化顺序补测试;
  5. 对依赖参数装饰器或 emitDecoratorMetadata 的框架,继续保留独立的 legacy 配置,或按框架官方方案升级;
  6. 无法表达的参数注入、校验元数据可改为显式注册、schema、工厂函数或静态字段,而不是制造脆弱的兼容适配器。

装饰器模式由编译配置决定。大型仓库如需同时维护两套生态,最好按包或构建目标隔离,避免在同一个源文件中混用两类假设。

十三、常见陷阱

  1. 以为 experimentalDecorators 是标准模式开关:设为 true 会启用旧语义。
  2. 把旧签名复制到现代示例target / key / descriptor 不是 Stage 3 方法装饰器签名。
  3. 尝试标准参数装饰器:标准提案目前不支持装饰参数。
  4. 标准模式开启 emitDecoratorMetadata:两者不兼容;依赖设计类型元数据的框架仍属于旧版迁移问题。
  5. 字段装饰器读取实例字段:装饰器应用时实例尚不存在,应返回初始化函数或使用 addInitializer()
  6. 忽略 privatestaticsymbol 名称:通用装饰器应检查上下文能力,并用 String(context.name) 安全记录名称。
  7. 返回不兼容的替代值:新方法、新访问器或新类必须满足标准和 TypeScript 对该位置的兼容要求。
  8. 忽略组合顺序:工厂从上到下求值,装饰器从下到上应用,顺序会改变包装行为。
  9. 用装饰器隐藏核心业务流程:权限、事务、缓存等行为若完全不可见,会增加调试成本;装饰器应保持职责单一并有测试。

小结

TypeScript 5.0 起默认支持的是 Stage 3 标准装饰器:它以 (value, context) 为统一模型,通过返回替代值和 addInitializer() 完成扩展。类、方法、字段和自动访问器各自拥有明确的上下文类型与初始化规则。

experimentalDecorators 则明确代表旧版 TypeScript 实现。旧版签名、参数装饰器和 emitDecoratorMetadata 不能直接迁入标准模式。迁移前应先确认框架生态和元数据依赖,再逐个用现代上下文重写;若依赖仍未迁移,隔离并保留 legacy 构建比表面兼容更可靠。

官方资料

系列导航:返回目录 · 上一篇