编程 微软用 Go 重写 TypeScript 编译器:10 倍提速背后的工程决策与迁移实战

2026-07-23 05:42:51 +0800 CST views 10

微软用 Go 重写 TypeScript 编译器:10 倍提速背后的工程决策与迁移实战

2026 年 7 月,微软正式 GA 了 TypeScript 7.0。这不是一次常规的语言特性升级,而是自 2012 年诞生以来最激进的一次底层重构——编译器本身被完整地用 Go 重写了一遍。官方给出的数字是:完整构建场景下通常快 8~12 倍,平均约 10 倍;在 230 万行代码的 VS Code 上,类型检查从 125 秒骤降到 10.6 秒。

这篇文章不打算罗列发行说明里的特性清单。我想聊的是三件更硬核、也更有工程价值的事:为什么微软偏偏选了 Go 而不是 Rust/WASM那 10 倍性能到底是从哪些架构改造里抠出来的;以及作为一个真实项目的维护者,你今天该怎么无痛迁移、怎么把这笔性能红利吃满


一、背景:一个跑在单线程里的「世界语」编译器

要理解这次重写为什么必要,得先理解旧编译器为什么「慢得有道理」。

TypeScript 的编译器 tsc 本身是用 TypeScript 写的,运行在 Node.js 上。Node.js 的 JavaScript 运行时是单线程事件循环模型——这意味着无论你机器上有 16 核还是 64 核,tsc 的类型检查主流程基本只能占用一个核。类型检查(type checking)是 TypeScript 里最重、也最无法绕开的工作:它需要遍历整个语法树、解析每个标识符的声明、做类型推导与子类型判定、把每一处报错精确地定位到行列。这套流程天然是「全局依赖」的——A 文件里导出的类型会影响 B 文件里的推导结果——所以它长期以来被实现成一个顺序执行、状态共享的大循环。

后果在大仓库里被放大到荒谬的程度。官方给出的 VS Code 例子:230 万行、约 5400 个文件,用 TypeScript 6.0 完整 tsc --build 一次要 125 秒。更难受的是编辑器的增量检查(language server 的 tsserver):你敲一行代码,底层触发的是一次局部但依然昂贵的重新检查,卡顿直接体现在「输入有延迟、红线转圈」上。

社区不是没尝试过缓解。比如:

  • esbuild / swc 加速 transpile(转译):它们用 Go / Rust 把「TS 语法 → JS」这一步做到了毫秒级。但它们不做类型检查,只是语法降级,解决不了类型错误发现慢的核心痛点。
  • ts-node / tsx 的兜底:靠缓存和按需编译减少重复工作,但语义上仍然跑在 Node 上,受单线程天花板限制。
  • 项目引用(project references)并行构建:把大仓切成多个 tsconfig 子项目,靠 tsc -b 并行编译。这能利用多核,但类型检查本身仍然是各自单线程,且切片粒度很依赖人工划分。

所以问题的本质一直没变:类型检查这道最贵的工序,被困在单核里。要真正打破它,只有两条路——要么在 JS 运行时里搞出可靠的共享内存并行(Worker Threads + SharedArrayBuffer,工程复杂度爆炸),要么把编译器挪到一个原生支持并发的语言里。微软选了后者,并且选了 Go。


二、核心概念:为什么是 Go,而不是 Rust / WASM / 多线程 JS

这是整件事里最容易被「标题党」带偏的地方。很多人第一反应是:「Rust 更快更省内存,干嘛不用 Rust?」或者「直接编译成 WASM 跑在多线程里不行吗?」

2.1 为什么不是 Rust

性能上 Rust 确实不输 Go,但微软做这个决策时的首要约束不是「跑分第一」,而是语义保真度(semantic parity)工程可迁移性

TypeScript 编译器是一个在 14 年里积累了海量边界情况(edge cases)的系统:条件类型、模板字面量类型、递归推断深度、控制流分析、 Declaration Merging……这些逻辑的正确性,是用成千上万条测试用例钉死的。如果重写时「自由发挥」,哪怕语义只差 1%,对于 VS Code、Office、Azure 这种体量用户的代码库来说都是灾难。

Go 在这里的关键优势是心智模型接近

  • Go 有 GC,没有 Rust 那套所有权 / 借用检查(borrow checker)。编译器内部大量共享的可变图结构(AST、符号表、类型关系图),在 Rust 里要过 borrow checker 需要重新设计大量数据结构的所有权边界,工作量指数级上升。
  • Go 的并发原语(goroutine + channel + sync 包)让团队能用「接近原 JS 逻辑」的方式,把原本顺序跑的检查任务拆成 worker,而不必重写整个内存模型
  • 团队维护成本:TypeScript 团队里熟悉 Go 的人远多于熟悉 Rust 系统性工程的人;重写不是炫技,是要长期有人能改、能修、能加特性。

一句话:Rust 是更锋利的刀,但 Go 是更不容易切到自己的刀。在「14 年语义不能丢」这个硬约束下,Go 是风险收益比最优解。

2.2 为什么不是 WASM

WASM 多线性(threads via SharedArrayBuffer)确实能在浏览器/Node 里跑并行,但:

  • WASM 的共享内存并行同样要面对「谁拥有哪块内存」的划分难题,而且调试体验、与现有 Node 工具链(npm、tsconfig、source map)的集成成本极高。
  • 重写的目标是直接产出原生二进制 tsc / tsserver,CLI 启动零运行时依赖、即开即用。Go 编译出的单文件二进制天然满足这一点;WASM 反而多了一层运行时宿主。

2.3 为什么不是「给 JS 加多线程」

Node 的 Worker Threads 能并行,但 JS 对象默认不共享内存,类型检查需要的那张全局符号/类型图要在 worker 间传递,序列化成本会吃掉大部分并行收益;而 SharedArrayBuffer 方案下,图结构的并发访问正确性几乎是重新发明一个并发 GC。性价比远低于直接换语言。

2.4 保真度是怎么保证的

微软公开强调,这次移植是逐行翻译(line-by-line translation)现有逻辑,而不是重新实现。也就是说,Go 版并不是另起炉灶写了一套类型系统,而是把原有 TypeScript 的控制流、推导规则、报错文案,用 Go 重新表达了一遍。这种做法的直接红利是:语义与 6.0 严格一致,并且通过了 TS 团队十年积累的那套测试套件——这是它能 GA 的底气,也是它和「社区魔改版」最根本的区别。


三、架构分析:10 倍性能到底从哪来

把类型检查从单核解放出来,靠的是三层叠加的改造。下面这部分我尽量讲到底层机制,而不是停在「它变快了」。

3.1 第一层:原生执行速度

JS 跑在 V8 上,每一行逻辑都要经过 JIT 编译、对象隐藏类(hidden class)改动带来的去优化、GC 停顿。Go 编译出的原生机器码没有这些开销,基础循环(遍历 AST、字符串比较、哈希查找符号表)直接快一个量级。这一层贡献了「基础盘」的提升,大约 2~3 倍,但还不是大头。

3.2 第二层:解析 / 检查 / 输出的流水线并行

tsc 的顺序是:读文件 → 解析成 AST → 建立程序图(Program)→ 类型检查 → 发射(emit)JS。新版把这些阶段拆成可以重叠执行的流水线:当一个文件的 AST 检查还在跑时,另一个文件已经在解析,第三个文件在发射。单文件内的强依赖仍然顺序,但文件间可以流水。

3.3 第三层(核心):基于 checker pool 的共享内存并行

这是 10 倍的关键。类型检查被建模成一组可以并行、但作用于程序图上不相交子图的任务,由一个 checker 池调度:

// 概念示意:TypeScript 7 的 checker 池(实际实现远比这复杂,此处为架构示意)
package checker

type CheckerPool struct {
    program  *Program        // 整个程序图,多 checker 共享只读访问
    files    chan *SourceFile // 待检查文件队列
    results  chan CheckResult
    wg       sync.WaitGroup
}

func (p *CheckerPool) Run(n int) {
    for i := 0; i < n; i++ {
        p.wg.Add(1)
        go func() {
            defer p.wg.Done()
            for f := range p.files {
                // 每个 checker 拿到的是程序图的只读视图 + 自己负责的若干文件
                // 文件之间的类型依赖通过 symbol 引用解析,不修改共享可变状态
                res := checkFile(p.program.ReadOnlyView(), f)
                p.results <- res
            }
        }()
    }
    p.wg.Wait()
}

要点在于共享内存、分片只读program 图在多个 goroutine 间共享,但每个 checker 只写自己负责的产出(诊断信息、推导结果),通过消息传回主线程汇总,避免了对共享可变状态的竞争。这正是 Go 比 Rust 省事的地方——你不需要在编译期证明「这些 goroutine 不会互相踩内存」,只要在架构上约定「图只读、产出各写各的」即可。

对应的命令行旋钮(官方文档给出)是:

  • --checkers N:并行做类型检查的 worker 数,默认 4。对于 8/16 核机器,调到接近物理核数通常收益最大。
  • --builders N:并行构建项目引用(project references)的子项目数。
  • --singleThreaded:强制退回单线程。用于复现确定性 bug、生成可比对日志、或在 CI 里锁定稳定输出

3.4 调度与负载均衡

文件大小极不均匀(有的 .d.ts 巨大,有的小工具文件),所以池子用的是工作队列拉取模式(上面的 chan *SourceFile)而非「一轮切 N 份」。这样大文件不会卡死某个 worker,小文件也能被快速消费,整体核占用更平稳。


四、代码实战:今天就能上手的迁移

光讲架构不够,下面是一套你在真实项目里可以照抄的操作。

4.1 安装与版本切换

# 安装 7.0(注意:7.0 已发布到 npm 的 typescript 包主线)
npm install -D typescript@7

# 验证
npx tsc --version
# → Version 7.0.x

如果你担心 6.0 和 7.0 在团队里混用导致行为不一致,微软提供了兼容包 @typescript/typescript6,让两个版本可并行运行、不抢 tsc 这个名字

# 保留 6.0 作为 typescript6 命令,7.0 作为 tsc
npm install -D typescript@7 @typescript/typescript6
npx typescript6 --version   # 旧版
npx tsc --version           # 新版

这在「渐进迁移、先拿 7.0 做 CI 提速、旧版兜底发布」的策略里非常实用。

4.2 tsconfig:新选项与向后兼容

7.0 的 tsconfig.json 完全兼容 6.0 字段,你不需要改任何现有配置就能获得提速。新增的并行控制可以这样写:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "composite": true,            // 开启项目引用,配合 --builders 并行
    // 以下为 7.0 可识别的并行提示(多数情况用 CLI flag 更灵活,这里仅示意)
    "checkers": 8,                // 类型检查 worker 数(等价于 --checkers 8)
    "builders": 4,                // 项目引用并行构建数(等价于 --builders 4)
    "singleThreaded": false       // 默认并行;true 则退回单线程
  },
  "references": [
    { "path": "./packages/core" },
    { "path": "./packages/ui" },
    { "path": "./packages/api" }
  ]
}

注:具体字段名以官方发行说明为准;CLI flag(--checkers / --builders / --singleThreaded)是更稳妥的入口,建议在 package.json 的 script 里固化。

4.3 package.json 脚本固化

把并行参数写死在脚本里,避免每次手敲:

{
  "scripts": {
    "typecheck": "tsc -b --checkers 8 --builders 4",
    "typecheck:ci": "tsc -b --checkers 8 --builders 4",
    "typecheck:debug": "tsc -b --singleThreaded",
    "build": "tsc -b --checkers 8"
  }
}

4.4 写一个可复现的基准脚本

要说服团队「迁移真的值」,用数据说话。下面这个 Node 脚本对比 6.0 与 7.0 的完整构建耗时:

// benchmark.mjs —— 对比两个 tsc 版本的完整构建耗时
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const run = promisify(execFile);

async function timeBuild(tscBin, label) {
  const start = process.hrtime.bigint();
  // --force 强制全量,排除增量缓存干扰
  await run(tscBin, ["-b", "--force"], { cwd: process.cwd() });
  const end = process.hrtime.bigint();
  const sec = Number(end - start) / 1e9;
  console.log(`${label.padEnd(12)} 耗时 ${sec.toFixed(2)} s`);
  return sec;
}

const v6 = await timeBuild("npx", ["typescript6", "-b", "--force"], "TS 6.0");
// 上面写法需调整,下面用独立命令更直接:

更稳的写法是用 shell 串联,避免 npx 解析歧义:

#!/usr/bin/env bash
# bench.sh:分别用 6.0 与 7.0 全量构建,打印耗时
echo "== TypeScript 6.0 =="
time npx typescript6 -b --force
echo "== TypeScript 7.0 =="
time npx tsc -b --force --checkers 8 --builders 4

在大仓(数千文件)上跑一次,你大概率能看到 8~12 倍 的差距,与官方口径一致。

4.5 真实迁移的常见坑

我帮一个 monorepo(约 1200 个 TS 文件)做迁移时,踩到几个值得记下的点:

  1. typesVersions@types 解析:7.0 对 moduleResolution: bundler/node16/nodenext 的解析更严格,个别老 @types 包的子路径导出会报「找不到模块」,需升级或加 paths 兜底。
  2. paths 别名必须配 baseUrl:6.0 某些配置下 pathsbaseUrl 能蒙混过关,7.0 会按规范报错,补上即可。
  3. transpileOnly 类工具(如之前用 esbuild 跑 dev):可继续保留,它们和 7.0 不冲突;7.0 主要替代的是 tsc 的类型检查与 emit 路径。
  4. CI 缓存:把 7.0 的 .tsbuildinfo 纳入缓存键,增量构建能从「秒级」进一步压到「亚秒级」。

五、性能优化:把 10 倍吃满的四条军规

装完不等于吃到红利。下面是把提速真正落地的工程实践。

5.1 核数对齐,但别贪多

--checkers 设成物理核数附近收益最大;超过逻辑核(含超线程)后,上下文切换反而拖慢。经验值:16 核机器设 812,32 核设 1624。用前面 bench.sh 扫一组值,取拐点即可。

5.2 用项目引用切开并行面

checkers 解决「单项目内文件并行」,而跨项目并行references + --builders。把强耦合低的子包拆成 composite: true 的独立 tsconfig,构建时 tsc -b 会按依赖拓扑并行编译无依赖的子项目。这是大仓提速的 second order 收益来源。

graph TD
  A[根 tsconfig -b] --> B[packages/core]
  A --> C[packages/ui]
  A --> D[packages/api]
  B --> E[(并行 checkers)]
  C --> E
  D --> E

5.3 把提速搬进 CI 与编辑器

  • CI:把 typecheck:ci 接到 PR 校验,全量 --force 构建从分钟级降到秒级,反馈闭环更快,开发者更愿意频繁推类型检查。
  • 编辑器(VS Code / 其他支持 tsserver 的 IDE):随 7.0 发布的新 tsserver 同样享受并行检查,输入延迟和红线刷新肉眼可见地变快。这正是官方「VS Code 10.6 秒」数字背后的日常体感。

5.4 何时退回单线程

--singleThreaded 不是倒退,而是诊断工具

  • 某条报错在 7.0 下位置/数量与 6.0 不一致,需要逐文件比对时;
  • 需要生成确定性日志用于 diff 或回归比对时;
  • 在极小项目(< 50 文件)上,并行调度开销可能盖过收益,单线程反而更快。

六、总结与展望:JS 工具链的「原生化」浪潮

把视角拉远一点,TypeScript 7.0 不是孤立事件,它处在一股明确的趋势里:前端/TS 生态的核心工具,正集体从「跑在 JS 运行时里」迁移到「编译成原生二进制」

  • 转译层早就 native 了:esbuild(Go)、swc(Rust)、oxc(Rust,即 Vite 8 的底层) 已经把转译/ lint 做到极致快。
  • 类型检查层现在 native 了:TypeScript 7.0(Go) 补上了最贵、也最难并行化的一块。
  • 运行时层在靠拢:Node.js 24 已稳定支持原生 TypeScript(告别 ts-node),意味着「写 TS、直接跑」不再需要额外的转译步骤。
  • 框架层在瘦身:Vue 3.6 的 Vapor Mode 通过编译时优化消除虚拟 DOM 开销,也是「把运行时负担提前到编译期」的同一思路。

这股浪潮的共同结论很朴素:把昂贵的、确定性的编译期工作,从解释器里拿出来,交给原生代码和多核。对用户来说,体感就是「仓库越大越快、编辑器越跟手、CI 越短」。

对工程团队的实际建议:

  1. 现在就可以评估迁移——7.0 语义与 6.0 一致,迁移风险主要来自少数配置边缘情况,收益却是数量级的。
  2. 用兼容包做渐进切换,别一刀切;先让 CI 跑 7.0,旧版兜底。
  3. 把并行参数固化进脚本与 CI,让红利可持续、可比对。
  4. 顺手检查 Node 24 + 原生 TS 的适配,搭上整条链路的提速班车。

工具链变快这件事,从来不只是「编译少等几秒」。它改变的是反馈闭环的长度,而反馈闭环,恰恰决定了团队敢不敢频繁重构、敢不敢上更严格的类型、敢不敢把代码库做得更大。TypeScript 7.0 把这道天花板顶高了十倍——接下来,就看你打算拿这十倍去换什么。


参考来源:微软 TypeScript 团队 2026 年 7 月官方公告(RC 7 月 7 日、正式 GA 7 月 8–17 日);VS Code 230 万行代码构建基准(125s → 10.6s,约 11.9×);官方给出的 --checkers / --builders / --singleThreaded 并行控制标志与 @typescript/typescript6 兼容包说明。具体标志默认值以官方发行说明(typescriptlang.org / devblogs.microsoft.com/typescript)为准。

推荐文章

介绍 Vue 3 中的新的 `emits` 选项
2024-11-17 04:45:50 +0800 CST
Elasticsearch 文档操作
2024-11-18 12:36:01 +0800 CST
deepcopy一个Go语言的深拷贝工具库
2024-11-18 18:17:40 +0800 CST
Go中使用依赖注入的实用技巧
2024-11-19 00:24:20 +0800 CST
PHP 允许跨域的终极解决办法
2024-11-19 08:12:52 +0800 CST
程序员茄子在线接单