Skip to content

TypeScript 中的装饰器:是什么、解决什么问题与实现示例

很多人第一次接触装饰器时,通常都会先记住一件事:它长得像给类、方法、属性前面贴了一个 @ 标记。

但如果只停在这个印象上,后面一遇到框架代码、NestJS、元数据、依赖注入、权限控制这些词,就很容易越看越乱。

装饰器真正要回答的是:能不能用一种更声明式的方式,把附加行为挂到类或类成员上。

1. 装饰器到底是什么

装饰器可以看成 一类在定义阶段附加额外逻辑或元信息的语法机制

这里最值得抓住的词有两个:

  1. 定义阶段
  2. 附加

也就是说,装饰器并不是“调用时突然魔法生效”的黑盒,它更像是在类、方法、字段这些结构刚被定义出来时,先插入一层加工逻辑。

最典型的长相就是:

ts
@sealed
class UserService {}

或者:

ts
class UserService {
  @log
  findUser() {}
}

所以装饰器可以粗略记成一句话:它是在类和类成员定义时,额外挂上一层行为或说明信息。

2. 装饰器在解决什么问题

如果没有装饰器,很多“横切逻辑”通常只能这样处理:

  1. 手动包一层函数
  2. 在每个方法里重复写日志
  3. 用大量配置对象去描述类和路由关系
  4. 用额外注册代码把元信息塞到框架里

这会带来几个常见问题:

  1. 重复代码多
  2. 业务逻辑和附加逻辑混在一起
  3. 类定义和框架配置分离得太远
  4. 声明关系不直观

装饰器最想解决的,通常不是“少写几行代码”,而是:把那些和业务主逻辑平行存在的附加规则,以更靠近定义位置的方式表达出来。

3. 可以把装饰器理解成“声明式扩展点”

这是最稳的一种入门理解方式。

例如你想表达:

  1. 这个类应该被冻结
  2. 这个方法调用时要打印日志
  3. 这个接口方法需要鉴权
  4. 这个属性需要校验
  5. 这个类应该注册到某个容器里

如果全都写成外部注册代码,通常会越来越散。

而装饰器的价值就在于:把这些规则直接贴回到类或成员定义旁边。

4. 装饰器常见能作用在哪些位置

不同写法和不同阶段的装饰器细节会有差异,但从使用视角看,最常见的关注点通常是:

  1. 类装饰器
  2. 方法装饰器
  3. 属性或字段装饰器
  4. 访问器装饰器

工程上最常见、最容易建立直觉的,通常还是前两个:

  1. 类装饰器:给一个类整体附加能力或元信息
  2. 方法装饰器:给一个方法加日志、权限、缓存、埋点一类逻辑

5. 一个最容易理解的示例:给方法加日志

先不要一上来就看复杂框架,可以看一个最简单的目标:希望某个方法每次执行前后都自动打印日志。

5.1 一种新版 decorators 风格的简化示例

ts
function log(value: Function, context: ClassMethodDecoratorContext) {
  return function (this: unknown, ...args: unknown[]) {
    console.log(`start: ${String(context.name)}`, args)
    const result = value.apply(this, args)
    console.log(`end: ${String(context.name)}`, result)
    return result
  }
}

class UserService {
  @log
  findUser(id: number) {
    return { id, name: 'Tom' }
  }
}

这段代码最值得先理解的不是签名细节,而是执行思路:

  1. @log 先拿到原始方法
  2. 它返回一个包装后的新方法
  3. 后续真正执行时,走的是这个包装后的版本

也就是说:装饰器最常见的一种能力,就是在不改业务调用方式的前提下,替你包一层增强逻辑。

5.2 这类写法到底解决了什么

如果不用装饰器,你通常就得:

  1. 手动改方法体
  2. 每个方法重复写日志
  3. 或者在类外再做一层额外包装

而装饰器把这个增强点放回了定义位置本身。

6. 一个类装饰器示例:给类附加冻结行为

类装饰器更适合拿来建立“作用在整个类上”的理解。

ts
function sealed<T extends new (...args: any[]) => any>(target: T, context: ClassDecoratorContext) {
  Object.seal(target)
  Object.seal(target.prototype)
}

@sealed
class ConfigService {
  getEnv() {
    return 'dev'
  }
}

可以直接把它理解成:

  1. 类刚定义出来时,装饰器就有机会介入
  2. 它可以给类本体或原型加附加处理

这个例子本身不是说“业务里一定要这么写”,而是帮助你建立一个稳定认识:类装饰器作用的是类定义本身,而不是某一次具体调用。

7. 装饰器最常见的应用场景

装饰器真正高频出现的地方,通常不是语言教学 demo,而是框架和工程约定。

7.1 元信息声明

例如声明:

  1. 这个类属于哪个模块
  2. 这个方法对应哪个路由
  3. 这个属性需要什么校验规则

这类场景里,装饰器更多是在做:声明信息贴附。

7.2 依赖注入

很多后端或工程框架会用装饰器表达:

  1. 哪个类应该注册进容器
  2. 哪个依赖需要被注入

这类场景里,装饰器更像:容器和类定义之间的桥。

7.3 日志、鉴权、缓存、埋点

这类都属于很典型的横切逻辑。

它们的共同特点是:

  1. 不只属于某一个业务方法
  2. 会在很多地方重复出现
  3. 直接混进业务代码后容易让方法体变脏

所以装饰器非常适合表达:

  1. 方法执行前后记录日志
  2. 某些方法需要权限检查
  3. 某些调用结果可以缓存
  4. 某些方法需要埋点统计

8. 为什么装饰器经常和框架一起出现

因为框架特别需要“声明式注册”。

例如框架很想知道:

  1. 哪些类是控制器
  2. 哪些方法是路由处理函数
  3. 哪些参数需要校验
  4. 哪些类要交给依赖注入容器管理

如果没有装饰器,通常就需要:

  1. 单独写很多配置
  2. 写大量注册代码
  3. 手动把类、方法和框架行为再绑定一次

所以从框架视角看,装饰器真正有吸引力的地方在于:它让“结构定义”和“框架约定”更容易贴在一起。

9. 装饰器和高阶函数、代理模式有什么关系

这几个概念看起来有点像,但不要混在一起。

9.1 和高阶函数的关系

方法装饰器很多时候看起来像在做:

  1. 接收一个函数
  2. 返回一个增强后的函数

这和高阶函数的思路确实很接近。

所以可以直接把它理解成:方法装饰器经常会借助高阶函数式的包装思路实现增强。

9.2 和代理模式的关系

代理模式更偏运行时对象层面的转发和控制。

而装饰器更偏:在定义阶段把增强逻辑贴上去。

所以它们可能都能实现“增强”,但时机和语义不一样。

10. 为什么现在项目里的装饰器写法可能不一样

这是最容易把人看晕的一点。

很多人会发现:

  1. 有的项目里装饰器函数签名是旧样子
  2. 有的项目里是新版 decorators 风格
  3. 有的项目还会搭配 emitDecoratorMetadata

这不是你看错了,而是因为:TypeScript 生态里长期同时存在“历史项目写法”和“较新的 decorators 语义”。

11. 新版 decorators 和历史项目写法为什么会并存

可以直接把它理解成:

  1. 较新的 TypeScript 已支持新版 decorators 语义
  2. 但大量历史项目和部分框架生态,仍然在使用更早期的装饰器模式

这会带来一个直接结果:同样写 @xxx,不同项目里的底层行为和函数签名可能并不完全一样。

所以工程上要注意的是:

  1. 当前项目到底采用的是哪一套写法
  2. 当前框架是否依赖历史装饰器生态
  3. 是否还需要配合元数据能力一起使用

12. experimentalDecoratorsemitDecoratorMetadata 是什么

这两个配置经常一起出现,但它们不是一个东西。

12.1 experimentalDecorators

它通常和历史装饰器写法强相关。

可以看成 告诉 TypeScript,当前项目要启用那套更早期的装饰器支持方式

12.2 emitDecoratorMetadata

它更偏:在编译产物里额外发出一部分和类型相关的元数据信息。

这类能力常见于依赖注入、参数推断、框架反射一类场景。

但要注意:它不是“有了装饰器就一定必须开”的通用选项,而是看你的框架和运行机制是否真的需要这些元信息。

13. 一个更贴近框架思维的示例

下面这个例子不追求可直接放进生产,而是帮助你理解“元信息贴附”这件事。

ts
const routes = new Map<string, string>()

function Get(path: string) {
  return function (_value: Function, context: ClassMethodDecoratorContext) {
    routes.set(String(context.name), path)
  }
}

class UserController {
  @Get('/users')
  list() {
    return ['Tom', 'Lucy']
  }
}

console.log(routes.get('list')) // /users

这段示例真正要表达的是:

  1. 业务方法本身仍然写自己的业务逻辑
  2. 装饰器额外给这个方法贴上“路由路径”这类说明信息
  3. 框架后续就可以基于这些元信息完成注册

这也是为什么很多框架喜欢装饰器:它很适合把声明信息贴回类和方法定义本身。

14. 装饰器的优点是什么

如果装饰器用得合适,它最常见的优点包括:

  1. 声明更集中
  2. 横切逻辑更容易抽离
  3. 类和框架约定靠得更近
  4. 某些重复增强逻辑更容易复用

尤其在下面这些场景里,它会显得很顺手:

  1. 路由声明
  2. 参数校验
  3. 权限控制
  4. 依赖注入
  5. 日志和缓存增强

15. 装饰器的代价和边界是什么

这部分同样重要,不能只讲它“很优雅”。

15.1 可读性未必总是更强

如果装饰器叠得太多,读代码的人往往会很难第一眼看清:

  1. 方法真正被增强了什么
  2. 执行顺序是什么
  3. 哪些行为来自业务本体,哪些来自装饰器

15.2 调试成本可能上升

因为你看到的类定义,不一定就是最终真正执行时的完整行为。

15.3 框架绑定更重

有些项目大量依赖框架装饰器后,会让类定义和框架本身高度耦合。

15.4 不适合把所有逻辑都装饰器化

装饰器适合的是:横切逻辑和声明式规则。

它不适合替代正常的模块设计、函数抽象和业务建模。

16. 工程里最容易踩的坑

16.1 误把装饰器当成“更高级的语法糖”

它不只是语法写法变化,而是会影响定义阶段行为和框架接入方式。

16.2 不区分新版 decorators 和历史项目写法

这会直接导致:

  1. demo 能看懂
  2. 一进真实项目就发现签名对不上

16.3 过度使用装饰器

结果就是:

  1. 类定义上贴满了 @xxx
  2. 真正执行链路越来越不直观

16.4 把装饰器当成万能抽象

它能解决的是一类问题,不是所有重复逻辑都适合用它处理。

17. 一份检查清单

当你考虑要不要用装饰器时,至少可以问自己:

  1. 这段逻辑是不是横切逻辑
  2. 这部分信息是不是更适合声明式表达
  3. 当前项目采用的是新版 decorators,还是历史项目写法
  4. 当前框架是否真的需要元数据支持
  5. 用装饰器之后,团队成员会不会更容易理解,而不是更难

18. 小结

这一篇最想建立的核心认识是:

  1. 装饰器是在定义阶段给类或类成员附加行为或元信息的机制
  2. 它真正擅长的是声明式规则和横切逻辑
  3. 框架场景里它常被用于路由、依赖注入、校验、权限和日志增强
  4. 工程里一定要区分新版 decorators 和历史装饰器写法
  5. 装饰器有价值,但也有可读性和调试成本

如果只压缩成一句话,可以记住:

装饰器不是为了让代码看起来更花哨,而是为了把附加规则和增强逻辑,更靠近类与成员的定义位置表达出来。

基于 VitePress 构建的个人技术笔记。