TypeScript 7 原生编译器深度工程实战:从类型检查引擎内部、共享内存并发到生产级迁移路径
执行摘要:TypeScript 7.0 于 2026 年 7 月 8 日 GA,编译器主体从 TypeScript 自举实现改为 Go 原生实现(内部代号 Project Corsa,预览期二进制名为 tsgo)。官方与第三方实测给出全量构建 8~12 倍提速:VS Code 仓库 125.7s → 10.6s(11.9x),内存峰值 5.2GB → 4.2GB,编辑器打开含错误文件到出波浪线从 17.5s 降到 1.3s 以内。Anders Hejlsberg 给出的拆解是:一半来自原生代码,另一半来自共享内存并发。本文拆开这台"类型检查引擎"的四层内核——binder 符号绑定、checker 结构化类型关系判定、类型实例化缓存、并发 checker pool——解释为什么它天然吃内存、为什么 JS 实现无法并行,以及 Go 移植真正改了什么;最后给出 7.0 破坏性默认值清单与可立即落地的双轨迁移方案。
一、tsc 慢在哪里:不是算法差,是运行时天花板
很多人的直觉是"类型检查慢是因为算法不够好"。这个判断在 TypeScript 上基本是错的。TypeScript 采用的结构化类型系统(structural typing)判定逻辑十多年来没有本质变化,真正压住它的是三个运行时约束:
- 单线程。Node.js 的事件循环对 I/O 密集型友好,对 CPU 密集型的类型检查毫无帮助。旧 tsc 从进程启动到退出只用一个核,你的 16 核机器上它在跑单核满载。
- JIT 预热 + 堆压力。tsc 是 TypeScript 写的、跑在 V8 上的程序。启动要先付 JIT 热身成本,随后在整个检查过程中持续制造大量短命对象:每个表达式节点都会解析出一个
Type对象,每个泛型实例化都会新建类型实例。V8 的新生代 GC 频繁触发,最终堆里常驻的是一张巨大的类型图。 - 不可变性的缺失。旧实现里类型对象被大量就地修改(
Type.flags、symbol.links之类都是可变槽位)。这让它无法安全地在线程间共享,也就从数据结构层面堵死了并行化。
所以"重写成 Go"并不能自动得到 10 倍。如果只是把同样的代码用 Go 再写一遍,你大概只能拿到官方所说的"另一半"——原生代码带来的常数级改善。真正的乘数来自并发,而并发要求先做一次数据结构的重构。
二、类型检查引擎内部:binder → checker 的两阶段模型
理解性能,必须先理解检查器在干什么。一次 tsc --noEmit 的主干流程是固定的:
阶段一:Parse + Bind。 每个源文件解析成 AST,随后 binder 遍历 AST 建立符号表(Symbol Table)——把声明的名字映射到 Symbol,并记录作用域嵌套、模块导出关系。这一步是逐文件独立的,天然可并行,也是移植中最容易并行的部分。
阶段二:Check。 checker 按需解析类型。关键机制有三:
- 延迟类型解析(lazy type resolution)。节点的类型不是一开始就全算出来,而是在被问到时才调用
getTypeOfNode求解,结果缓存在节点上。这是典型的"惰性求值 + 记忆化"。 - 类型关系判定
isTypeRelatedTo。TypeScript 不做名义比较,而是做结构化比较:两个类型是否兼容,要递归展开成员逐一比对。为了不重复计算,checker 用关系缓存(relation cache):把(sourceId, targetId, relation)三元组作为 key,缓存在一张可能包含变体的 map 里。这是内存的主要消耗源,也是 VS Code 仓库 5.2GB 堆的去处。 - 实例化缓存。泛型、映射类型、条件类型被实例化时,用
typeId -> Type的缓存表去重,避免同一个Array<User>被构造一千次。
这三条合起来说明一件事:类型检查是一张巨大的、带缓存的、高度互连的图遍历。缓存越大越快,但内存也越高;图是全局的,所以"并行"这个词的难度不在开线程,而在让多个线程安全地读同一张图。
三、为什么"直接多线程"行不通:实例化爆炸与全局可变状态
先看一个能拖垮任何检查器的真实写法:
// 递归条件类型:深度 20 时实例化数量呈指数级增长
type DeepUnwrap<T, D extends number = 20> =
D extends 0 ? T
: T extends Promise<infer U> ? DeepUnwrap<U, Prev[D]>
: T;
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
// 再叠一层映射类型:每个字段一次实例化
type Unwrapped<T> = { [K in keyof T]: DeepUnwrap<T[K]> };
这类写法的代价不是"算一遍",而是实例化缓存被大量唯一 key 打爆:每换一个 T 就是一批新的类型实例,缓存命中率掉到接近零,内存和 CPU 同时爆掉。这也是为什么很多团队会发现"我只是加了个工具类型,怎么 CI 多了一分钟"。
更要命的是并发。旧 checker 在检查过程中会往符号和类型对象上写入缓存(可变状态)。两个线程同时检查两个文件,很可能同时写同一个共享 Symbol 的 links——这在 JS 里本来不是问题(单线程),一旦并行就直接数据竞争。这也是为什么 Go 移植不是"加个 goroutine 就完事",而是必须先做去可变状态化:把类型与符号在检查期变成逻辑上不可变的结构,让共享读变成安全操作。
从工程角度推断,本次移植的并发收益主要来自三处:
- Parse/Bind 阶段全并行——最容易切开的一层;
- checker pool:把源文件分片给多个 checker 实例,共享只读的全局类型图,各自持有线程局部的实例化缓存;
- project references 并行构建——monorepo 里多个子项目同时构建,而不是拓扑排序后串行跑。
四、Go 版的并发旋钮:checkers、builders 与 singleThreaded
暴露给用户的并发参数很直白,但值得理解它们分别切在哪一层:
# 默认 4 个检查线程;VS Code 仓库压到 8 线程可达 16.7x(7.51s)
tsc -p . --noEmit --extendedDiagnostics --checkers 8
# monorepo:并行构建 project references,与 --checkers 相乘
tsc --build --builders 8
# 调试或内存受限容器:退回单线程
tsc -p . --noEmit --singleThreaded
三者是正交的:--checkers 切的是单个编译上下文内部的检查并行度,--builders 切的是跨项目的构建并行度,--singleThreaded 是逃生舱。
配套还有两个容易被忽略的改动:
--watch重写。旧 watch 依赖 Node 的fs.watch,在大仓库(尤其 monorepo 的符号链接目录)上 notorious 地不可靠。Go 版移植了 Parcel 的跨平台文件监听实现(用 Go 重写而非引入 C++ 工具链),增量检查不再为监听机制交税。- 语言服务迁移到 LSP。编辑器侧从私有协议转向 LSP,补全、quick info、跳转定义、查找引用都受益于原生实现与并发。Canva 报告首屏错误时间从约 58s 降到约 4.8s,Slack 报告 CI 类型检查从约 7.5 分钟降到约 1.25 分钟。
五、仍然有效的四件套:10x 之后还要做的优化
原生编译器把常数压下去之后,复杂度项反而更突出了。以下四项在 7.x 时代依然是主要杠杆:
1. incremental + .tsbuildinfo。 只重算变化的文件及其下游,官方 wiki 给的经验值是后续构建省 50%~80%。10x 之后叠加增量,才是 6 分钟 → 36s → 8s 这种复合收益。
2. project references(composite)。 把 monorepo 拆成可独立构建的单元,依赖方通过 .d.ts 而非源码引用,边界稳定后缓存命中率大幅提升。注意一个坑:extends 不继承 references,所以构建图必须写在直接声明它的那份配置里。
3. isolatedDeclarations。 强制每个导出成员有显式类型标注,使 .d.ts 生成退化成近乎纯语法操作,下游包无需等上游全量类型检查即可并行生成声明。这是 monorepo 最大的结构性提速手段:
// tsconfig.base.json
{
"compilerOptions": {
"target": "es2022",
"strict": true,
"incremental": true,
"composite": true,
"declarationMap": true,
"isolatedDeclarations": true,
"verbatimModuleSyntax": true,
"moduleResolution": "bundler"
}
}
4. skipLibCheck。 应用仓库默认应开(不重复检查 node_modules 里的 .d.ts),但库作者要谨慎——它会掩盖你对外暴露类型本身的错误。
六、7.0 的破坏性默认值与双轨迁移方案
7.0 是这几年破坏性最强的一个大版本,破坏的不是类型系统,是配置默认值。必须逐条核对:
| 变更项 | 7.0 行为 | 应对 |
|---|---|---|
strict | 默认 true | 存量项目显式写全 |
types | 默认 [],不再自动引入全部 @types/* | 显式列 ["node","vite/client","vitest/globals"] |
rootDir | 默认 ./(不再从输入推断) | 显式写 "rootDir": "./src" |
module / target | 默认 esnext / 当前稳定 ECMAScript | 确认打包链兼容 |
target: es5、downlevelIteration | 移除 | 清理历史配置 |
baseUrl | 移除 | 改用 paths |
moduleResolution: node/node10/classic | 移除 | 用 bundler / node16 / nodenext |
module: amd/umd/systemjs/none | 移除 | 迁移 ESM |
esModuleInterop / allowSyntheticDefaultImports | 不可再置 false | 删除该行 |
| 模板字面量类型 | 按完整 Unicode 码位处理(不再拆 UTF-16 代理对) | 检查 emoji / 非 BMP 字符串类型 |
最隐蔽的坑是 types: []:项目能编译通过,但 describe、process、NodeJS.Timeout 突然变成未解析——因为不再隐式全局引入 Jest / Node / Vite 的客户端类型。
最大的现实约束:7.0 没有稳定的编程式 API。 旧编译器对外暴露的 Strada API(类型感知 lint、模板类型检查赖以生存)要到 7.1 才补齐。这意味着 typescript-eslint、ts-jest、ts-morph,以及 Vue 的 Volar、Svelte、Astro 的模板类型检查器,在 7.1 之前跑不在原生路径上。
因此推荐双轨并行,把速度先兑现,把风险留在 6.x:
#!/usr/bin/env bash
set -euo pipefail
# 轨道 A:快速失败反馈(非阻塞),用原生编译器
npm install -D @typescript/native-preview
npx tsgo -p . --noEmit --checkers 8 || echo "::warning::tsgo found issues"
# 轨道 B:权威结论与 emit 仍由 6.x 负责
npx tsc@6 -p . --noEmit
# 编辑器:两扩展并存,日常用 Native Preview,emit 与工具链以 6.x 为准
判定标准:如果你的瓶颈是"类型检查排队"(CI 长、编辑器出波浪线慢),立刻上双轨;如果你的瓶颈是"工具链依赖编译器 API"(大量 type-aware lint 规则、模板类型检查),等 7.1。
七、结论:编译器性能是架构问题,不是参数问题
TypeScript 7 给出了一次难得的公开样本:同一套类型系统、同一套语义,只换运行时与并发模型,就能拿到一个数量级。它反过来证明了一件事——过去十年我们为 tsc 慢所付的代价,大部分不是类型系统的复杂度,而是单线程运行时 + 可变全局状态这对组合的代价。
实践判断可以收敛成三句话:能并行的部分(解析、绑定、声明生成、跨项目构建)尽早切开;复杂度项(实例化爆炸、递归条件类型、巨型交叉类型)必须靠工程规范约束,编译器再快也救不回来;迁移节奏按"工具链依赖"而非"性能渴望"来决定。

发表评论 取消回复