本节目标:读完这一节,你能在装饰器、
Proxy、编译期转换三条 AOP 路径之间做出有依据的选择,能手写环绕通知并落地缓存、事务、重试、日志四类横切关注点,能判断哪些场景装饰器无能为力而必须动用Proxy。最后你会看到一个元数据驱动的校验器,理解「运行时类型信息」在真实框架里到底怎么用,以及它为什么永远无法等同于编译期类型。
4.3 AOP 与运行时类型信息
前两节我们分别解决了「怎么改写成员行为」和「怎么把类型带到运行时」。这一节把两者合起来,处理工程中最常见的诉求:日志、缓存、事务、重试、鉴权这些横切关注点,不应该散落在每个业务方法里。 这就是面向切面编程(AOP)。
三条路径的取舍
| 路径 | 拦截粒度 | 何时生效 | 能否新增方法 | 性能开销 | 典型使用者 |
|---|---|---|---|---|---|
| 装饰器 | 类成员 | 类定义时包装 | 否 | 每次调用一层函数 | NestJS、TypeORM |
Proxy | 对象/任意属性 | 运行时按需 | 是 | 每次访问一次陷阱调用 | MobX、Vue 3 |
| 编译期转换 | AST 任意位置 | 构建时 | 是 | 零运行时开销 | babel-plugin、ts-patch |
一句话选型:成员在写代码时已知 → 装饰器;成员在运行时才确定或需要动态新增 → Proxy;对性能极度敏感 → 编译期。 装饰器的能力边界是「只能包装已存在的成员」,Proxy 的代价是「所有属性访问都要过陷阱」,编译期的成本是「构建链路变复杂、调试断点错位」。
用装饰器实现环绕通知
AOP 术语里,最强大的是环绕通知(around advice):它能在目标方法前后插入逻辑,并决定是否调用、如何调用、返回什么。标准装饰器天然就是这个形状:
function around<This, Args extends unknown[], R>(
advice: (
invoke: (...args: Args) => R,
thisArg: This,
...args: Args
) => R,
) {
return function (value: (...args: Args) => R, context: ClassMethodDecoratorContext) {
if (context.kind !== "method") throw new Error("around 只能用于方法");
return function (this: This, ...args: Args) {
return advice(() => value.apply(this, args), this, ...args);
};
};
}
有了这个通用骨架,四类横切关注点都是几行的事。
日志与耗时:
function timed(value: Function, context: ClassMethodDecoratorContext) {
return function (this: unknown, ...args: unknown[]) {
const start = performance.now();
const result = value.apply(this, args);
const ms = (performance.now() - start).toFixed(2);
console.log(`${String(context.name)} 耗时 ${ms}ms`);
return result;
};
}
缓存(注意:只对「参数可序列化」的纯函数安全):
function cached(keyFn: (...args: unknown[]) => string) {
const store = new Map<string, unknown>();
return function (value: Function, context: ClassMethodDecoratorContext) {
return function (this: unknown, ...args: unknown[]) {
const key = keyFn(...args);
if (store.has(key)) return store.get(key);
const result = value.apply(this, args);
store.set(key, result);
return result;
};
};
}
class Pricing {
@cached((sku: unknown) => String(sku))
lookup(sku: string) {
return db.query("select price from products where sku = ?", sku);
}
}
这里有个隐蔽的坑:store 定义在装饰器工厂内,所有实例共享同一份缓存。如果方法是依赖实例状态的(比如缓存键没包含 this.userId),就会出现跨实例串数据。正确做法是把 store 换成 WeakMap<object, Map<string, unknown>>,以实例为键。
重试(结合上一节的思路,注意只在可重试错误上重试):
function retryable(times = 3, isRetryable: (e: unknown) => boolean = () => true) {
return function (value: Function, context: ClassMethodDecoratorContext) {
return async function (this: unknown, ...args: unknown[]) {
for (let attempt = 1; ; attempt++) {
try {
return await (value as Function).apply(this, args);
} catch (err) {
if (attempt >= times || !isRetryable(err)) throw err;
}
}
};
};
}
事务是最能体现 AOP 价值的场景:把「开启事务 → 执行业务 → 提交/回滚」封装起来,业务代码里只剩 SQL:
function transactional(container: Container) {
return function (value: Function, context: ClassMethodDecoratorContext) {
return async function (this: unknown, ...args: unknown[]) {
const db = container.resolve<Database>("DB");
const tx = await db.beginTransaction();
try {
const result = await (value as Function).apply(this, args);
await tx.commit();
return result;
} catch (err) {
await tx.rollback();
throw err;
}
};
};
}
注意事务对象是通过 container 显式传入的——装饰器不应该去 import 一个全局单例,那会让测试无法替换实现。依赖从外部注入是装饰器设计的第一原则。
装饰器的能力边界
装饰器只能包装已经写出来的成员。以下情况它做不到:
- 方法名在运行时才确定(动态代理、ORM 的
findByXxx系列) - 需要拦截属性读取(
obj.foo)而不是方法调用 - 需要在对象创建之后才决定拦截哪些成员
- 数组下标、
in运算符、delete等操作
这些场景必须用 Proxy。
用 Proxy 做对象级 AOP
Proxy 拦截的是操作而不是成员,粒度更细、能力更强:
function withLogging<T extends object>(target: T): T {
return new Proxy(target, {
get(obj, prop, receiver) {
const value = Reflect.get(obj, prop, receiver);
if (typeof value !== "function") return value;
return function (this: unknown, ...args: unknown[]) {
console.log(`调用 ${String(prop)}`, args);
return value.apply(this === receiver ? obj : this, args);
};
},
});
}
const svc = withLogging(new OrderService());
svc.create("A-1"); // 调用 create [ 'A-1' ]
Proxy 的三条实用规则:
this绑定要小心:get陷阱返回的函数必须显式apply到原始对象上,否则this会指向 Proxy,导致内部访问私有字段失败。Reflect是标配:用Reflect.get/Reflect.set保持默认行为,比手写obj[prop]更安全(能正确处理 getter、继承、receiver)。- 只包装需要的操作:
Proxy的陷阱有 13 个,每多实现一个就多一层开销和一处 bug 风险。默认不实现即透传。
一个真实的组合用法——用 Proxy 给容器解析出的对象统一加上「调用即记录」的能力:
container.register(OrderService, (c) =>
withLogging(resolveClass(OrderService, c)),
);
这样横切逻辑在容器层统一施加,业务类里一行装饰器都不用写。代价是类型层面 withLogging 必须声明为 <T extends object>(t: T) => T,否则 container.resolve(OrderService) 的返回类型会丢掉。
运行时类型信息:从元数据到校验
AOP 的另一半是「根据运行时信息做决策」。最典型的场景是校验:框架拿到一个对象,需要判断每个字段是否符合声明的类型与约束。
先用装饰器把约束记下来:
const RULES = Symbol("validation:rules");
interface Rule {
property: string;
type: "string" | "number" | "boolean";
min?: number;
max?: number;
}
function Rule_(
type: Rule["type"],
opts: { min?: number; max?: number } = {},
): PropertyDecorator {
return (target, key) => {
const rules: Rule[] = Reflect.getMetadata(RULES, target.constructor) ?? [];
rules.push({ property: String(key), type, ...opts });
Reflect.defineMetadata(RULES, rules, target.constructor);
};
}
class CreateUserDto {
@Rule_("string", { min: 2, max: 32 })
name!: string;
@Rule_("number", { min: 0, max: 150 })
age!: number;
}
再写一个读取元数据并执行的校验器:
function validate<T extends object>(input: T): string[] {
const rules: Rule[] = Reflect.getMetadata(RULES, input.constructor) ?? [];
const errors: string[] = [];
for (const rule of rules) {
const value = (input as Record<string, unknown>)[rule.property];
if (typeof value !== rule.type) {
errors.push(`${rule.property} 期望 ${rule.type},实际 ${typeof value}`);
continue;
}
if (rule.min !== undefined && (value as number | string) < (rule.min as never)) {
errors.push(`${rule.property} 小于下限 ${rule.min}`);
}
}
return errors;
}
validate(Object.assign(new CreateUserDto(), { name: "a", age: 200 }));
// [ 'name 小于下限 2', 'age 大于上限 150' ]
这段代码暴露了运行时类型信息的根本局限:元数据是显式声明的,不是从类型系统推导的。@Rule_("string", { min: 2 }) 里的 "string" 与字段的 : string 是两份互相独立的真相,改一处忘一处就会不一致。
因此成熟的校验库(zod、valibot、class-validator)走的是另一条路:从值本身推导类型(zod 的 z.infer<typeof Schema>),让 schema 成为唯一的真相源。这条路线的原理与取舍,我们在 10.1 类型守卫与验证库原理
里会完整展开。
性能与边界
装饰器与 Proxy 都不是零成本,量化一下:
| 手段 | 单次调用开销量级 | 主要来源 |
|---|---|---|
| 裸方法调用 | ~1x | — |
| 一层装饰器包装 | ~1.5–3x | 多一次函数调用 + apply |
Proxy 属性访问 | ~10–50x | 陷阱分派 + Reflect |
| 元数据读取 | 首次查表较慢,后续命中 WeakMap | 原型链查找 |
几点工程结论:
- 热路径(每秒百万次以上)慎用
Proxy,它比装饰器贵一个数量级。 - 装饰器包装的层数要控制。五层嵌套的环绕通知会让火焰图完全看不出业务代码。关于火焰图的读法见 8.3 性能剖析与火焰图 。
Proxy会破坏instanceof的直觉:proxy instanceof OrderService为true(因为原型链被保留),但obj === proxy为false,用对象身份做键的Map会失效。- tree-shaking 受影响:装饰器让类与元数据之间产生隐式引用,打包器可能无法判定某个类「未被使用」,需要
sideEffects显式标注。构建层面的优化见 TypeScript 构建性能优化 。
常见坑与报错对照
| 现象 | 根因 | 处理 |
|---|---|---|
this 为 undefined | 装饰器返回的普通函数被解引用后调用 | 用 addInitializer 绑定或箭头包装 |
| 缓存跨实例串数据 | 缓存表建在装饰器工厂作用域 | 用 WeakMap 以实例为键 |
Proxy 后私有字段报错 | this 指向 Proxy | apply 到原始对象 |
装饰器里 import 全局单例 | 依赖被写死,无法测试 | 从参数或容器注入 |
| 异步方法被包装后返回类型变了 | 包装函数是 async | 显式标注 Promise<T> |
| 元数据与类型声明不一致 | 两份真相源 | 改用 schema 优先的库 |
| 断点跳不进业务代码 | Proxy / 多层装饰器改变调用栈 | 用 sourcemap + 条件断点 |
第一行是最常见的:装饰器返回的函数如果是普通函数,它被 const f = obj.method 取出后调用时 this 会丢失。解决办法与 4.1 节的 @autoBind 完全一致——用 context.addInitializer 在构造时 bind。
想横向对比其他语言的 AOP 实现,可以看 Java AOP 深入 与 PHP 属性与反射 ——PHP 8 的 Attribute 与 TypeScript 装饰器在「注解即元数据」这一点上高度相似,而 Java 的字节码织入是编译期路径的典型代表。装饰器生态的更多实战可以延伸阅读 TypeScript 装饰器与元编程 。
小结
这一节把装饰器与运行时类型信息合起来,落成了 AOP:
- 三条路径:装饰器(成员级、零依赖)、
Proxy(操作级、可动态)、编译期(零运行时开销、构建复杂)。 - 环绕通知骨架:
(invoke, thisArg, ...args) => R一种形状,覆盖日志、缓存、重试、事务。 - 依赖必须注入:装饰器里不要
import全局单例,否则无法测试、无法替换。 - 元数据的局限:它是显式声明的第二份真相源,与类型声明可能不一致;schema 优先的库从值推导类型,才是可持续的路线。
- 性能量级:装饰器约 1.5–3x,
Proxy约 10–50x,热路径要谨慎。 this是高频坑:解引用后丢失绑定,用addInitializer解决。
至此第四章结束。我们走完了「标准装饰器语法 → 元数据与 DI → AOP 与运行时类型信息」这条链路,也看清了装饰器能力的边界:它能改写行为,却拿不到编译期的类型。当需要真正处理类型本身——读取 AST、理解类型节点、按类型生成代码——装饰器就完全不够了。第五章我们将打开 TypeScript 自己:从 5.1 TypeScript Compiler API 入门 开始,用程序的方式访问类型系统。
阅读导航:上一节:4.2 reflect-metadata 与依赖注入容器 · 下一节:5.1 TypeScript Compiler API 入门 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。