TypeScript-Go 为什么可以这么快?
前一阵子 TypeScript 团队发布了 TypeScript-Go,宣称相比基于 JS 的 TypeScript 有 10 倍的性能提升,那么 TypeScript 是什么?它为什么可以这么快?
一个 go 版本的 tsc,而不是一个运行时
下图是 TypeScript-go 项目中贴出的性能收益,提升确实非常明显,但要注意这并不是这些项目本身执行的速度提升,而是编译这些项目的性能提升。也就是说 typescript-go 相当于是 go 版本的 tsc。
为什么选择 go?
现在我们知道了,typescript-go 本质是对 typescript 项目本身的重写,提供编译解析能力。所以第一优先级自然是要对齐现有的所有特性,这里 typescript 团队选择了 1:1 重写现有的 typescript 代码逻辑,这就要求这门语言既有 native 的性能(不然没意义),语法上又尽量可以对齐 typescript 的功能。经过一番对比,他们选择了 go:
- 原生性能:Go 直接编译为原生可执行文件,无需虚拟机和 JIT,性能更高且部署简便。
- 垃圾回收:TS/JS 代码假设有 GC,Go 内建 GC 可平滑移植现有模型。Rust/C++等需手工内存管理并不适合直接移植现有 TypeScript 工具链。
- 数据结构布局更优:Go 支持 struct,可以减少对象分配(VS JS 里对象需多次分配),节省内存,提高 cache 命中率。
- 优秀的并发/多核支持:Go 协程(goroutine)+ 共享内存并发、channel等模型,可以充分利用所有 CPU 核心,而 JavaScript/Node 只能用单线程或成本高昂的多进程消息传递。
- 语言风格契合:TypeScript 编译器本身就是偏函数式+闭包风格,Go 虽无 class,但函数、闭包和 struct 数据模型无缝迁移。
顺带一提这种函数/算法一一移植,不重写逻辑的方式,非常适合 AI 编程,效率很高。TypeScript 团队开发了自动转换工具,将 TypeScript AST 直接生成 Go 代码的骨架,再手动调整。
关于这个一比一移植,我们可以看一个例子,输入 for 循环的代码,用 TypeScript 和 用 Go 分别来实现:
针对 JS 动态对象的部分,Go 需重构为接口及 struct 的混合方案,最大程度兼容原有结构。
在一致性的保持上,接口优先,完整兼容:保证和当前 JS 版 TypeScript 100% 行为兼容(错误信息一致、类型推断细节等),便于集成进大规模现有工具链和编辑器。
除了 native 语言,还做了哪些事情来优化性能?
据 typescript 团队自己所说,性能提升主要来自两个方面:一半来自“原生代码执行+更优数据结构”,另一半来自“充分并发利用多核”。
这里“原生代码执行+更优数据结构”应该就不用过多介绍了,这更多是 native 语言本身带来的优势,我们重点看一下“充分并发利用多核”是什么意思。
编译流程多个阶段有不同并行度:
- Parsing/Binding/Emitting:属于“embarrassingly parallel”(极易并行化)的问题。例如有 n 个源码文件可以直接启动 n 个 go routine 并行解析、绑定,无需跨任务通信。Go 调度原生支持,解析速度可随 CPU 核增倍提升。
- 类型检查(Type Checking):理论上全局依赖多,难以直接并行。团队采用思路是”分块遍历”(如四个 checker 各负责整个源码树的1/4),每个线程独立运行自己的上下文,大部分类型是本地的,只有局部会出现重复计算;结果是内存消耗略有增加(20%),但速度提升 2-3 倍。由于有共享内存,复用 AST,结果合并时消重即可。
- 数据结构Builder与AST本身:解析、语法树、符号表等均充分利用共享内存占用,无需复杂序列化/反序列化。
- JavaScript并发限制说明:Node.js/Web Worker 模型下,工作线程是彼此隔离的,数据之间只能用消息和 JSON 传递,AST 这样的大型对象极难高效共享和并发运算,且 JS 运行时非线程安全。
这里有一个并发解析代码的简单示例,通过 Go 语言的 channel,可以方便的共享解析结果:
1 | // 并行解析源码文件例子 |
运行时有机会吗?
其实我一直憧憬 TypeScript 团队可以开发一个有类型的 JS 运行时,这可以显著提升 TS 代码的执行速度,优于 JS 本身。为什么呢?如果我们看过一些 V8 的介绍,会发现 V8 之所以可以这么快,是因为其中进行了类型的预测,简单说如果有一个函数接收一个参数,如果我们不知道这个参数的类型,则需要在运行时每次执行时进行类型的判断,这是一个很耗时的过程。但如果执行多次以后发现这个函数每次接收的都是 string,V8 会把这个热函数进行优化,得到 native 代码,这样这个函数的执行就会非常快了。
这里可以看到,如果我们在编译阶段就有类型信息,而不是执行阶段再去获取,就可以在编译阶段直接编辑为 native 代码,从而使得代码首次执行就非常快。
当然这工作量太大了,TypeScript 中也有 any,并不是一个静态类型的语言,不过想想如果能实现,还是很棒的,哪怕是一个 TypeScript 的子集呢?