装饰器(Decorator)是 TypeScript 中最接近"语言反射"的特性:它允许在类、方法、属性、参数声明的挂载点上注入逻辑,让框架在运行时读取声明期附加的元数据。NestJS 正是靠装饰器 + reflect-metadata 建立了整套依赖注入体系。
在 TS 5.0 之前,装饰器被称为"实验性"特性,需要开启 experimentalDecorators;TS 5.0 正式实现了 ECMAScript 标准装饰器(Stage 3),其上下文对象与旧式装饰器有显著差异。本文以 TS 5.0 标准装饰器为主,同时给出旧式装饰器的对照,帮助你阅读存量代码。
1. 装饰器基础:从实验性到标准
1.1 标准装饰器语法与上下文
TS 5.0+ 的标准装饰器接收 (value, context) 两个参数,context 携带 kind、name、static、private、addInitializer 等字段。以方法装饰器为例:
function logged(value: Function, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
return function (this: unknown, ...args: unknown[]) {
console.log(`[LOG] 调用 ${methodName},参数:`, args);
const result = value.apply(this, args);
console.log(`[LOG] ${methodName} 返回:`, result);
return result;
};
}
class Calculator {
@logged
add(a: number, b: number): number {
return a + b;
}
}
new Calculator().add(1, 2);
// [LOG] 调用 add,参数: [1, 2]
// [LOG] add 返回: 3
关键差异:标准装饰器的返回值会替换原始成员(方法装饰器返回新函数),而旧式装饰器没有返回值语义。ClassMethodDecoratorContext 还提供 addInitializer,可用于在类实例化时执行初始化逻辑。
1.2 类装饰器
类装饰器在类定义后运行,常被用来"包装"或"标记"一个类:
type Constructor<T = object> = new (...args: any[]) => T;
function seal<T extends Constructor>(value: T, context: ClassDecoratorContext) {
return class extends value {
constructor(...args: any[]) {
super(...args);
// 冻结实例,防止运行时被随意扩展
Object.seal(this);
}
};
}
@seal
class User {
constructor(public name: string) {}
}
context.kind 此时为 'class',ClassDecoratorContext 提供了 addInitializer 用于在类创建后立即执行的钩子。
1.3 属性、访问器与参数装饰器
标准装饰器对属性只做标记(不能替换值,因为还没有值),对访问器可以替换 getter/setter,对参数则只能读取索引与上下文:
// 属性装饰器:只能做元数据标记
function required(value: undefined, context: ClassFieldDecoratorContext) {
// context.kind === 'field'
}
// 参数装饰器:记录参数位置,供 DI 容器收集
function inject(token: symbol) {
return function (_value: undefined, context: ClassParameterDecoratorContext) {
const paramIndex = context.index; // 参数位置
const ctor = context; // kind === 'parameter'
// 把 (token, index) 记录下来,供构造时注入
};
}
class Service {
constructor(@inject(DB_TOKEN) private db: Database) {}
}
注意:参数装饰器本身拿不到构造函数,通常需要配合
reflect-metadata的design:paramtypes,或由容器在扫描类时统一收集。
2. reflect-metadata 与元数据反射
2.1 设计时类型信息
reflect-metadata 提供了 Reflect.metadata(key, value) 与 Reflect.getMetadata(key, target) API。TS 编译器在开启装饰器后,会自动为被装饰成员注入三条"设计时"元数据:
| Metadata Key | 含义 | 举例 |
|---|---|---|
design:type | 属性/参数声明的类型 | Number |
design:paramtypes | 构造器/方法参数的构造器类型数组 | [Database, Logger] |
design:returntype | 方法返回类型 | Promise |
import 'reflect-metadata';
class Database {}
class Logger {}
class Service {
constructor(
private db: Database,
private logger: Logger,
) {}
fetch(id: number): Promise<Database> {
return Promise.resolve(this.db);
}
}
const paramTypes = Reflect.getMetadata('design:paramtypes', Service);
// => [Database, Logger](构造函数,而非字符串)
const retType = Reflect.getMetadata('design:returntype', Service.prototype.fetch);
// => Promise(泛型参数在运行时被擦除,只有 Promise 构造器)
这就是依赖注入的根基:框架不靠约定,而是从 design:paramtypes 读出构造器参数的类型,再去容器里查找对应实例。
2.2 自定义元数据键
除了设计时类型,装饰器经常需要写入业务元数据:
const ROUTE_META = Symbol('route');
function route(path: string) {
return function (value: Function, context: ClassDecoratorContext) {
Reflect.defineMetadata(ROUTE_META, path, value);
};
}
@route('/api/users')
class UserController {
// ...
}
const p = Reflect.getMetadata(ROUTE_META, UserController);
// => '/api/users'
使用 Symbol 作为 key 可以避免与库之间的 key 冲突,这是框架作者的标准做法。
2.3 参数装饰器的收集模式
标准参数装饰器拿不到类,因此 DI 容器需要在类装饰器里统一读取 design:paramtypes,再结合参数装饰器记录的注入 token 做映射:
const INJECT_TOKENS = Symbol('inject_tokens');
function inject(token: unknown) {
return function (_v: undefined, context: ClassParameterDecoratorContext) {
const ctor = context;
// 找到类之后写入的数组:容器在类装饰器阶段初始化
// 这里通过静态字段模拟收集
};
}
实践中更常见的方案是:参数装饰器把 (index, token) 通过 globalThis 的弱引用暂存,类装饰器再"认领"。NestJS 使用的就是类似的"metadata 双阶段"收集模式。
3. 手写轻量 DI 容器
3.1 Token 设计与注册表
DI 容器的核心是一个"类型 → 实例"的注册表。Token 可以是构造函数、Symbol 或字符串:
type Token<T = unknown> = Constructor<T> | symbol | string;
type Factory<T = unknown> = (container: Container) => T;
class Container {
private registry = new Map<Token, { factory: Factory; singleton: boolean }>();
private instances = new Map<Token, unknown>();
bind<T>(token: Token<T>, factory: Factory<T>, singleton = true): this {
this.registry.set(token, { factory, singleton });
return this;
}
resolve<T>(token: Token<T>): T {
// 单例命中缓存
const cached = this.instances.get(token);
if (cached !== undefined) return cached as T;
const entry = this.registry.get(token);
if (!entry) {
throw new Error(`DI: 找不到 token 的注册,token=${String(token)}`);
}
const instance = entry.factory(this);
if (entry.singleton) this.instances.set(token, instance);
return instance as T;
}
}
3.2 构造器注入:自动解析参数
配合 reflect-metadata,容器可以读取 design:paramtypes 自动完成构造器注入——这是手写 DI 最核心的一步:
import 'reflect-metadata';
type Constructor<T> = new (...args: any[]) => T;
class Container {
private registry = new Map<Constructor, { ctor: Constructor; singleton: boolean }>();
private instances = new Map<Constructor, unknown>();
register<T>(ctor: Constructor<T>, singleton = true): this {
this.registry.set(ctor, { ctor, singleton });
return this;
}
resolve<T>(ctor: Constructor<T>): T {
const cached = this.instances.get(ctor);
if (cached !== undefined) return cached as T;
const entry = this.registry.get(ctor);
if (!entry) throw new Error(`DI: ${ctor.name} 未注册`);
// 反射构造器参数类型
const paramTypes =
(Reflect.getMetadata('design:paramtypes', ctor) as Constructor[]) ?? [];
const deps = paramTypes.map((dep) => this.resolve(dep));
const instance = new entry.ctor(...deps) as T;
if (entry.singleton) this.instances.set(ctor, instance);
return instance;
}
}
// 使用
class Database { ping() { return 'pong'; } }
class UserService {
constructor(private db: Database) {}
async list() { return this.db.ping(); }
}
const container = new Container();
container.register(Database);
container.register(UserService);
const svc = container.resolve(UserService);
// UserService 的构造函数参数 Database 被自动从容器解析注入
3.3 接口注入:用 Token 桥接抽象与实现
design:paramtypes 只能读取到构造器类型,接口在运行时不存在。因此面向接口编程时,需要注册"接口 Token"而非接口本身:
interface Logger { log(msg: string): void; }
const LoggerToken = Symbol('Logger');
class ConsoleLogger implements Logger {
log(msg: string) { console.log(msg); }
}
class OrderService {
constructor(private logger: Logger) {}
create() { this.logger.log('order created'); }
}
这里容器无法自动注入——design:paramtypes 读到的是接口(运行时为 Object)。解决方案是把 design:paramtypes 读到的顺序与显式注入 token 对齐:
const INJECT_PARAMS = Symbol('inject_params');
function injectParam(token: symbol) {
return function (_v: undefined, context: ClassParameterDecoratorContext) {
// 收集 (构造器, index, token)
Reflect.defineMetadata(
INJECT_PARAMS,
{ ...(Reflect.getMetadata(INJECT_PARAMS, context) ?? {}), [context.index]: token },
context,
);
};
}
容器在 resolve 时,若存在 INJECT_PARAMS 元数据就优先用其中的 token 解析,否则回退到 design:paramtypes。这就是 NestJS 中 @Inject(LoggerToken) 参数装饰器的原理。
4. 与 NestJS 装饰器体系对照
4.1 类级装饰器对照
NestJS 的 @Injectable()、@Controller()、@Module() 本质上是"写元数据"的类装饰器,底层是 reflect-metadata:
// NestJS 内部的核心调用(简化)
function Injectable(options?: InjectableOptions): ClassDecorator {
return (target: Function) => {
Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, options, target);
Reflect.defineMetadata(IS_ENDPOINT_METADATA, true, target); // 标记"可被容器扫描"
return target;
};
}
| NestJS 装饰器 | 作用目标 | 底层行为 |
|---|---|---|
@Injectable() | 类 | 写入 scope 元数据,注册进 DI |
@Controller(prefix) | 类 | 写入路由前缀元数据 |
@Get() / @Post() | 方法 | 写入 HTTP 方法与路径元数据 |
@Param(key) / @Body() | 参数 | 写入参数提取元数据 |
@Inject(token) | 构造器参数 | 覆盖 design:paramtypes 的注入 token |
4.2 参数装饰器与请求映射
NestJS 的参数装饰器把"从请求里取哪一段"编码成元数据,框架在运行时拼装:
// 模拟 NestJS 的参数解析
const PARAM_METADATA = Symbol('params');
function Param(propertyKey?: string) {
return (target: object, propertyKeyMethod: string, parameterIndex: number) => {
// 旧式装饰器签名:target / method / index
const existing =
(Reflect.getMetadata(PARAM_METADATA, target, propertyKeyMethod) ?? {}) as Record<number, unknown>;
existing[parameterIndex] = { type: 'param', propertyKey };
Reflect.defineMetadata(PARAM_METADATA, existing, target, propertyKeyMethod);
};
}
class UserController {
async get(@Param('id') id: string) {
// 运行时由框架读取 PARAM_METADATA,
// 从 request.params 中取出 id 传入方法
}
}
NestJS 的 RouterExplorer 正是这样:扫描控制器类的元数据,把路由与方法绑定,再在每个参数位上注入对应的请求提取器。
4.3 从元数据到路由注册的完整闭环
一个迷你框架可以完整演示"装饰器写元数据 + 启动时读取元数据并注册":
import 'reflect-metadata';
const ROUTES = Symbol('routes');
function Get(path: string) {
return function (target: object, propertyKey: string, descriptor: PropertyDescriptor) {
const existing = (Reflect.getMetadata(ROUTES, target.constructor) ?? []) as Array<{
method: string; path: string; handler: string;
}>;
existing.push({ method: 'GET', path, handler: propertyKey });
Reflect.defineMetadata(ROUTES, existing, target.constructor);
};
}
function Controller(prefix: string) {
return function <T extends Constructor>(target: T) {
Reflect.defineMetadata('prefix', prefix, target);
};
}
// ---- 业务代码 ----
@Controller('/users')
class UserController {
@Get('/:id')
getUser(id: string) {
return { id };
}
}
// ---- 框架侧:注册路由 ----
function mountRoutes(ctrlClass: Constructor) {
const prefix: string = Reflect.getMetadata('prefix', ctrlClass);
const routes: Array<{ method: string; path: string; handler: string }> =
Reflect.getMetadata(ROUTES, ctrlClass);
for (const route of routes) {
const fullPath = `${prefix}${route.path}`;
console.log(`注册 ${route.method} ${fullPath} -> ${route.handler}`);
// router.get(fullPath, (req, res) => handler(instance, ...))
}
}
mountRoutes(UserController);
// 注册 GET /users/:id -> getUser
这个闭环就是 NestJS 路由系统的缩小版,理解它之后再读 NestJS 源码会轻松很多。
5. 标准装饰器 vs 旧式装饰器
存量代码(NestJS 7 及更早的基于 experimentalDecorators 的项目)仍在大量使用旧式装饰器,需要能读懂并迁移:
| 维度 | 旧式(experimental) | 标准(TS 5.0) |
|---|---|---|
| 启用方式 | "experimentalDecorators": true | 默认支持,无开关 |
| 参数 | (target, key, descriptor) 等 | (value, context) |
| 方法替换 | 修改 descriptor.value | 直接返回新函数 |
| 参数装饰器 | 能拿 target 与 index | 只能拿 context |
| 与 reflect-metadata | 深度集成 | 需自行 defineMetadata |
| 兼容性 | Node 12+ / 全框架 | Node 16+ / 新框架 |
tsconfig 中如果同时出现 experimentalDecorators 与标准装饰器混用,TS 会报错。迁移路径通常是:先升级到 TS 5.0,再逐文件把旧式签名改写为标准上下文签名,最后移除 experimentalDecorators。
6. 最佳实践与陷阱
6.1 生产实践清单
- 装饰器保持无状态、纯标记:不要在装饰器里执行业务逻辑,它只在类定义时运行一次;副作用逻辑放到框架的"读取元数据"阶段。
- 统一使用 Symbol 作元数据 key:字符串 key 容易与第三方库冲突。
- 给 DI 容器做循环依赖检测:
resolve时维护"解析中"栈,发现循环依赖立即抛错,否则会栈溢出。 - 单例 vs 瞬态的权衡:单例省内存但会跨请求共享状态,HTTP 场景的请求级服务应注册为瞬态或使用作用域。
- 标准装饰器下慎用
design:paramtypes:TS 5.0 标准装饰器默认不再自动生成设计时类型元数据,需要开启emitDecoratorMetadata兼容。NestJS 10+ 已对此做了适配。
6.2 常见陷阱
// 陷阱 1:装饰器求值顺序是"从下到上、方法先于类"
// 方法装饰器先执行,类装饰器后执行(若同层则从下往上)
// 陷阱 2:design:returntype 对泛型会丢失
// function fetch<T>(): Promise<T> 的 returntype 是 Promise(无 T)
// 陷阱 3:标准装饰器对属性无法替换值
// 属性在定义时尚无值,只能做元数据标记
6.3 与类型系统的协同
装饰器元编程天然与类型体操互补:用类型工具把"被装饰的类"的元数据推导出来,让框架层获得类型安全:
type ControllerMeta<T> = {
[K in keyof T as T[K] extends (...args: any[]) => any ? `on${Capitalize<string & K>}` : never]: T[K];
};
// 把控制器方法映射成带前缀的回调签名,供路由表消费
想深入了解类型层能力,可参考 https://plumephp.com/typescript-type-level-programming/;想把这些模式放入生产项目的工程结构中,可参考 https://plumephp.com/typescript-project-architecture-tsconfig/ 的 tsconfig 分层配置;NestJS 作为后端容器时,其整体工程化实践详见 https://plumephp.com/typescript-nodejs-backend/。
7. 总结
装饰器 + reflect-metadata 是 TypeScript 元编程的一对核心组合:装饰器在声明期写入元数据,reflect-metadata 在运行期读取它们。掌握这一模式,你不仅能读懂 NestJS 的路由与依赖注入机制,还能为自己的框架、库或大型应用构建类似的"声明式基础设施"。
手写一个百行 DI 容器,是理解容器化思想最好的方式——它让你看清 @Injectable() 背后真正发生的事情,而不是把框架当作黑盒。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。