TypeScript 装饰器的实际使用场景

HTMLPAGE 团队
16 分钟阅读

TypeScript 装饰器在经历了多次提案变更后,终于在 5.0 版本支持了 ECMAScript 标准的装饰器。本文对比新旧装饰器语法,讲解类装饰器、方法装饰器、访问器装饰器和属性装饰器的实际用途,并用四种模式展示它们在项目中的应用。

#TypeScript #装饰器 #依赖注入 #日志 #校验 #AOP

TypeScript 装饰器经历了一段不太平滑的演进。早期版本(5.0 之前)使用的是实验性的"传统装饰器"(experimental decorators),需要在 tsconfig.json 中开启 experimentalDecorators: true。5.0 之后开始支持 ECMAScript 标准的装饰器提案(Stage 3),默认不兼容。

目前大部分遗留项目仍然在用传统装饰器,新项目可以逐步采用标准装饰器。本文以传统装饰器为例来说明模式——这些模式在新标准下同样适用,只是语法和参数有差异。

四种装饰器的签名

传统装饰器有四类,每种接收的参数不同:

// 类装饰器
type ClassDecorator = (target: Function) => void | Function

// 方法装饰器
type MethodDecorator = <T>(
  target: T,
  propertyKey: string | symbol,
  descriptor: TypedPropertyDescriptor<any>
) => void | TypedPropertyDescriptor<any>

// 访问器装饰器(getter/setter)
type AccessorDecorator = <T>(
  target: T,
  propertyKey: string | symbol,
  descriptor: TypedPropertyDescriptor<any>
) => void | TypedPropertyDescriptor<any>

// 属性装饰器
type PropertyDecorator = (
  target: Object,
  propertyKey: string | symbol
) => void

方法装饰器和属性装饰器虽然都接收 targetpropertyKey,但方法装饰器额外接收属性描述符(descriptor),所以可以修改方法的行为。属性装饰器没有 descriptor,只能做元数据附加。

模式 1:自动日志(方法装饰器)

最常用的装饰器模式——给方法加日志,不侵入业务逻辑:

function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value

  descriptor.value = function (...args: any[]) {
    console.log(`[${new Date().toISOString()}] ${propertyKey} called with:`, args)
    const start = performance.now()
    const result = originalMethod.apply(this, args)
    const duration = performance.now() - start
    console.log(`[${new Date().toISOString()}] ${propertyKey} completed in ${duration.toFixed(2)}ms`)
    return result
  }

  return descriptor
}

class UserService {
  @log
  async getUser(id: string) {
    // 实际的业务逻辑
    return db.users.find(id)
  }

  @log
  async updateUser(id: string, data: Partial<User>) {
    return db.users.update(id, data)
  }
}

输出:

[2026-06-04T10:00:00.000Z] getUser called with: ["123"]
[2026-06-04T10:00:00.150Z] getUser completed in 150.23ms

这种模式的优点是所有方法的日志行为一致,修改日志格式只需要改一个装饰器。而且每个装饰器是独立的——可以给重要方法加日志,给高频方法跳过。

模式 2:输入校验(方法装饰器 + 元数据)

给方法参数加校验前置条件:

type ValidationRule = (value: any) => string | null // null 表示通过

function validate(rules: Record<string, ValidationRule>) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value

    descriptor.value = function (...args: any[]) {
      // 通过参数名匹配规则(需要额外元数据来映射参数名)
      const paramNames = getParamNames(originalMethod)
      for (const [name, rule] of Object.entries(rules)) {
        const index = paramNames.indexOf(name)
        if (index === -1) continue
        const error = rule(args[index])
        if (error !== null) {
          throw new ValidationError(`Validation failed for ${propertyKey}.${name}: ${error}`)
        }
      }

      return originalMethod.apply(this, args)
    }

    return descriptor
  }
}

class UserController {
  @validate({
    email: (v: any) => typeof v === 'string' && v.includes('@') ? null : 'Invalid email',
    age: (v: any) => typeof v === 'number' && v >= 0 && v <= 150 ? null : 'Invalid age'
  })
  async createUser(email: string, age: number) {
    // ...
  }
}

这里不需要在每个方法体里手写校验逻辑。如果将来校验规则变了,只需要改装饰器参数。

模式 3:方法节流/防抖(方法装饰器)

在 UI 事件或 API 调用场景下,节流和防抖是常见需求:

function throttle(ms: number) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value
    let lastCall = 0

    descriptor.value = function (...args: any[]) {
      const now = Date.now()
      if (now - lastCall < ms) return
      lastCall = now
      return originalMethod.apply(this, args)
    }

    return descriptor
  }
}

class AnalyticsTracker {
  @throttle(1000) // 每秒最多上报一次
  trackEvent(name: string, data: Record<string, any>) {
    // 发送分析事件
    navigator.sendBeacon('/api/analytics', JSON.stringify({ name, data }))
  }
}

模式 4:依赖注入(类装饰器 + 属性装饰器)

依赖注入是类级别装饰器的典型应用。先定义一个容器:

const container = new Map<string, any>()

function Injectable() {
  return function (target: any) {
    // 标记这个类可以被注入
    Reflect.defineMetadata('injectable', true, target)
  }
}

function Inject(token: string) {
  return function (target: any, propertyKey: string | symbol) {
    // 在属性上存储依赖标记
    Reflect.defineMetadata('inject', token, target, propertyKey)
  }
}

// 自动装配
function autoInject<T extends { new (...args: any[]): any }>(target: T): T {
  return class extends target {
    constructor(...args: any[]) {
      super(...args)
      // 遍历原型链,查找需要注入的属性
      const instance = this as any
      const proto = Object.getPrototypeOf(instance)
      for (const key of Object.getOwnPropertyNames(proto)) {
        const token = Reflect.getMetadata('inject', proto, key)
        if (token && container.has(token)) {
          instance[key] = container.get(token)
        }
      }
    }
  }
}

@Injectable()
class Logger {
  log(msg: string) { console.log(msg) }
}

@Injectable()
@autoInject
class UserServiceDI {
  @Inject('Logger')
  private logger!: Logger

  async getUser(id: string) {
    this.logger.log(`Fetching user ${id}`)
    // ...
  }
}

实际项目一般用 reflect-metadata 库来处理元数据操作。这里的关键是:属性装饰器只能标记"需要注入什么",具体的注入逻辑在类装饰器(autoInject)中执行。

传统装饰器 vs 标准装饰器

TypeScript 5.0 开始支持 ECMAScript 标准装饰器。主要区别:

方面传统装饰器(experimental)标准装饰器(Standard)
tsconfig 配置需要 experimentalDecorators: true不需要
方法装饰器参数(target, key, descriptor)(target, context)
返回值descriptor 或无新函数或 void
元数据支持reflect-metadata需要装饰器自行管理
与 Babel 兼容不一致一致

标准装饰器的方法装饰器签名:

function logged<T extends (...args: any[]) => any>(
  target: (this: void, ...args: Parameters<T>) => ReturnType<T>,
  context: ClassMethodDecoratorContext
) {
  const methodName = String(context.name)

  function replacementMethod(this: any, ...args: any[]) {
    console.log(`LOG: Entering method '${methodName}'.`)
    const result = target.call(this, ...args)
    console.log(`LOG: Exiting method '${methodName}'.`)
    return result
  }

  return replacementMethod
}

context 对象提供了 namekindstaticprivateaddInitializer 等信息,比传统装饰器的签名更丰富。

总结

装饰器类型能做的事典型用途
类装饰器替换或包装构造函数依赖注入、注册到框架
方法装饰器修改方法行为日志、校验、重试、缓存
访问器装饰器修改 getter/setter响应式计算、延迟初始化
属性装饰器附加元数据注入标记、序列化映射

装饰器的核心价值在于横切关注点(cross-cutting concerns)的分离。日志、校验、权限检查这类逻辑不适合散落在每个方法体内——它们横跨多个方法,最好用一个装饰器统一管理。使用装饰器时要注意的一个原则是:它应该增强方法但不改变方法的语义,否则会引入难以调试的隐式行为。