微软为何选择用Go而非Rust重写TypeScript
最近, TypeScript 宣布用 Go 语言全面重写 TypeScript。重写后的ts在某些测试中实现了 10 倍的速度提升(例如对于VS Code项目),有的甚至高达 15 倍。 A 10x Faster TypeScript
短短几天,其官方库 typescript-go star数超过了1.4万,各种文章纷至沓来. 但同时大家有一个疑惑,为什么微软选用了Go,而不是最近几年重写万物的Rust?
(Why Go?)
就此,Michigan TypeScript
频道主持人,也是一位Rust开发者,采访了TypeScript 联合创始人兼首席架构师 Anders Hejlsberg (译者注: 中文一般译为安德斯·海尔斯伯格, 丹麦人, 编程语言领域的传奇,Delphi、C#和TypeScript之父). 以下是对完整内容的整理与翻译,过程中为符合中文表达有适当删改.
主持人前言:
JavaScript 从来都不是为了计算密集型的系统级工作负载而设计的,对吧?而 Go 语言却正是为此而生。我们这次追求的是完全的兼容性,真正想要的是让它成为旧编译器即插即用的替代品。但我认为,更值得关注的是这样一个问题:如果有一个类型检查器,它的速度比以前快 10 倍,那么这会带来什么影响?
当我们把这个问题与 AI 以及Agentic Programming等领域的进展结合起来思考时,我们能用 10 倍速度生成的信息做些什么呢?比如,是否可以为 LLM(大型语言模型)提供更多上下文信息?比如,这些类型的实际解析结果是什么?某个符号究竟代表什么?它究竟是在哪里声明的?目前,LLM 只能看到符号的拼写,并不能真正理解它的含义。
如果我们能够以实时的方式赋予 LLM 更高精度的信息,会产生什么样的变化呢?
今天的公告表明,TypeScript 的类型检查器---
作为全球最基础的软件开发工具之一---
现在的速度提升了 10 倍。这是因为团队已经将代码库迁移到了 Go 语言,这个过程已经持续了数月之久。当 TypeScript 的首席架构师、联合创始人 Anders Hejlsberg 邀请我讨论这一重大变革时,我试着思考了一些问题---
这些问题可能是库作者、资深用户、工具开发者以及编译器贡献者所关心的。
我本职工作是写 Rust,我知道很多人都会好奇:为什么他们没有选择 Rust?我一开始也有同样的疑问。但在与 Anders 的对话中,他分享了团队对未来路径的愿景。在听完技术上的考量后,我完全认同 Go 是正确的选择。
不过,我更希望大家关注的不是 TypeScript 迁移到了哪种原生语言,而是类型检查的速度提升了 10 倍,同时内存占用减少了一半,并且实现了并发处理。这意味着 TypeScript 将迎来一个新的时代,带来许多新的可能性。我希望你们能通过 Anders 的分享,深入了解这次变革。
采访正文:
主持人:大家好,我们今天邀请到 Anders Hejlsberg。他将为我们介绍 TypeScript 领域正在发生的一件大事,并解答一些问题。希望能从库作者的视角、从那些深入使用 TypeScript 的开发者视角,来聊聊这一变化的影响。
Anders,你好!最近怎么样?请向大家介绍一下自己吧!
Anders:我很好,谢谢!我是 Anders Hejlsberg,微软的技术院士(Technical Fellow),目前担任 TypeScript 开源项目的首席架构师。在此之前,我在 C# 语言项目上工作了十多年。而在更早之前,我在 Borland 公司工作,负责 Delphi 和 Turbo Pascal 的开发。所以,我从事编程语言和软件开发工具的工作已经超过 40 年了,甚至更久。
主持人:哇,真是令人惊叹的职业生涯!其实,不久之前,我还特别喜欢写 C 语言,并且对它的方方面面都非常欣赏,所以要特别感谢你!不过后来,TypeScript 出现了……
也许我们可以从最初的故事讲起。这次新项目的代号是 Corsa,对吧?而旧代码库的代号是 Strata。我听说,Strata 其实是 TypeScript 最早的代号,是这样吗?
Anders:是的,没错!大概在 2010 年底到 2011 年初,我们开始着手这个项目。当时,我们在公司内部开发 TypeScript,最初的代号就是 Strata。
主持人:当时这个项目的主要成员是你和 Luke,对吧?
Anders:是的,当然,还有 Steve Lucco。他编写了最初的原型编译器。当时,他是通过获取 Internet Explorer 的 JavaScript 引擎中的词法分析器(Scanner)和解析器(Parser),然后重新利用这些组件来构建 TypeScript 编译器的原型。那是一套用 C 语言编写的代码库,只是想先做个概念验证(Proof-of-Concept)。Luke 当时是我们的产品经理。所以,一开始是 Steve、我和 Luke 组成了 TypeScript 的核心团队。
主持人:太棒了!那么快进到最近……你能告诉我们,这次的 Corsa 项目是谁提出来的吗?这一天是什么时候?
Anders:嗯……其实并没有特定的某一天。这种想法在我们脑海里已经存在很久了。你应该也注意到,在 ECMAScript 生态系统中,很多关键工具已经开始向原生代码迁移,比如 esbuild、SWC(译者注: 前者是用Go开发的web打包工具, 后者为用 Rust 编写的Ts/Js编译器) 等等。现在市面上已经有多个原生代码编写的 JavaScript 解析器和 Linter(代码检查工具)。
我们一直在关注这些趋势,并且实际上,在 TypeScript 编译器的构建过程中,我们自己也使用了 esbuild。此外,我们也观察到社区里有多个团队尝试用原生代码重新实现 TypeScript。有些团队从零开始构建,有些则尝试进行迁移,但遗憾的是,这些项目都没能真正形成影响力。
这其实可以理解,因为 TypeScript 是一个非常复杂的项目---
目前,我们在 TypeScript 上已经投入了大约 100 人年的开发工作。因此,对于一个个人开发者来说,想要迁移或重写一个高度兼容的 TypeScript 版本,几乎是不可能的任务。这是一项庞大的工程。
我们一直在关注社区中的这些尝试,同时,我们也进行了大量关于性能和可扩展性的讨论。因为这是 TypeScript 用户最常提出的需求之一---
“我们可以让它扩展得更好吗?可以让它运行得更快吗?”
是的,它确实需要更快。随着软件的发展,代码库只会越来越庞大,而不会变小。项目规模在不断增长,我们的编译器也在不断变大。这也给运行时环境带来了更多压力,比如 V8 引擎和 JavaScript 引擎。由于 JavaScript 采用即时编译(JIT Compilation),随着我们不断增加新功能,TypeScript 的启动时间也在逐渐增加。
我们一直在观察 TypeScript 运行时间的缓慢增长,或者说是逐渐变慢的趋势。因此,我们做了一些性能优化的尝试,并进行了一系列改进。但这些优化通常只能带来 5% 或 10% 的提升,并没有实质性的突破。我们逐渐意识到,我们的优化空间已经接近极限了。
当我们用性能分析工具(Profiler)查看 TypeScript 编译器的运行情况时,我们发现它没有明显的性能瓶颈(Hotspots)。它已经尽可能快地运行了,所有的优化方式都已经被用尽。
因此,在去年 8 月,我们开始思考:如果我们将 TypeScript 迁移到原生代码,会带来怎样的影响? 我们需要获取一些数据,从而做出更明智的决策,判断是否值得进行这次迁移。
于是,我们开始用不同的语言进行原型开发(Prototyping)。我们尝试了 Rust、Go、C 以及其他一些语言。最终,我们发现 Go 非常符合我们的需求。
在 8 月,我开始将 TypeScript 的词法分析器(Scanner)和解析器(Parser)迁移到 Go,以建立一个基准(Baseline),看看它的性能会有多快,以及从 JavaScript 迁移到 Go 的难度究竟如何。
结果比我们预期的要顺利得多。在短短几个月内,我们就实现了一个可以运行的版本,它能够解析我们所有的源代码,并且不会报错。
从这个阶段开始,我们便能推测出一些性能数据。我们逐渐意识到,这次迁移可以让 TypeScript 的性能提升 10 倍!
其中,大约 3 到 3.5 倍 的提升来自于原生代码的执行效率,而另外 3 到 3.5 倍 则来自于并发执行(Concurrency)。两者结合后,我们可以实现 10 倍的性能提升。
10 倍的速度提升是一个巨大的突破!一旦你看到这样的可能性,就很难放弃这个方向。与之相比,其他的优化方式都显得微不足道。
主持人:团队其他成员在得知这个消息时,是什么反应?有没有哪一天,大家突然意识到:“哇,这个方案真的可行!”?
Anders:我想,团队内部的反应是兴奋和紧张并存的。
一方面,大家对这个技术方向感到兴奋,因为它带来了前所未有的性能提升。另一方面,这也是一片未知的领域,我们能否真正成功?团队成员能否快速掌握 Go 语言?Go 的工具链是否能像 TypeScript 那样好用?
毕竟,我们已经在 TypeScript 代码库中工作了十多年,突然要迁移到一个全新的语言,这确实带来了很多未知因素。这让我们既充满期待,也有些忐忑。
主持人:你刚刚提到了 “自举语言”(Self-Hosted Language)的概念。对于不太熟悉这个概念的听众,能否简单介绍一下?
Anders:当然。很多编程语言最初是用其他语言编写的,比如 Go 语言最早是用 C 语言编写的,Rust 最早是用 OCaml 编写的。但当这些语言发展到一定程度后,它们就可以用自己来实现自己的编译器。
TypeScript 也是如此---
它是用 TypeScript 自己编写的,这意味着我们一直在用 TypeScript 开发 TypeScript。
但这次迁移相当于 “放弃自举”(Ejecting from Self-Hosting),这在编程语言历史上并不常见。
主持人:所以,这相当于 TypeScript 从 JavaScript 生态系统中 “脱钩”,改用 Go 作为核心实现?
Anders:是的,在 JavaScript 生态系统中,已经有很多工具从 JavaScript 迁移到原生代码,比如 esbuild、SWC 等等。它们最初都是用 JavaScript 编写的,但后来为了提升性能,迁移到了原生语言。
我们也在认真考虑这个问题。事实上,我们最大的担忧之一,就是放弃自托管是否会带来负面影响。毕竟,TypeScript 一直以来都是用 TypeScript 自己编写的,而这种自托管模式也为我们带来了很大好处。
目前,我们仍然在探索最终的架构方案。可以肯定的是,TypeScript 语言服务(Language Service)的核心部分,特别是语义分析引擎(Semantic Engine),将会是原生代码。但 TypeScript 生态中仍然有很多部分可能会继续用 JavaScript 编写。
编译器提供了所有的信息,但周围仍然有很多东西可能会继续留在 JavaScript 中。我们知道,我们必须在原生部分、Go 以及希望使用其他语言的消费者之间构建一个 API。
这是我们尚未完全解决的问题。但我们确实看到了自托管(self-hosting)的价值。然而,我们也必须现实一点。长期来看,如果我们不考虑其他方案,可能会损失 10 倍的性能提升。所以,我们需要权衡,最终选择哪个方案对社区的利益更大。
主持人: 完全赞同!我希望大家能从这次讨论中得到这个信息。我应该提一下,我的工作主要是使用 Rust,当我听到这个消息时,我非常高兴。对于很多人来说,一个显而易见的问题是:为什么不是 Rust? 因为社区中的许多工具都在逐渐向 Rust 靠拢。所以,我想直接问你这个问题。我知道你们曾经考察过 Rust,我也想听听为什么最终没有选择 Rust。另外,我也想知道,C# 语言是否曾被考虑过。我的理解是,C# 语言近年来在异步处理、线程池等方面有了很大进步,也许你可以谈谈这两个选择?
Anders: 其中一个关键因素是,我们是在迁移现有代码,而不是从零开始。如果我们是从零开始,那么选择哪种语言可以根据项目需求来决定。例如,如果我们从零开始编写 Rust,我们会从一开始就设计一个不依赖自动垃圾回收(GC)、不过度依赖循环引用的编译器。
但现实是,我们的产品已经有十多年的历史,有数百万的程序员在使用,还有数百万行代码在运行。因此,我们不可避免地会遇到各种兼容性问题。我们的编译器中有很多行为是“随意”决定的---
比如在类型推导中,可能有多个候选项都是正确的,而我们的编译器会选择其中一个。这种行为实际上已经成为很多程序依赖的特性。如果新的代码库在这方面的处理方式不同,就可能引发新的错误。
所以,从一开始,我们就知道唯一可行的方案是迁移现有代码库。而现有代码库有一些基本假设,其中之一就是 依赖自动垃圾回收。这个前提基本上就排除了 Rust,因为 Rust 没有自动 GC。
在 Rust 中,你可以使用手动内存管理、引用计数等方式,但 Rust 还有一个额外的限制:借用检查(Borrow Checker),它对数据结构的所有权管理非常严格,尤其是禁止循环数据结构。而我们现有的代码库中,循环数据结构无处不在,比如:
- AST(抽象语法树) 既有子节点指向父节点,也有父节点指向子节点。
- 符号表 里的符号可能引用声明,而声明又可能回溯引用符号。
- 类型系统 也是高度递归的,存在大量循环引用。
如果要适配 Rust,我们就必须重新设计所有这些数据结构,这会让迁移到原生代码的难度变得难以逾越。因此,我们需要一种语言,它既能生成高效的原生代码,又能支持循环数据结构,同时还必须具备自动垃圾回收。
此外,我们还需要并发支持,并且是共享内存并发。虽然 JavaScript 通过 Web Workers 提供了并发能力,但它不支持共享内存并发。而我们的编译器需要共享内存并发。
当我们把所有这些需求列出来后,再加上我们希望有优秀的开发工具(比如 VS Code 的支持),最终发现 Go 语言在各方面的表现都非常优秀。因此,我们开始用 Go 进行原型开发,结果发现体验非常好,于是就继续推进了。
主持人: 这确实是一个很现实的考量。我个人对 Rust 充满激情,但我也清楚 Rust 并不是一门“可以在一个周末学会的语言”。Rust 关注的是尽可能正确,即使这会影响开发体验(DX)。
Anders: 对于 JavaScript 开发者来说,Go 的学习曲线显然比 Rust 低得多,这一点我深信不疑。
主持人: 很高兴听到你这么说,因为从人力资源的角度来看,这也是一个很重要的决策依据。如果你对比 JavaScript 和 Go,它们的代码结构其实很相似。但如果你对比 JavaScript 和 Rust,尤其是涉及到递归结构时,Rust 的代码就很难让人直接看出它是从 JavaScript 迁移过来的。
这也是 Go 的一个巨大优势。
那么,我们来补充最后一个问题:C#语言呢? C#
是否曾被考虑过?
Anders: 是的,我们确实考虑过 C#
语言。但最终,我们发现 Go 的优势更大。
Go 是我们能选择的最低级别的语言,同时仍然具备自动垃圾回收。它是最接近原生的语言,同时还提供 GC。相比之下,C#语言
更像是“字节码优先”的语言,虽然某些平台上有 AOT(Ahead-of-Time)编译选项,但它并不适用于所有平台,
从某种程度上来说,C 语言并没有经过十多年的严格打磨,它最初的设计目的也不是为了我们这样的应用。而 Go 在数据结构布局和内联结构体(inline structs)方面更具表现力,这对我们来说是一个很大的优势。
此外,我们的 JavaScript 代码库采用了高度函数式的编程风格,我们几乎不使用类(classes),事实上,核心编译器部分根本不使用类。而 Go 也具有类似的特性,它主要由函数和数据结构组成,而不像 C#
那样高度面向对象(OOP)。如果我们选择 C#
,就必须切换到面向对象的范式,这会增加迁移的阻力,而 Go 则是阻力最小的选择。
主持人: 太好了!关于这个问题,我有一些疑问。我过去在 Go 语言的函数式编程方面遇到过很多困难,但听你这么说,似乎你们并没有遇到类似的问题,这也是我想问的一个问题。
Anders: 当我说“函数式编程”时,我的意思是纯粹的函数式风格,即我们主要使用函数和数据结构,而不是对象。我并不是指模式匹配(pattern matching)、高阶类型(higher-kinded types)、单子(monads)之类的概念。我们所谈论的,仍然是一个相对底层的实现方式。
主持人: 我曾深入调试 TypeScript 代码库,虽然我没有大量贡献代码,但在研究编译后的输出时,我发现 TypeScript 代码库大量使用枚举(enums)和按位运算(bitwise operations),尤其是一元按位运算来跟踪状态值。然而,Go 似乎没有完全相同的概念,虽然它支持常量(const)分组,看起来有点像枚举,但我很好奇,你们是如何在 Go 里处理这个问题的?
Anders: 你提到的本质上是 TypeScript 拥有比 Go 更丰富的类型系统,这一点我完全同意。Go 并没有真正的枚举(enums)概念,尽管它支持常量分组(const grouping)以及 Iota 机制(自动编号),但它仍然有些怪异,类型检查的支持也不如 TypeScript 复杂。
然而,Go 在位运算(bit manipulation)和标志位(flags)存储方面的支持却远超 JavaScript。JavaScript 中,所有数据类型本质上都是浮点数(floating point numbers),而在 Go 中,你可以使用各种整数类型,比如 int8
、int16
、int32
、int64
,既有有符号(signed),也有无符号(unsigned)。相比之下,JavaScript 甚至用 8 字节的浮点数来存储布尔值(true/false),这显然是低效的。
在我们的 JavaScript 编译器中,我们采用了一些优化技巧,比如在浮点数中打包 31 位信息,但这仍然是一个权宜之计。而在 Go 里,我们可以使用所有的位(bits),甚至可以将它们排列成内联结构体(inline structs),并存储在数组中。这种优化使得我们的内存消耗减少了大约一半。
在现代计算机架构中,内存消耗直接影响速度。使用更多的内存会导致频繁访问主存,从而降低性能。CPU 处理指令的速度在预测命中时几乎是零周期(zero cycles),但如果发生缓存未命中(cache miss),可能就需要数千个周期才能从主存中取回数据。因此,优化数据结构的布局,减少内存占用,可以显著提升性能。
主持人: 听你这么说,我开始思考另一个问题。尽管 Go 的类型系统不如 TypeScript 复杂,但它确实有一些独特的功能。例如,TypeScript 允许创建不透明类型(opaque types),在 Go 里,是否有类似的机制?你每天都在使用 Go,是否有一些 Go 语言的特性让你想要引入到 TypeScript 里?
Anders: 嗯,你的这个问题很有趣,让我思考一下。不过,我可以先提一点,Go 的新版本**引入了“新类型(fresh types)”的概念,你可以创建一个 int32
的变体,它不同于其他 int32
,这就是我们在 Go 里实现类型安全的枚举(enums)**的方法。
至于有没有 Go 的特性值得引入到 TypeScript,我觉得很难说。因为 Go 的类型系统相对简单,它更关注运行时特性,而 TypeScript 主要是静态类型系统。不过,在运行时特性方面,我确实希望 JavaScript 也能像 Go 一样拥有结构体(structs),但这是否适合整个 JavaScript 生态系统,我还不太确定。
毕竟,编译器本身是一个极端特殊的 JavaScript 应用场景。如果有人在十年前告诉我,我会在 JavaScript 里写编译器写十年,我肯定觉得他们疯了。但现实是,我们的团队确实一直在用 JavaScript 开发编译器。
然而,JavaScript 并不是为计算密集型的系统级负载设计的,而 Go 恰恰是为这种场景设计的。看看 Kubernetes 这些基于 Go 的大型项目,你就会明白这一点。Go 没有 UI 相关的抽象,它本质上是一个系统级工具,而 TypeScript 编译器也是一个系统级程序,所以 Go 非常适合我们的需求。
主持人: 这确实很有道理。那么,我们来深入探讨一下,这次迁移如何确保整个 TypeScript 生态系统的平稳过渡?我的第一个问题是,TypeScript 没有正式的规范(formal specification),它的**参考实现(reference implementation)**就是规范本身。那么,在迁移到新的 Go 代码库时,你们如何确保行为的一致性?
Anders: 这正是我们**选择“迁移”而不是“重写”**的核心原因之一。当你迁移代码时,最终的语义(semantics)保持不变。虽然代码的实现方式不同,但输入相同的数据,仍然会得到相同的行为。
主持人: 所以,你的意思是,迁移不会导致任何行为上的变化?
Anders: 是的,我们的目标是尽可能忠实地保持原有的行为。我们保留了所有相同的类型,数据结构的布局方式也与 JavaScript 版本一致。当然,在 JavaScript/TypeScript 里,我们大量使用联合类型(union types)、交叉类型(intersection types),以及一些 Go 里没有的高级类型系统特性,因此我们的类型声明方式会有所不同,但核心逻辑仍然保持一致。从语义上讲,我们讨论的仍然是相同的概念。这一点适用于符号(Symbols)、对象模型(Object Model)以及编译器内部的类型系统。
主持人: 太好了!因为库的作者们可能会担心是否需要维护两套类型定义。而听起来,你们正在努力确保这个过渡是平稳的。
Anders: 我们的目标是 99.99% 的兼容性。理想情况下,我们希望对相同的代码基生成完全一致的错误信息。这正是我们一直在努力的方向。
目前,我们开放源码的编译器已经能够无错误地编译和检查整个 Visual Studio Code 代码库,而那可是一个庞大的代码基,包含约 150 万行代码,接近 5000 个源文件,总大小约 50MB。此外,我们也已经非常接近启用所有的测试。
我们知道,我们可以运行 2 万个符合性测试而不会崩溃。我们仍在分析基准数据,并消除一些细微的差异。但我们的目标是完全兼容,确保它能够作为旧编译器的无缝替代品。因为只有这样,我们才能最终摆脱长期维护旧编译器的负担。
主持人: 目前来看,有什么特别困难的挑战会影响这个过渡吗?
Anders: 这是个好问题。如果没有挑战,那当然是最好的答案(笑)。但实际上,确实存在一些复杂的情况。例如,我们在内部对类型的表示方式做了一些调整,尤其是在类型排序方面。
当你有一个类型的联合(Union)时,顺序在某些情况下是重要的,比如当你打印出联合类型时,类型的顺序决定了输出的显示方式。另外,在某些错误消息中,我们需要选择合适的候选项进行错误报告,或者在执行子类型简化时,这些排序都会产生影响。
在旧编译器中,类型排序的方式相对简单但并不确定(非确定性)。它在单线程环境下是确定的,但在多线程环境下可能会有所不同。以前,我们会在创建类型对象时简单地分配一个递增的序列号,这在单线程环境下是可预测的。但在多线程环境下,由于并发的特性,这种方法不再适用。因此,我们需要引入一种确定性的类型排序方式。但这也导致在某些情况下,类型的排序与旧编译器有所不同。虽然理论上联合类型的顺序不应该影响功能,但在某些情况下,它确实会造成变化,我们也需要处理这些问题。
不过,所有这些问题都是可以解决的。我认为目前最大的挑战可能是如何为新的代码库提供一个可版本化(Versionable)且现代化的 API。
在旧的代码库中,源代码本身就是 API 规范。JavaScript 允许你从任何地方调用任何东西,因此编译器的所有内部组件都被暴露成了 API。但在新架构中,我们不能再这样做了。实际上,新代码库目前默认不暴露任何 API,因此我们需要精心设计一个新的 API,并确保它在进程间通信(IPC)环境下仍然高效,而不是简单地通过调用堆栈进行函数调用。
主持人: 我完全理解你的意思。我参与过一些类似的项目,调用一个函数可能有效,但在类型系统中它可能已经被移除(笑)。人们确实会利用这些漏洞。
Anders: 是的(笑)。我们确实在认真考虑 JavaScript API 的设计,并希望它能与其他语言更好地对接。
主持人: 我很高兴这个项目没有用 Rust 编写,因为这意味着它可以更好地与其他语言集成。如果我想用 Zig 编写一个代码生成器(Emitter),这可能会更容易。你们有考虑 WebAssembly(WASM)吗?是否会提供 Rust 或其他语言的绑定(Bindings)?
Anders: 我们的目标是提供语言无关的绑定(Language-Neutral Bindings)。我们确定会支持 语言服务器协议(LSP,Language Server Protocol),因为这是我们新的原生语言服务(Native Language Service)的核心架构。而这也是我们一直希望完成的转变。
TypeScript 项目早于 LSP 诞生,事实上,TypeScript 还是 LSP 设计的灵感之一。但我们自己一直没有完全迁移到 LSP,而这次重构给了我们一个机会。LSP 将成为一个通用的 API,所有工具都可以利用它。此外,我们可能还会在 LSP 之上提供额外的功能,以便开发者能够查询更多语言服务器的信息。
不过,LSP 的功能远不及当前 JavaScript API 的丰富程度,因此我们也在研究如何提供一个更丰富、更同步的 API,尤其是如果我们仍然希望部分语言服务继续使用 JavaScript。但目前,我们还没有最终确定 API 的设计方案,我们正在积极探索这个领域。
主持人: 你们计划在过渡到新的 Go 版编译器后,维护 Strata 代码库多久?
Anders: 大概还会维护几年。我们非常谨慎,不希望让任何用户掉队。我们清楚,一些项目可能还没有办法立即迁移到新的原生编译器,因此我们会继续维护旧的 TypeScript 编译器。
不过,到今年年底,我们预计会有一个完全可用的编译器,绝大多数用户都可以使用它。实际上,命令行编译器部分已经非常接近完成。我预计到春末或夏初,它就能投入使用,成为旧编译器的直接替代品。同时,我们正在开发 JSX、JSDoc 支持、项目引用(Project References)、构建模式(Build Mode)和 Watch 模式等功能,这些都在进行中。
目前,我们已经可以用新编译器编译一个单独的项目,它的速度比旧编译器快 10 倍,并且能给出相同的错误信息。
主持人: 那意味着 TypeScript-Go 代码库会成为新的主代码库,对吧? (译者注: 目前两者star数为103k
/14k)
Anders: 是的,长期来看,它将成为 TypeScript 的主要代码库。当然,我们仍在探索语言服务的架构,最终可能会有一个原生组件和 JavaScript 组件的结合。但总体来说,新的 Go 版编译器将是 TypeScript 的未来。
主持人: 这个时间表看起来很激进(笑)。但你们的支持周期也很长。我的朋友 Andrist 之前也来过这个频道,他现在有大约 100 个 PR(Pull Requests)在排队。我担心这些 PR 会怎么样?
Anders: 我们会尽量迁移这些 PR。我们在去年 8 月或 9 月选定了一个基准提交(Baseline Commit),作为迁移的起点。因此,我们可以从那个点开始,回溯并挑选合适的 PR 进行迁移。
主持人: 这对 TypeScript 生态系统中的工具开发者来说是个好消息。他们的工具,比如 Linter(代码检查器)、Formatter(格式化工具)等,会因此变得更快吗?
Anders: 这很难一概而论。具体取决于工具的实现方式,以及它们依赖语言服务(Language Services)的程度。有些工具可能只是使用解析器(Parser),而不是完整的语义分析(Semantic Analysis)。不过,我们确实在与生态系统中的主要工具开发者沟通,以帮助他们迁移或优化工具。
主持人: 太好了!你们积极与工具开发者沟通,这对整个社区来说是个好消息。
另外,我想问一下并发模型(Concurrency Model)。你们在引入并发时遇到了哪些挑战?有没有遇到死锁(Deadlock)或其他并发问题?
Anders: 这很有趣,因为我们正在迁移的 TypeScript 编译器,也就是大家过去十年一直在使用的编译器,它本身就是一个 高度函数式(Functional)的代码库。
它采用了许多函数式编程的模式,特别是在 不可变性(Immutability) 方面。这种策略可以确保数据的安全共享。例如,在我们扫描、解析并绑定 抽象语法树(AST,Abstract Syntax Tree) 之后,我们基本上会将其视为不可变的。这意味着多个类型检查器可以同时访问相同的 AST,而不会相互干扰。
你可能会问:“但 JavaScript 本身并不支持并发,这有什么影响呢?” 实际上,这一点仍然很重要。因为在开发过程中,你可能会打开多个项目,而这些项目可能都包含相同的文件。我们的做法可以避免创建多个重复的 AST,从而节省大量资源。此外,它还能帮助我们更高效地复用数据。
当你在 语言服务(Language Service) 中编辑代码时,实际上你是在不断地创建新的 程序视图(Program View)。因为每次编辑文件,整个代码库的状态都会发生变化。然而,大部分代码并没有变化,因此在重建新的程序视图时,我们希望尽可能复用旧的视图数据。这也是为什么 不可变数据结构(Immutable Data Structures) 非常重要的原因。
举个例子,在一个包含 100 个文件的项目里,如果你只修改了其中 1 个文件,每次按下键盘的瞬间,编译器实际上都需要重新构建整个程序视图。但如果我们使用不可变数据结构,那么 99 个未修改的文件的 AST 仍然可以直接复用,只需要更新当前编辑的文件即可。
从一开始,我们的编译器就是按照这种方式设计的。可以说,它本质上就是一个 非常适合并发处理的编译器,只是一直被限制在一个无法充分利用并发的环境中。而这正是我之前提到的 共享内存并发(Shared Memory Concurrency) 的关键所在。
主持人: 你能具体讲讲并发对你们的帮助吗?JavaScript 不是只能通过 Web Workers 实现并发吗?这方面的限制如何影响你们的开发?
Anders: 是的,JavaScript 的并发模型主要依赖 Web Workers,但 Web Workers 之间是相互隔离的,无法直接共享内存。它们唯一能共享的是 JSON 数据 或 字节数组(Byte Array),但无法共享结构化数据(Structured Data)。
然而,在编译器的某些环节,比如 解析(Parsing),我们可以充分利用并发处理能力。解析是一个 高度可并行化(Embarrassingly Parallelizable) 的任务。它的基本流程是:
- 读取源文件到内存。
- 构建一个数据结构,以便快速解析和导航代码。
在这个过程中,每个源文件的解析都是 完全独立的。如果你有 5000 个源文件,同时有 8 个 CPU 核心,你可以将这些文件分成 8 份,然后让每个 CPU 负责解析其中的一部分。最终,你会得到 8 组解析后的数据结构。
然而,这个方法只有在所有进程共享相同的内存空间时才有效。而 JavaScript 的 Web Workers 是无法共享内存的,所以如果我们在 JavaScript 里尝试这样做,最终会得到 8 个 孤立的解析结果,然后我们还需要跨进程通信,把它们合并起来。而 跨进程通信的开销往往比解析本身还要大,最终反而得不偿失。
但在新的架构中,我们可以利用 共享内存,让所有的解析进程都在同一个内存空间中运行。这让解析变得 3 到 4 倍 更快。而且,实现并行解析的代码改动非常小,仅仅需要 大约 10 行代码,只需要在适当的地方使用 Go 语言的 Goroutines 和 互斥锁(Mutex) 来保护共享资源,比如唯一 ID 生成器(Serial Number Generator)。最终,我们的解析速度显著提升。
主持人: 竟然只需要 10 行代码就能实现这么大的提升?这也太厉害了!
Anders: 是的(笑)。不过,类型检查(Type Checking)的并发优化就 没有那么简单了。
与解析不同,类型检查并不是 文件级别的独立任务。类型检查器的核心理念是 全局视图(Whole Program View),即:
- 代码可以从 其他文件 导入类型,
- 变量的类型可能依赖于 整个项目的上下文,
- 这意味着 类型检查过程会不断跨越文件边界。
举个例子,如果你写了 let x: SomeType
,那么 SomeType
可能定义在另一个文件里。类型检查器必须跳转到那个文件,解析 SomeType
的信息,然后再回到当前文件继续检查。这个过程涉及大量的 跨文件访问,而这使得并发处理变得更加复杂。
我们的解决方案是 将整个程序拆分成多个部分,然后让 多个类型检查器并行工作。目前,我们默认把代码库分成 4 个部分(这个数值可能会调整)。
- 我们创建 4 个类型检查器,每个检查器都能访问 整个程序,
- 但每个检查器 只检查自己负责的那部分文件,
- 这样,它们可以并行进行类型检查,而不需要频繁地跨文件访问。
这样做的好处是:
- 并行加速:类型检查的速度提升了 3 倍。
- 额外的内存开销较小:虽然某些类型信息会在不同检查器中重复计算,但整体的内存占用 仍然低于旧编译器。
- 最终提升 10 倍性能:新的 Go 编译器本身就比旧的 TypeScript 编译器快 3 倍,加上 并发优化再提升 3 倍,最终获得 10 倍的整体性能提升。
主持人: 这确实是一个巨大的变化!那么,展望未来,你认为这会对 TypeScript 未来 10 年的发展产生什么影响?是否会影响语言特性(Language Features)的设计?
Anders: 我认为这确实是一个重要的转折点。
如果我们在 TypeScript 刚推出 2-3年后 进行类似的重构,可能整个生态系统 还没有准备好,甚至我们自己也没有足够的经验去做这样的改变。但现在,TypeScript 和 ECMAScript 都已经成熟,并且 JavaScript 语言的发展速度也比过去慢了很多。
比如,从 ES5 到 ES6,我们经历了一次 巨大的变革,比如新增了 类(Class)、箭头函数(Lambda)、模块(Module) 等等。但现在,JavaScript 语言的演进节奏明显放缓,而开发者越来越关注 可扩展性(Scalability)和性能(Performance),而不是新的类型系统特性。
当然,我们仍然会持续跟进 ECMAScript 规范,也可能会新增一些类型系统特性。但我认为 更重要的事情是:我们如何利用 10 倍更快的类型检查器。
- 结合 AI 和生成式编程(Generative Programming),
- 提供更 高精度的代码分析,
- 甚至 实时验证 AI 生成的代码的正确性,确保它不仅 语法正确,而且 语义正确。
未来,我们可能会让 AI 代码生成器(如 LLMs) 直接调用 TypeScript 编译器,以 实时检测并校正错误。这样,我们不仅可以生成代码,还能 让 AI 生成的代码更安全、更可靠。这无疑是一个令人兴奋的方向!
在实时的方式下,我们不仅可以检查 AI 生成的代码是否在语法上正确,还可以确保其语义上的正确性。如果你打算让 AI 生成代码,并真正交付到生产环境中运行,这是至关重要的。
是的,谁能保证这段代码是安全的呢?让 AI 保持“诚实”的唯一方法就是通过确定性类型检查器或验证器来进行检查。
我认为,这里确实有一些非常有趣的新方向,以前我们根本没有条件去尝试,但现在我们可以开始认真思考这些可能性了。
主持人: 那么,在未来的某个时间点,你认为是否可能会出现一个原生支持 TypeScript 的运行时?我期待这个已经很多年了。当然,我们现在有 Deno,它是用 Rust 编写的,也许这项工作会与它有一些交集。你觉得未来有没有可能基于这个代码库构建一个以 TypeScript 为核心的运行时?
Anders: 在这个行业里,我学会了一件事,那就是永远不要说“不可能”。这确实有可能发生。
不过,我要说的是,JavaScript 运行时当前的部分性能瓶颈,比如 JIT(即时编译)编译,这是 V8 运行时的一部分,但如果采用更原生的编译系统,可能可以绕过这些问题。然而,JavaScript 的对象模型本身也是一个挑战。
例如,JavaScript 允许你随时给对象动态添加新属性(扩展属性),或者计算属性名。实际上,JavaScript 的对象更像是哈希表,而不是像 C 语言那样的结构体,它们在内存中的排列方式完全不同。尽管我们可能会认为它们类似,但它们的行为本质上是不同的。
JavaScript 处理数字的方式也是如此:它没有真正的整数,所有数字都是浮点数。这些特性如果要保持 JavaScript 的语义,就不可能被完全抛弃。
当然,你可以构想一种类似 TypeScript 但具有不同语义的语言,很多人也尝试过这样做,并且可以为其构建一个原生编译器。但问题是:这真的是人们所需要的吗?这很难说。
所以,我不确定未来会如何发展。我曾经希望能够找到某种“魔法粉末”,让 JavaScript 运行时性能提升一个数量级,但老实说,我认为至少在可预见的未来,这种情况不会发生。但谁知道呢?
主持人: 你说的很有道理。我是一个技术爱好者,所以我经常梦想这些事情,不要责怪我(笑)。
最后一个问题。当我看到一些工具迁移到 Rust 时,社区中有时会出现一些担忧,尽管“反对”这个词可能过于强烈。有些人会担心:原本有很多开发者愿意用 TypeScript 和 JavaScript 贡献代码,但当工具迁移到 Rust 后,这些开发者可能会因此流失,或者他们被迫学习 Rust。你可以说,这种情况在 TypeScript 或 Go 语言中也可能发生。
不过,我个人认为这其实是个积极的变化。不同技术社区之间有很多能量,我们已经看到 Rust 生态的成功,Zig 生态(比如 Bun)也有类似的趋势。这说明人们愿意为了自己关心的项目走出舒适区。但我想知道,这对你来说是否是一个考虑因素?你们考虑过第三方贡献者的影响吗?
Anders: 当然,既懂 Go 又懂 JavaScript 的开发者人数肯定比单纯懂 JavaScript 的少。但另一方面,愿意贡献到编译器的开发者数量本就有限,他们通常对底层技术非常感兴趣,并且很多人也有原生开发的经验。
此外,从 JavaScript 迁移到 Go 其实是一个相对温和的过程,Go 并不是一个特别复杂、仪式感很强的语言,而 Rust 就更接近 C++,学习曲线相对陡峭。相比之下,Go 更像是一个现代化的 Python 或 JavaScript。
主持人: 当我写 Go 代码时,我曾在一家公司里做了两年 Go 开发。在一次全员工程会议上,一些工程师抱怨说 Go 语言“平庸”,他们不喜欢 Go 不能做一些“花哨”的事情。然而,我永远不会忘记 CTO 当时的回应。他告诉大家:“你必须理解,Go 语言的‘平庸’是刻意设计的。”
Anders: Go 并不尝试变得复杂,而是追求简单。但它的结果却并不平庸
主持人: 比如 Kubernetes,它绝对不是一个“平庸”的软件项目。Go 语言让我们能够构建出像 Kubernetes 这样庞大且强大的系统,这本身就是一种成功。
**主持人:**太棒了!我对这个项目的下一阶段感到非常兴奋。我认为这是一个很棒的决定,而且你们在做决定时考虑得非常周全,包括第三方贡献、生态兼容性等各个方面。我很高兴看到你们如此认真地对待这个项目。
我一直是这个团队的忠实粉丝,这个项目能够发展到今天,功能如此强大,并且仍在不断壮大,真的令人惊叹。而我们即将迎来一个全新的篇章。祝贺你们!
Anders: 谢谢!我可以告诉你,我们整个团队对这件事都非常兴奋。这对我们来说是一个巨大的动力,我相信这对社区来说也是如此。我认为,这将为 TypeScript 迎来又一个精彩的十年,我们已经做好迎接这段旅程的准备了。
主持人: 感谢你今天的分享!这些信息对于库作者以及 TypeScript 生态的核心开发者来说都非常有价值。
Anders: 我的荣幸,谢谢你们邀请我!
更多参考:
Microsoft Ports TypeScript to Go for 10x Native Performance Gains
Microsoft Rewriting TypeScript in Go
TypeScript Announces Go Rewrite, Achieves 10x Speedup