​TypeScript源码启示:惊人的52000行代码文件

技术最新也最全 2024-10-13 09:55:25

作者:ecznlai

重型 JS 项目的性能问题一向很难,我们在 review 各大开源 js 仓库性能实践的时候注意到:ts 源码的 checker.ts 这个文件相当暴力,它将 TS 完整类型系统全部逻辑 5.2 万行全部写在一个 ts 文件里,而文件大小则达到了惊人的 2.92 MB —— 这相当有趣,为什么?

备注:配图制作于 腾讯文档 - 智能白板

大名鼎鼎的 checker.ts 这个文件我很久以前就知道了, 在 Github 上直接打不开:Github - microsfot/Github: ./src/compiler/checker.ts

好,VSCode,启动:

0. 五万行 all-in-one 的 checker.ts

这个文件很暴力,类型系统全部逻辑 5 万行 all-in-one file ,是 ts 源码维护者不会写代码吗?显然并不是,我翻了一些资料和读了下其中的实现,稍微震撼了一下,将相关思考细节记录在本文。

1. 低配版 named parameters

众所周知,js 各种规范都推荐你用一个对象来传递多个参数,然后在函数里解构 —— 多数时候这没什么,但是在 ts compiler 里,任何浪费都会被极限放大,因此他们用了这种低配版用注释的方式来表示 named parameters (这行还是 anders 老爷子写的,C# 之父 编程领域的传奇!C#、TypeScript之父!全世界最顶尖的程序员之一。-腾讯云开发者社区-腾讯云 )

何为 named parameter 呢?其实就是带名字标签的函数,调用的时候可以指定标签来传参数,这个在其他语言里是基操,比如 moonbit or swift 里的标签函数:

fn add(~left: Int, ~right: Int) -> Int { return left + right;}add(left: 1, right: 44); // add(right: 44, left: 1); // add(1, 2); // 此时会自动匹配到 left 和 right

为什么 ts 需要 named parameter 特性: 在 ts 这种高频调用场景里通过解构 options 对象的方式传参会导致大量无谓的内存开销 —— 这通常会导致 type checking 过程中的内存峰值而造成频繁 gc & mem_copy 更重要的是字面量 key 的顺序还会影响 v8 的 inline caches 优化,写的不好可能会对函数调用 feedback 造成严重负面反馈进而影响 TurboFan 的进一步优化最后造成非常大的性能损失 ...

当 V8 函数调用的 feedback slot 从 SMI 变成 Any 时,TurboFan codegen 的汇编将会慢三倍,关于这个问题的细节,我们在这里有深度讨论&实践。

2. 能用 number 尽量 number

比如 switch、比如 const enum、比如各种 enum bitmap flags 等等设计,原因是 object 和 string 的开销太大了,而小一点的整数在 v8 里甚至是无开销的(如果 SMI tagged pointer 指针自身数值不算开销的话)。

3. 无限制使用 const enum

const enum 有个特性可以直接 inline 枚举值到函数里变成立即数,能享受极致优化:

但目前社区对于 const enum 的主流意见是 不推荐使用,而且 ts 的部分维护者也认为这个是 mistake:

但是这说法其实相当尴尬:是的虽然这是 mistake 我们不推荐使用,但我们 ts 源码里全都是 const enum 到处飞 ... (800+ 个 const enum,没这个特性估计 tsc 要慢不少)

4. ESM/CJS 的性能问题:尤其是 export 导出特别多的时候

当 export 导出太多成员的情况下,V8 内部处理这类对象会将其变成 Slow Properties 字典模式,在多数时候这没啥,但如果遇到某高频模块内的常量被引用大几百万次的情况下,此时 export.xxxxx 的点读查询开销就不能忽视了,尤其是当 export 上有几百个导出的时候,此时点读开销不可忽视,比如:

const constant = require(`./constant`);module.export = function getXXConfig() { return constant.xxx + constant.bbb;}// 由于 constant 上有几百个常量,// 即使是 constant.xxx 这样简单的语句// 在百万次调用的时候,其耗时将不可忽略 ( 几百 ms 以上 )

而 checker.ts 则是将所有东西 all in one,就没这问题了,全都在函数作用域内,查询时间是 O(1)。

5. ESM 没有 private 导出

有种 export 是只想在项目内无限制使用,但是又不期望导出能被外部的 npm 看到 —— 也就是 esm 没有提供 private export 这种特性:

import D from '@tencent/xxx/a/b/c/d';// ⬆️ 我不期望别人能这样 import 我内部的东西

而 ts 又恰恰要这种特性,那么它们怎么实现的呢?通过 /** @internal */ 注解,比如:

标记为 @internal 的东西在生成 d.ts 的时候会被抹去,变相实现外部无法 import 而 ts 仓库内随便 import 。

6. ts 甚至大量使用 var,而不是用 let 和 const

又比如,有部分函数为了性能全用 var,愣是没用 const / let 这些,你看 ts 怎么写的:

具体见: github.com/microsoft...

大意是 ts 的场景下,v8 这类 js runtime 的 TDZ 检查甚至会相当影响运行性能。。。毕竟五万行呢。。。(production build 会比 dev build 要快不少的原因之一)

7. 往 String.prototype.xxx 上注入东西

这类操作在普通 js/ts 项目里是一定会被鄙视的,但一个静态类型语言怎么没办法自己拓展基础类型来使用呢?(这在 swift / go 之类的语言里基于 string / int 来搞出一个新的类型出来是基操。)

8. 无类编程,推崇组合编程

checker.ts 几万行核心逻辑几乎没有 和继承,完全通过函数组合的方式来架构代码,整体看着像是有 rust impl 关键字的 ts 那样:

代码里大部分函数都是上面这种风格,第一个参数是「核心接口」其他参数则是对应的参数,当然,组合优于继承也算是近年来业界达成的共识了。

当然比起架构,我更愿意相信 ts 是考虑到 继承可能存在潜在的性能问题导致的:

比如 V8 引擎下的 A extends B 场景,B 上面有个方法 fn,当 A.fn(); B.fn(); 都调用了之后,如果 A 和 B 的 shapes 不一样,此时 fn 调用 feedback slot 会从 monomorphic 的变成 polymorphic 的,当继承三个以上的时候就会变成 megamorphic 了,这会影响引擎 ICs 的优化效果,导致性能下降。

9. 怎么没有用「表驱动」这种所谓的常用「前端设计模式」?

源码里很多这种根据 ast node kind 去走不同逻辑,然后这些逻辑都写成 if else if else 或者 switch 语句 —— 为何不使用一个 Record<Kind, Fn> 的方式去表驱动呢?

原因很简单:表驱动无法被 v8 这类 runtime 静态分析优化,而且表驱动这类写法会慢个几十倍对于基础设施来说这是不可接受的。(无贬义,js 的表驱动写法看场景,高频调用还是别了吧,写 event selector 之类的倒是一类比较合适的场景)。

从语言特性的层面来说,ts 真的缺一个满血版模式匹配 + enum adt 了,但目前 ts 原则上是不会再合入新的 runtime 特性了 —— 这就很难受了,又不能表驱动,又不能模式匹配,最后代码很 C style 了,而且要写非常多的 x is X 谓语 。

10. 基本没有 try-catch

与 go 有类似的想法,checker.ts 里通过返回值 + 往 context.xxx 上写东西的方式来指示异常,一方面是为了性能,另外一方面我甚至可以合理怀疑为是没有 checked exception 导致只能这样才能 type checked ... (当然 anders 老爷子应该是 uncheck 党,参考 C# 的设计)

11. 文件多才是大问题 —— 可惜了半成品的 ts namespace

如果有接触过大型 js/ts 项目的同学肯定知道,文件一多就不知道东西在哪了,找个 import 你甚至要垮十几个文件 。

—— 从这也可看到,东西为什么要 import 才让用呢?能否有 moonbit、rust 那样好用的模块系统呢?⬅️ 但这依然涉及 runtime 改造,现阶段 ts 就别想了,当然 tc39 也不会再考虑这类特性就是了,等一个 TypeScript Pro Max 吧。

关于 namespace:有接触过 go Rust C++ 的同学应该都有了解了,是用来管理包及语言符号的特性,是业内比较通用的解决方案。

在 ESM 落地之前,ts 有尝试去做满血版的 namespace 特性,但是由于重新确定了不做运行时的想法,因此这个特性在成熟之前就放弃迭代而全面转向 ESM 了,至今 ts 源码里还大量使用 namespace 或者用 ESM 模拟出 namesapce 特性:

12. 最后来个暴论:JS 已经严重影响 TS 的演进了

不得不说,ts 如果继续死磕 js/tc39 而放弃做 runtime feature,恐怕现在已经是最终形态了 ... 以后不会有更进一步的演进了,因为目前 ts 类型系统已经相当完善了,甚至部分能力其他语言都没有,比如 Union Types 以及领先各大友商的控制流分析技术(然而,2024 了 ts 还没有满血版 ADT + 模式匹配,因为这属于 runtime 特性,不是简单擦掉类型就能搞定的)。

当然,近期 tc39 虽然也提了不少新东西但是没有静态类型系统就显得这些特性相当鸡肋以至于它们看起来就像是 ts39 一样,比如备受关注的 Record & Tuple 已经到 Stage 2 了,但懂得都懂这特性一看就知道明显就是给 ts 设计的,给 js 用这个特性跟到处传 void* 一样没什么区别,因为这东西是运行时强类型的,也就是访问 one_record.x 如果真的没有定义 x 那么会直接抛出 error 的而不是返回 undefined。

此外这东西太猛了,几乎就是一个 C 语言版的 匿名 struct 定义对象+内存结构的方案了,我估计各大浏览器估计都不太想搞这个 —— 这个要大改引擎的 js 对象模型了,如果真能实装我很期待它的性能表现。

总之,就目前 ts 源码仓库来看,js 自身的语言特性已经极其限制 ts 对其自身的实现了,但是 ts 又承诺不再做新的 runtime 特性,只做类型系统,这就相当拧巴了,尤其是体现在 ts 源码里,这要是放在公司,晋级 CR 估计要凉透了(悲)

EOF

checker.ts 已经搞出几万行文件以及大量 if-else 超高复杂度的控制流了,还自己手写 named para 注释、甚至不用 const / let / 。而且从代码里处处可见 ts 相当鄙视 esm 和 cjs 这些 module 方案,觉得性能不行,然后搞出来一个半成品的 namespace 模块方案。

总之由于 js 特性太少了,导致源码实现相当拧巴,虽然如此但 ts 整体的 compiler pipeline 架构设计却相当漂亮和简洁,尤其是 transfomers 和 anders 老爷子主推的 LSP 所带来的 IDE 革命,有机会我单开一篇谈谈这个。

1 阅读:54

技术最新也最全

简介:感谢大家的关注