ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

TypeScript 7 原生编译器实战:Go 重写带来的性能飞跃与迁移指南

2026/9/19 11:36:45 拓冰建站 浏览量
TypeScript 7 原生编译器实战:Go 重写带来的性能飞跃与迁移指南 最近 TypeScript 7 算是彻底出了圈不光技术社区在聊连我们这种日常写业务代码的也在追。起因大家应该都知道了微软官方公布下一个大版本会用 Go 重写整个编译器代号 Project Corsa目标是把类型检查和编译速度提升一个数量级。我平时维护的项目里有几个老仓库的类型检查已经慢到每次保存都要等上十几秒所以这个消息对我不是话题热度而是实打实的刚需。从官方放出 native preview 包开始我就在新项目里从零到一搭建 TS7 工程踩了不少预览期的坑也沉淀出一套可以照抄的流程。这篇就把整个创建过程讲清楚环境怎么准备、项目怎么初始化、tsconfig 怎么配、日常开发工作流怎么改以及从 5.x 迁移时会撞上的几个典型问题。1. TypeScript 7 到底改了啥引擎重写背后的性能账本先把概念对齐TS7 不是一个像 2.x 到 3.x 那样靠新语法拉动的版本它最大的变化发生在编译器的实现层。理解这一点后面所有配置决策才说得通。1.1 为什么非要用 Go 重写编译器TypeScript 编译器本身是用 TypeScript 写的跑在 JS 引擎里。它做类型检查的时候要先做词法分析、语法分析、生成 AST再建立整个项目的语义模型最后在内存里维护一张巨大的程序图——每个文件、每个符号、每一条类型引用关系都在这张图里。项目一大这张图就非常吃内存和 CPU。JS 引擎的 JIT 每次启动tsc都要重新预热对象模型又远比紧凑的结构体松散垃圾回收的压力也大。一个 50 万行以上的仓库全量检查跑几十秒甚至几分钟在大厂 monorepo 里几乎是日常。微软团队自己也承认继续在 JS 实现上做局部调优边际收益已经很有限。类型检查器的性能瓶颈已经不只是算法复杂度问题而是运行时模型问题。与其继续在 JS VM 里打转不如整个换成一个更可控的原生运行时。所以 TS7 不是给 5.x 打个补丁而是把编译器从 JS 移植到 Go重新实现对语言语义的完整解析和类型检查。1.2 原生编译器快在哪几个环节用 Go 写的原生编译器快在四个层面。第一是启动速度。tsc每次运行都要等 JS 引擎加载脚本、完成 JIT 预热相当于每次查资料都要先把书架从仓库搬到书房Go 编译出来的静态二进制直接执行没有这一层预热成本。第二是内存布局。Go 的结构体在内存里是紧凑排列的相比 JS 对象模型有更好的缓存友好性遍历一个大项目的符号表时Cache Miss 会少很多。第三是并行能力。Go 的 goroutine 模型在处理大量独立文件的类型检查时能更自然地充分利用多核 CPU而 JS 实现要在单线程模型和 Worker 之间做复杂取舍。第四是整体内存占用。官方演示里某些在tsc下需要吃好几个 GB 内存的仓库原生编译器会明显更省。官方公开的演示数据里一个大型内部仓库的全量类型检查从 10 秒量级进入 1 秒内微软博客的大致说法是 10 倍左右部分场景更高。我自己在中等规模项目里观察到的提升普遍在 5 到 15 倍越大的仓库收益越明显。这里也提醒一句如果你的项目只有五六个文件、几十行类型检查这些提速对你来说基本无感TS7 的爽点在大项目里才真正体现。1.3 版本号是 7 而不是 6语义没有破坏性变化这里有个有意思的设计为什么直接跳到 7而不是叫 6官方的说法是6 的版本号在社区直觉里总是和一些破坏性变更绑定在一起而 TS7 不打算这么做。这一代的目标是换发动机不换车型——语言语法、类型规则、绝大多数 tsconfig 选项都保持兼容你要写代码的方式和 5.x 几乎完全一样只是检查得更快、构建得更快。所以如果你已经熟悉 5.x完全不需要担心 TS7 会推倒重来。它的兼容性承诺是完整的同一个.ts文件在 5.x 下什么语义在 7 下就是什么语义。我把两个版本的差异整理成一张表方便对照对比维度TypeScript 5.xTypeScript 7编译器本体TypeScript 编写Node 运行Go 编写原生二进制启动与检查速度性能基线官方演示约 10 倍量级提升语言语法语义当前基线兼容无计划内破坏性变更tsconfig 兼容性当前基线目标兼容预览期部分选项未对齐构建模式--build成熟预览版支持滞后编辑器语言服务JS 版 tsserver原生语言服务仍在推进过渡期常混搭2. 动手前的环境清单Node 版本、安装命令与编辑器语言服务创建 TS7 项目的环境准备比想象中要简单但有几个细节如果不注意很容易在第一步就劝退。2.1 版本选择与安装命令先说你本机需要装什么。Node.js 是必须的建议直接上 20 LTS 或 22 LTS。这里有个容易误解的点tsgo这个原生编译器本身是二进制文件理论上不依赖 Node 运行但你的项目还要用 npm 装依赖、用 Node 跑编译产物、依赖整个前端/后端生态所以 Node 一定得装。在当前目录下建好项目文件夹用 npm 初始化mkdir ts7-demo cd ts7-demo npm init -y然后安装依赖。我推荐的组合是三个包一起装npm install -D typescript5 typescript/native-preview types/node有人可能会问都上 TS7 了为什么还要装typescript5原因很现实当前阶段原生编译器以预览包的形式发布很多工具链、编辑器插件、以及 tsconfig 的 schema 提示仍然默认认typescript这个包。把 5.x 装一份一个是给 IDE 的语言服务当稳定的后备另一个是让你随时可以用npx tsc做对比验证。typescript/native-preview里带的就是tsgo命令也就是 TS7 的原生编译器本体。types/node则提供 Node 内置模块的类型声明写服务端代码基本离不开。2.2 验证安装与常用版本查看装完之后先验证一下两个命令是否都能跑npx tsgo --version npx tsc --version正常情况下npx tsgo --version会输出一个类似tsgo-x.y.z的版本号npx tsc --version输出 5.x 的版本号。如果列出来的版本和你安装的对不上多半是 npm 缓存或者全局命令干扰删掉node_modules重装一次就好。预览期的tsgo参数列表变化比较快想要看当前版本支持哪些参数直接跑npx tsgo --help这个命令在后续排坑时非常有用。很多时候你从网上搜到一个 5.x 的旧参数在tsgo里已经不生效或者改了名字--help永远是最新的真相。2.3 编辑器语言服务现阶段不要指望和编译器一样快这里要先打一针预防针命令行里tsgo再快你的编辑器中间可能还是旧的 JS 版语言服务。TS7 的原生语言服务是一整套工程官方在逐步推进但在相当长一段时间里你会遇到命令行 0.8 秒跑完VS Code 里悬停提示还要转圈半秒的反差。这很正常不是装错了。我的做法是在.vscode/settings.json里把 TypeScript 的语言服务明确指向本地的 5.x 稳定包{ typescript.tsdk: node_modules/typescript/lib }然后在 VS Code 的选择 TypeScript 版本里选使用工作区版本。这样编辑器的诊断、跳转、悬停都走稳定的 JS 实现命令行编译、CI 检查走tsgo两者各司其职。等官方原生语言服务真正落地再把tsdk指向原生实现也不迟。最后别忘了建一个.gitignore把node_modules/、dist/、*.tsbuildinfo都忽略掉。.tsbuildinfo是旧版增量编译的产物TS7 虽然走内存图不一定会生成但留着这个规则对两种编译器都安全。3. 初始化项目与 tsconfig 核心配置一份能直接落地的样板环境准备完毕接下来就是从零到一的核心部分把项目的骨架搭起来把 tsconfig 配好然后让第一个 TS 文件跑起来。3.1 最小目录结构我建议最小项目用这样的结构ts7-demo/ ├── src/ │ ├── types.ts │ └── main.ts ├── dist/ ├── tsconfig.json ├── package.json └── .gitignoresrc放源码dist放编译产物tsconfig.json管类型检查和输出路径。按这个结构起步后面不管是往 CLI 工具方向扩展还是接一个前端框架都不会有太大改动。3.2 tsconfig 样板逐项说明关键开关这是我在新项目里默认使用的一份 tsconfig你可以直接复制到项目根目录{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, rootDir: src, outDir: dist, strict: true, declaration: true, sourceMap: true, skipLibCheck: true, verbatimModuleSyntax: true, erasableSyntaxOnly: true, noUncheckedIndexedAccess: true, noImplicitOverride: true, forceConsistentCasingInFileNames: true }, include: [src] }重点解释几个值得注意的开关。strict是 TypeScript 的总闸不解释任何新项目都应该开这是共识。target: ES2022决定输出 JS 的语法基线Node 20 以上跑 ES2022 没问题如果你要兼容更老的运行环境再往下降。module: NodeNext和moduleResolution: NodeNext是一对面向编译产物直接丢给 Node 运行的场景。这个配置的代价是相对导入必须写完整扩展名比如import { x } from ./utils.js。很多前端出身、习惯 Bundler 解析的人第一次见会不适应但它在 Node 生态里是最不折腾的路线。verbatimModuleSyntax: true要求 import 怎么写、输出就怎么保留不会让编译器擅自把import转成require。配合 NodeNext 的 ESM 场景能避免一堆运行时模块格式错误。erasableSyntaxOnly: true是最有 TS7 味道的一个选项。它要求源码里只使用可以直接擦除的语法——类型注解、interface、type 别名这些在编译时删掉完全不影响运行而 enum、namespace、构造器参数属性这类擦掉类型之后还要生成一段运行时代码的语法会被编译器直接报错。提前用这个约束写代码将来换原生编译器、换任何只做类型擦除的工具链摩擦都是最小的。后面第五部分我会专门用一个完整案例讲它。noUncheckedIndexedAccess: true会让你访问数组元素或对象索引时类型多带一个| undefined。比如arr[0]的类型从T变成T | undefined逼你把越界和空值的边界处理好。这个选项刚开的时候报错会有点多但非常值。skipLibCheck: true跳过node_modules里 .d.ts 声明文件内部的类型检查提速明显。声明文件是你安装的依赖包的接口描述它们内部偶尔会有一些类型上的小瑕疵跳过检查不会影响你的项目安全。剩下几个相对直白declaration生成.d.ts声明文件如果你要发布 npm 包必须开只是写内部脚本可以关掉。sourceMap方便调试。noImplicitOverride要求继承时覆盖父类方法必须写override关键字防止覆盖时拼错方法名导致静默调用不了父类逻辑。forceConsistentCasingInFileNames强制 import 的文件名大小写和磁盘一致跨平台项目必备否则在 Linux 的 CI 上随时可能炸。3.3 第一个源文件与首次编译src/types.ts里先放一些简单的类型声明export interface Todo { id: number; title: string; done: boolean; tags: string[]; } export function createTodo(title: string): Todo { return { id: Date.now(), title, done: false, tags: [], }; }src/main.ts里写入口逻辑import { createTodo, type Todo } from ./types.js; const nextTodo: Todo createTodo(写一篇 TypeScript 7 实战记录); console.log(nextTodo); function summarize(list: Todo[]): string { const done list.filter((item) item.done).length; return 完成了 ${done} / ${list.length} 项; } const todos: Todo[] [nextTodo]; console.log(summarize(todos));注意第一行import { createTodo, type Todo } from ./types.js。文件明明叫types.ts为什么导入要写.js后缀这是 NodeNext 模块解析的硬性要求Node 的 ESM 加载器不会自作主张帮你猜扩展名所以编译输出之后实际加载的文件就是types.js源码里的相对导入必须对准输出后的名字。TypeScript 自己会找到对应的.ts源文件这个习惯养成之后就不会在模块解析上浪费时间了第 5 节我会再展开排坑。执行编译npx tsgo正常的话dist目录下会多出types.js、main.js以及对应的.d.ts和.map文件。跑一下编译产物node dist/main.js然后直接执行只做类型检查的命令npx tsgo --noEmit如果没有任何输出说明类型检查全部通过。第一次用tsgo的人常常会以为没执行成功因为真的太快了——没有输出就是好消息这一点和旧版tsc的习惯一样。4. 日常开发工作流watch 模式、noCheck 转译和 Vite 集成项目能跑起来之后真正决定日常体验的是工作流。TS7 在这一块带来的变化比单纯编译快了要深一层。4.1 把 watch 模式当成默认选项开发期建议直接跑npx tsgo --watchwatch 模式下编译器会把内存里的程序图常驻只对依赖变更的文件做重算。打个比方旧版 5.x 是每次保存文件重新过一遍全流程安检TS7 的原生 watch 更像给每个人发工牌进门刷卡就行只有新增行李才会查。之前用 5.x 的增量编译会依赖.tsbuildinfo文件本质上是在磁盘上做缓存tsgo的做法则是把整个程序图留在内存里配合原生编译器对多核的利用大项目里保存之后到类型诊断出来延迟几乎可以忽略。这也是我建议开发默认挂着 watch的原因既然它足够快你就没必要在改完代码之后才手动跑一次完整检查。如果你只是偶尔想看一次结果不用 watchnpx tsgo --noEmit一两秒就完成了。4.2 noCheck 与类型检查分离的构建思路TS7 原生编译器带了一个非常有意思的参数--noCheck。这个参数的核心思想是把类型检查和把类型擦掉、生成 JS这两件事彻底分开。npx tsgo --noCheck加了--noCheck之后编译器不会建立完整的类型检查模型只是把源码里的: type、interface、type这些可擦除语法删除输出可运行的 JS。速度自然更快。这么做安全吗分场景看。在开发服务器上前端构建链路本来就更看重快速把 TS 转成 JS 让浏览器能跑完整的类型检查可以在 CI 或 pre-commit 阶段补上。所以我在package.json里的脚本通常这样配{ scripts: { dev: tsx src/main.ts, check: tsgo --noEmit, build: tsgo node dist/main.js } }check脚本只在需要时跑完整类型检查build脚本正常编译产物。始终记住一条原则--noCheck不是类型检查的替代品它是构建流水线为了快而做的取舍。团队协同项目里pre-merge 的时候必须有一道完整的tsgo --noEmit把关。4.3 前端项目用 Vite 时怎么结合如果是一个前端项目最省事的方式还是用 Vite 的脚手架起步npm create vitelatest my-app -- --template vanilla-tsVite 底层用 esbuild 做 TS 编译它从头到尾就不依赖tsc来生成产物所以你项目里原来的build脚本往往是tsc --noEmit vite build——这一步tsc就是纯负责类型检查的。切换到 TS7 之后只需要把tsc换成tsgo{ scripts: { dev: vite, build: tsgo --noEmit vite build, preview: vite preview } }实际体验下来vite build之前跑那一下tsgo --noEmit从原来的 6 到 8 秒降到 1 秒内整个构建流程的阻塞时间被大大压缩。对于已经全面拥抱 Vite 的团队TS7 是零替换成本的——你不改构建工具只改一个检查器性能就能白拿。4.4 几个常用命令的对比速查给迁移期容易混淆的常用命令做一张对照表贴在项目 README 里非常方便需求TypeScript 5.xTypeScript 7 预览版仅类型检查npx tsc --noEmitnpx tsgo --noEmit监听模式npx tsc --watchnpx tsgo --watch输出构建产物npx tscnpx tsgo跳过类型检查的转译需要额外工具npx tsgo --noCheck查看支持参数npx tsc --helpnpx tsgo --help另外如果你需要在开发时直接运行 TS 源码而不是先编译再运行推荐用tsx这个小工具npx tsx src/main.tstsx内部走 esbuild和 TS7 原生编译器互不干扰适合做日常调试最终交付物和 CI 检查再用tsgo把质量关。5. 从 5.x 迁移排坑实录enum 撞墙、模块解析与一次报错的完整定位真正从老项目迁移到 TS7 的时候遇到最多的问题集中在语法约束和模块解析上。这一节我会把典型的坑和完整的排查链路写出来都是我实际撞过的。5.1 erasableSyntaxOnly 撞上 enum错误、原理与改法先从最典型的例子说起。很多老项目里都有这样的代码enum Color { Red, Green, Blue, }如果你在 tsconfig 里开了erasableSyntaxOnly直接运行npx tsgo --noEmit会得到类似下面的错误具体错误码会随预览版变化src/color.ts:1:6 - error: Enum declarations are not allowed under erasableSyntaxOnly.为什么会报错因为 enum 不是可擦除的语法。TypeScript 在编译 enum 的时候不会只是删掉类型而是会生成一段完整的运行时对象甚至带反向映射var Color; (function (Color) { Color[Color[Red] 0] Red; Color[Color[Green] 1] Green; Color[Color[Blue] 2] Blue; })(Color || (Color {}));这段代码是 TypeScript 编译器注入的而 TS7 的定位是一个纯粹的类型擦除器遇到需要改写信源码的语法就选择直接报错。这个设计决策是为了保证原生编译器的一致性、可预测性和速度。所以在 TS7 项目里enum 的替代方案是 const 对象加联合类型const Color { Red: 0, Green: 1, Blue: 2, } as const; type Color (typeof Color)[keyof typeof Color];这样用起来几乎一样Color.Red是值Color也能当类型用。你失去的只是 enum 自带的反向映射和自动递增下标换来的是和 TS7 无缝兼容、还能被 esbuild 等工具原生支持的代码。老实说在新项目里直接用后一种写法是最省心的选择。5.2 声明文件报错先看 skipLibCheck 和 types 字段迁移过程中第二种常见的报错来自node_modules里的声明文件。比如说装了个老一点的types/xxx运行tsgo --noEmit时它报错的位置并不在你写的代码里而是某个依赖包的.d.ts文件内部。应对手段有两步。第一步是确认skipLibCheck: true已经在 tsconfig 里。这个选项会跳过所有.d.ts文件内部的类型一致性检查只把它们当作声明来使用。绝大多数情况下你的业务代码安全性与依赖声明文件内部的小瑕疵没有关系跳过是安全的。第二步是利用types字段做白名单。如果项目里装了不少types包而你又只希望加载其中特定的几个可以在 tsconfig 里显式声明{ compilerOptions: { types: [node] } }这样编译器就只会加载types/node其他types包全部忽略。预览版的原生编译器对声明文件的检查有时比旧版更严格这个白名单能帮你快速定位到真正需要的类型来源也减少了整体检查时间。5.3 一次 TS2307 的完整排查链路下面还原一次我在迁移中遇到的真实报错完整的排查链路都写出来你照着走一遍就能理解 TS7 的模块解析思路。第一步在src/main.ts里加了一行导入import { formatDate } from ./date;第二步运行npx tsgo --noEmit终端报错src/main.ts:3:23 - error TS2307: Cannot find module ./date or its corresponding type declarations.第三步先确认自己的基础认知没有错。我ls src看了一下date.ts文件确实存在目录名、文件名大小写也都对。那问题就不在文件是否存在而在模块解析规则。第四步检查 tsconfig。当前配置是{ module: NodeNext, moduleResolution: NodeNext }第五步想明白 NodeNext 的规则它模拟的是 Node 对 ESM 的真实加载行为。Node 的 ESM loader 拿到./date这个路径去磁盘上找./date文件结果不存在——它不会自动追加.js、.ts、.mjs这些扩展名去尝试。所以tsgo会认为这个模块根本不存在给你 TS2307。第六步修改导入语句把扩展名写全import { formatDate } from ./date.js;不要写成./date.ts要写./date.js。因为运行时加载的是编译产物date.js源码里的相对导入必须对准输出后的文件名。TypeScript 会把./date.js解析回./date.ts进行类型检查。第七步重新运行npx tsgo --noEmit错误消失。如果你实在不习惯在源码里写.js后缀TypeScript 5.7 之后提供了rewriteRelativeImportExtensions选项允许你写./date.ts编译时自动改写成./date.js。但我个人建议在预览期不要依赖这个选项因为有些工具链对.ts扩展名的处理还没有完全统一。让我选的话老老实实写.js后缀跨工具最稳。5.4 预览期已知边界--build与项目引用最后一个要提前知道的边界是构建模式。tsgo预览版里--build的支持成熟度远不如 5.x。如果你在一个 monorepo 里重度使用 project references一个 tsconfig 通过 references 引用另一个 tsconfig暂时不要轻易把tsc --build整个切到tsgo --build。折中的做法是编译入口继续用tsc --build保证产物没问题另外加一条tsgo --noEmit作为快速类型检查。等到稳定版把--build模式对齐之后再考虑切换。预览期每个小版本的变化都很大不确定某个参数是否支持时最直接的办法是跑npx tsgo --help以你手头版本的输出为准。6. 现在要不要上车新项目与老项目的不同版本策略看过前面这些内容你可能已经在想我的项目现在该不该切到 TS7这个问题没有统一答案我把我的判断标准列出来。6.1 适合立刻上车的项目特征如果你的项目符合下面任何一条建议立刻试试全新启动的项目没有任何历史包袱工具脚本、CLI 工具、内部小服务不依赖复杂的 monorepo 工程化前端项目用的是 Vite 这类不以 tsc 作为产物编译器的构建工具或者你的 CI 里类型检查时间已经超过 5 秒想立刻看到提速效果。在这些场景下切换成本极低。把typescript/native-preview装上tsgo用起来代码风格顺手往可擦除语法靠拢收益是立竿见影的。6.2 建议再等一等的场景反过来如果你在大型 monorepo 里重度依赖 project references或者团队里有人用编译器的 API 做自定义转换、代码生成又或者你们依赖的某些工具链还没跟进原生编译器那么建议再等一等。预览期每次升级可能带来参数或行为变化团队如果没精力跟着版本走贸然切到预览版会让维护成本失控。还有一个特殊情况如果你非常在意编辑器里所有功能都快而你的编辑器语言服务还没有原生化TS7 现在能满足的只是命令行和 CI 这一侧。这种情况下可以先用着 5.x等官方把语言服务也原生化之后再整体切换。6.3 我的实操策略与最终建议我自己现在的做法是新项目一律在 tsconfig 里开erasableSyntaxOnly和verbatimModuleSyntax代码风格从一开始就向可擦除语法靠拢编译器用tsgoIDE 语言服务暂时回退到本地 5.xCI 里把tsgo --noEmit加进 pre-merge 检查旧的tsc --noEmit先保留一段时间做对照。这样即便稳定版发布时配置上还有差异我需要改的也只是package.json里的依赖名而不是整个项目的代码。最后分享一个预览期的小习惯native preview 是按周迭代的你遇到的问题很可能是上一版已经修掉的。遇到任何看起来像 bug的行为先npm update typescript/native-preview到最新版再跑一次npx tsgo --help看看参数有没有变。这两步能解决一半的怪问题。TS7 带来的不是新 API而是把等待时间还给开发者——这个价值在大项目里体会一次就回不去了。