《TypeScript高级编程》4.3 AOP 与运行时类型信息

本节讲面向切面编程在 TypeScript 里的三种实现路径:装饰器、Proxy、编译期转换,给出各自的适用边界与性能代价。随后用装饰器实现环绕通知,落地缓存、事务、重试、日志四类横切关注点;再用一个元数据驱动的校验器说明运行时类型信息的真实用法,并列出各自的坑。

本节目标:读完这一节,你能在装饰器、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 的三条实用规则:

  1. this 绑定要小心:get 陷阱返回的函数必须显式 apply 到原始对象上,否则 this 会指向 Proxy,导致内部访问私有字段失败。
  2. Reflect 是标配:用 Reflect.get / Reflect.set 保持默认行为,比手写 obj[prop] 更安全(能正确处理 getter、继承、receiver)。
  3. 只包装需要的操作: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 指向 Proxyapply 到原始对象
装饰器里 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 入门 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes