编程 AccessGuard 类型缓存治理:tsc --noEmit 3s→45s→8s,编辑器 5s→300ms

2026-10-01 00:04:48

AccessGuard 类型缓存治理:tsc --noEmit 3s→45s→8s,编辑器 5s→300ms

现场:编译墙如何形成

AccessGuard v2.4 的权限体系里,权限类型从 20 个膨胀到 100+,策略模板从 5 个增长到 50+,Monorepo 子包从 3 个扩展到 20 个。tsc --noEmit 从最初的 3 秒升到 45 秒,编辑器红色波浪线延迟超过 5 秒,CI 中最慢的环节变成类型检查。类型系统从生产力工具变成瓶颈,这是大项目会撞上的编译墙。

即使 TypeScript 7.0 带来原生级性能,类型复杂度优化仍然是根本性工程实践:无论编译器多快,O(2ⁿ) 的泛型展开终究会触达硬件极限。用编译器友好的方式写类型,是长期生效的能力。

类型计算复杂度与三处缓存

TypeScript 类型计算可以从时间复杂度、空间复杂度、结构复杂度三个维度看。联合类型成员数量为 N 时,分配条件类型计算复杂度至少是 O(N)。Permission 联合类型包含 127 个成员,任何对 Permission 的条件类型操作都可能触发 127 次独立计算。

tsc 的类型缓存有三个核心:Type Cache(类型实例化结果的内存缓存)、Assignability Cache(“A 是否可赋值给 B”的缓存)、结构比较缓存。命中率直接决定编译性能。优化前约 62%,健康水平在 85%+。大量动态生成的映射类型和条件类型无法命中缓存。

缓存本质是去重哈希表:结构等价的类型只计算一次。但有三类模式命中不了:

  1. Fresh Object:每次字面量对象类型都是“新鲜的”,结构相同也无法命中。
  2. 条件类型的分配计算:对联合类型做条件类型分配时,需要为每个成员单独构造类型实例。
  3. 递归类型的深度展开:每次展开到不同深度产生不同中间类型,进一步稀释缓存。

优化方向是减少“不可缓存”的类型操作,提高结构等价类型的复用率。

诊断先行:--generateTrace 看三个指标

不要先改类型。运行 --generateTrace 生成 trace 数据,再用 @typescript/analyze-trace 分析。

tsc --noEmit --generateTrace trace
npx @typescript/analyze-trace trace

关键指标:

  • Check time:过高说明类型复杂度(泛型、递归)到瓶颈。
  • I/O time:过高说明 paths 配置或文件索引量过大,磁盘扫描阻塞。
  • Types created:创建类型数量达到数十万级别,类型系统已经过度设计。

治理手段

类型写法瘦身

  • 避免过度递归:一个类型需要递归 5 层以上才能推导出结果,考虑用 interface 替代 type。interface 在 TS 内部有更好的缓存机制。
  • 限制联合类型规模:巨大的联合类型会强制编译器全量遍历,尽量拆成多个小接口。
  • 显式标注返回类型:在复杂函数上标注返回类型,切断推导链,防止编译器在整个调用栈上深层搜索。
  • 优化 paths 解析:过多通配符 * 会让模块解析器在文件系统中做大量 I/O 扫描,尽量使用精确路径映射。
  • 开启 isolatedModules:强制每个文件可以独立转译,是使用 esbuild 等工具的前提。

tsconfig 常用优化项:target: esnext(避免不必要的 ES5 转换)、module: esnext、skipLibCheck: true(跳过库文件类型检查)、allowJs: false、isolatedModules: true、incremental: true、tsBuildInfoFile;include 精确指定源文件范围,exclude 排除 node_modules 与测试文件。

Project References 与增量编译

Project References 是 TypeScript 3.0 引入的官方 Monorepo 编译方案。核心是把一个大项目拆成多个独立 TypeScript 项目,各自拥有 tsconfig.json,编译时只重新编译发生变化的子项目。大型 Monorepo 必须开启 composite: true。

增量编译用 --incremental 与 .tsbuildinfo 文件缓存上次编译状态,tsBuildInfoFile 指定缓存路径。AccessGuard 最终把编译时间从 45 秒压到 8 秒,编辑器响应从 5 秒降到 300 毫秒,缓存命中率从 62% 提到 85%。

转译与检查解耦

tsc 是运行在 Node.js 上的单线程程序。无论机器有多少核心,类型检查这个最耗时的环节始终在单核上阻塞。当写下 T extends U ? A : B,且 T、U 是数千个成员组成的联合类型时,TS Server 必须在内存中构建庞大的决策树。如果结构递归,计算复杂度会从线性增长演变为指数级增长,直接导致 IDE 的 Intellisense 卡死。

转译是把 TS 转成 JS 的 AST 节点替换过程,不需要关心类型是否正确;类型检查需要扫描整个项目的所有定义。强耦合意味着每次保存文件,都要为毫无变化的类型定义支付计算成本。开发环境停止使用 ts-loader 或 tsc 构建,用 tsx 或 esbuild,把热更新从秒级降到 50ms 以内。类型安全交给 CI/CD 或独立后台进程。在 Webpack 或 Vite 环境中,使用 fork-ts-checker-webpack-plugin,它会启动独立 Node.js 进程执行 tsc --noEmit。

可复用结论

  • 编译墙来自联合类型规模、递归泛型、不可缓存类型操作,以及转译与类型检查强耦合。
  • 先度量:--generateTrace + @typescript/analyze-trace,看 Check time、I/O time、Types created。
  • 提高缓存命中率:减少 Fresh Object、条件类型分配、递归深度展开;拆小联合类型,复杂递归用 interface,显式返回类型,精确 paths。
  • Monorepo:Project References + composite: true + --incremental + .tsbuildinfo/tsBuildInfoFile。
  • 开发环境解耦:tsx/esbuild 或 fork-ts-checker-webpack-plugin,开启 isolatedModules。
  • AccessGuard 的量化结果:tsc --noEmit 3s→45s→8s,编辑器 5s→300ms,缓存命中率 62%→85%,HMR 秒级→50ms,联合类型成员 127 个。

推荐文章

程序员茄子在线接单