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 抽成团队共享库,让全组受益。




