TypeScript 装饰器实战:从类装饰器到依赖注入

TypeScript 装饰器(Decorator)是一种在不修改原代码的前提下,为类、方法、属性和参数动态织入横切逻辑的语言特性。无论你用 NestJS、TypeORM 还是自己造轮子,搞懂 TypeScript 装饰器都能让你写出更优雅、更可维护的代码。本文从最基础的类装饰器讲起,一步步手写一个轻量依赖注入(DI)容器,并补充组合、缓存、AOP 等进阶实战。

一、装饰器到底是什么

装饰器本质上是一个高阶函数:它接收被修饰的目标(构造函数、方法、属性描述符、参数索引等),返回新的或被修改后的目标。它和 Python 的装饰器、Java 的注解(Annotation)思路相通,但在 TypeScript 里有自己的一套签名约定。理解这一点,你就掌握了它的全部心智模型。

为什么它重要?在大型项目里,日志、鉴权、缓存、重试这些能力往往与业务无关,却散落在几十个方法里。装饰器把这类“横切关注点”收拢成声明式的标签,业务代码保持干净,复用与测试都更简单——这正是面向对象里 AOP(面向切面编程)想解决的问题,而装饰器是 JavaScript/TypeScript 世界里最轻量的实现方式。

二、打开编译器开关

当前 TypeScript 同时支持两套装饰器语义:实验性的 experimentalDecorators(NestJS、TypeORM 大量使用)与 TC39 Stage 3 标准装饰器。两套写法略有差异,标准装饰器用 await 风格、直接返回替换值,而实验模式通过描述符改写。下面的示例以兼容性最广的实验模式为主,并配合 reflect-metadata 做元数据反射。

{
  "compilerOptions": {
    "target": "ES2021",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "strict": true,
    "lib": ["ES2021", "DOM"]
  }
}

emitDecoratorMetadata 会向编译产物注入设计期类型信息,是后续依赖注入读取构造参数类型的关键。别忘了安装 reflect-metadata 并在入口文件顶部 import 'reflect-metadata'。如果项目用 Vite 构建,可在 vite.config 里用 esbuild 的 tsconfigRaw 透传这两个开关。

三、类装饰器:给类“盖章”

类装饰器接收构造函数,常用于 sealed、添加元数据或替换实现。它最适合做“类级别”的横切,比如禁止继承、自动注册到某个容器、或打印实例化审计日志。下面是一个给类打日志、并在实例化时打印类名的例子:

function logged(target: Function) {
  const original = target;
  const wrapper: any = function (...args: any[]) {
    console.log(`[new] ${original.name} 实例化`);
    return new original(...args);
  };
  wrapper.prototype = original.prototype;
  return wrapper;
}

@logged
class UserService {
  constructor(public name: string) {}
  hello() { return `hi ${this.name}`; }
}

new UserService("Ada"); // 输出: [new] UserService 实例化

注意类装饰器返回的是“替换后的构造函数”。如果你只想附加元数据而不替换,直接不返回值即可,TypeScript 会保留原类。这一点和工厂函数模式一脉相承。

四、方法装饰器:织入横切逻辑

方法装饰器是最常用的一种,参数为 (target, propertyKey, descriptor)。借助它,我们可以零侵入地实现耗时统计、缓存、权限校验等能力。核心是改写 descriptor.value:把原始函数包一层,再赋值回去。

function measure(
  _target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const original = descriptor.value;
  descriptor.value = function (...args: any[]) {
    const start = performance.now();
    const result = original.apply(this, args);
    const end = performance.now();
    console.log(`${propertyKey} 耗时 ${(end - start).toFixed(2)}ms`);
    return result;
  };
  return descriptor;
}

class Calc {
  @measure
  fib(n: number): number {
    return n < 2 ? n : this.fib(n - 1) + this.fib(n - 2);
  }
}

改写描述符时要保留 this:务必用 original.apply(this, args),否则方法内部的 this 会丢失。这也是新手最容易踩的坑之一。

五、属性与参数装饰器 + 元数据反射

属性装饰器用来标记只读或必填字段;参数装饰器配合 reflect-metadata 可以记录依赖令牌(token),这正是依赖注入的基石。编译器在开启 emitDecoratorMetadata 后,还会自动写入 design:paramtypes(构造参数类型),让容器能在运行时反查出依赖。

import "reflect-metadata";

function Inject(token: string) {
  return function (
    target: any,
    _propertyKey: string,
    parameterIndex: number
  ) {
    const existing: string[] =
      Reflect.getMetadata("inject:tokens", target) || [];
    existing[parameterIndex] = token;
    Reflect.defineMetadata("inject:tokens", existing, target);
  };
}

class Controller {
  constructor(@Inject("Logger") private logger: any) {}
}

六、实战:手写一个轻量 DI 容器

把上面的积木拼起来,我们就能实现一个极简 IoC 容器:用 @Injectable 登记实现,用 @Inject 声明依赖,运行时自动完成构造与注入。它读取 design:paramtypes 得到构造参数类型,再去容器里取出对应实例。

const container = new Map();

function Injectable(token: string) {
  return function (target: T) {
    const paramTypes: any[] =
      Reflect.getMetadata("design:paramtypes", target) || [];
    const deps = paramTypes.map((t) => container.get(t.name));
    container.set(token, new target(...deps));
    return target;
  };
}

@Injectable("Logger")
class Logger { log(m: string) { console.log(m); } }

@Injectable("App")
class App {
  constructor(private logger: Logger) {}
  run() { this.logger.log("DI 容器启动成功"); }
}

(container.get("App") as App).run();

这个玩具级容器说明了原理,但生产框架(如 NestJS、InversifyJS)还要处理作用域(单例/请求级)、循环依赖、异步 Provider 等问题。理解了上面这段,你读它们的源码就不会再被装饰器绕晕。

七、装饰器的执行顺序

当多个装饰器叠加时,顺序很容易踩坑。记住一条规则:从下往上(从外往里)求值,从上往下执行。为了直观看到顺序,我们用一个打印标记的示例:

function tag(name: string) {
  console.log(`定义 ${name}`);
  return function () { console.log(`执行 ${name}`); };
}

class Demo {
  @tag("A") @tag("B")
  method() {}
}
// 输出:定义 B → 定义 A → 执行 A → 执行 B
场景求值顺序
同一方法上的多个方法装饰器从最后一个(最靠近方法)开始,向第一个执行
类装饰器 vs 成员装饰器先执行所有成员装饰器,最后执行类装饰器
参数装饰器在方法装饰器之前执行,按参数从左到右

八、常见坑与排障清单

实际工程中,下面几个坑出现频率最高:

现象原因与对策
装饰器不生效忘了开 experimentalDecorators,或 ts-node / esbuild 未透传该开关
Reflect.getMetadata 返回 undefined漏了 emitDecoratorMetadata 或入口未 import 'reflect-metadata'
DI 拿到 undefined 依赖容器登记顺序错误,被依赖项必须早于依赖者登记
箭头函数 this 丢失方法装饰器里用 original.apply(this, args) 保留调用上下文

排障时建议先用 tsc --noEmit 确认编译期没有开关告警,再在装饰器入口打日志观察是否真的被求值——很多时候“装饰器没生效”其实是根本没被编译进产物。

九、进阶:多个装饰器组合实战

装饰器真正的威力在于组合。下面把日志与耗时两个能力叠在同一方法上,业务方法本身一行不改:

class OrderService {
  @logged @measure
  create(order: { id: number }) {
    return `created ${order.id}`;
  }
}

组合时遵循第七节的顺序规则:@measure 先求值、@logged 后求值,但执行时 @logged 在外层先包裹。理解这点,你才能预测组合后的真实行为。

十、进阶:用装饰器实现方法缓存(memoize)

纯函数的重复计算很适合用装饰器做缓存。下面用参数的 JSON 作为 key,自动复用上次结果:

function memoize(_t: any, _k: string, descriptor: PropertyDescriptor) {
  const cache = new Map();
  const original = descriptor.value;
  descriptor.value = function (...args: any[]) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = original.apply(this, args);
    cache.set(key, result);
    return result;
  };
  return descriptor;
}

class MathUtil {
  @memoize
  expensive(n: number) { return n * n; }
}

十一、装饰器与 AOP 思想

回头看,日志、耗时、缓存、鉴权本质上都是“在方法前后插入逻辑”,这正是 AOP 的核心。装饰器用声明式语法把这些逻辑从业务方法里抽离,让 create() 只关心业务、把横切能力交给标签。这种分层让代码更聚焦、更易单测,也是 Spring、NestJS 等框架把装饰器用得出神入化的原因。

十二、落地到工程:构建与 CI

在 Vite 工程里用装饰器,关键是确保 esbuild 透传编译开关;在 GitHub Actions 的 CI 中加入 tsc --noEmit 类型检查,可以把装饰器签名错误挡在合并之前。编辑器侧,VS Code 配合 TS 5.x 能对装饰器目标做精准跳转,排障体验更好。

十三、小结

TypeScript 装饰器是一把“声明式”的利器:把日志、缓存、校验、依赖注入等横切关注点从业务代码里抽离出来。从类装饰器入门,到方法、属性、参数装饰器,再到用元数据反射手写 DI 容器,你已经掌握了它在框架底层运转的核心原理。下一步可以读 NestJS 源码,看看工业级容器是如何在装饰器之上建起整个依赖图的;也可以尝试把本文的 memoize、measure 抽成团队共享库,让全组受益。

上一篇 Raft 共识算法实战:从选主到日志复制与安全性
下一篇 Kubernetes HPA 实战:Pod 水平自动扩缩容
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]