通过名称重整缩小 VS Code
2023年7月20日,由 Matt Bierner (@mattbierner) 发布
我们最近将 Visual Studio Code 附带的 JavaScript 大小减少了 20%。这相当于节省了超过 3.9 MB。当然,这比我们发布说明中的某些 GIF 图片还要小,但仍然不可小觑!这次缩减不仅意味着你需要下载和存储在磁盘上的代码更少了,它还改善了启动时间,因为在 JavaScript 运行前需要扫描的源代码也更少了。考虑到我们是在没有删除任何代码,也没有对我们的代码库进行任何重大重构的情况下实现这一缩减的,这成果相当不错了。而这一切只需要一个新的构建步骤:名称混淆 (name mangling)。
在这篇文章中,我想分享我们是如何发现这个优化机会,探索解决问题的方法,并最终实现了这 20% 的大小缩减。我想把这更多地看作一个案例研究,介绍我们 VS Code 团队如何处理工程问题,而不是专注于混淆的具体细节。名称混淆是一个巧妙的技巧,但在许多代码库中可能并不值得,而且我们具体的混淆方法很可能可以改进(或者根据你的项目构建方式,可能根本不需要)。
识别问题
VS Code 团队对性能充满热情,无论是优化热点代码路径、减少 UI 重排,还是加快启动时间。这份热情也包括保持 VS Code 的 JavaScript 体积小巧。随着 VS Code 除了桌面应用程序之外,还发布在 Web 上 (https://vscode.dev),代码大小已成为我们更加关注的焦点。主动监控代码大小让 VS Code 团队的成员能够及时了解其变化。
不幸的是,这些变化几乎总是增加。虽然我们对在 VS Code 中构建哪些功能进行了深思熟虑,但多年来添加新功能必然会增加我们交付的代码量。例如,VS Code 的一个核心 JavaScript 文件(workbench.js
)现在的大小大约是八年前的四倍。现在,当你考虑到八年前的 VS Code 缺乏许多人今天认为必不可少的功能——比如编辑器标签页或内置终端——这个增长或许不像听起来那么可怕,但也不是无足轻重。
这 4 倍的大小增长还是在大量持续的性能工程工作之后的结果。同样,这项工作很大程度上是因为我们一直跟踪代码大小,并且非常讨厌看到它增加。我们已经做了许多简单的代码大小优化,包括通过 esbuild 运行我们的代码来压缩它。多年来,寻找进一步的节省变得越来越具挑战性。许多潜在的节省也不值得它们所带来的风险,或者实现和维护它们所需的额外工程努力。这意味着我们不得不眼睁睁地看着我们的 JavaScript 大小慢慢向上攀升。
然而,去年在 vscode.dev 上调试我们压缩后的源代码时,我注意到了一个令人惊讶的事情:我们压缩后的 JavaScript 仍然包含大量长标识符名称,例如 extensionIgnoredRecommendationsService
。这让我感到惊讶。我以为 esbuild 应该已经缩短了这些标识符。事实证明,esbuild 实际上在某些情况下确实会通过一个叫做“混淆”(mangling)的过程来缩短标识符(这个术语 JavaScript 工具很可能是从编译型语言中一个仅有粗略相似性的过程中借鉴过来的)。
在压缩过程中,混淆会缩短长标识符名称,将如下代码:
const someLongVariableName = 123;
console.log(someLongVariableName);
转换为更短的形式:
const x = 123;
console.log(x);
由于 JavaScript 是以源文本形式交付的,减少标识符名称的长度实际上会减小程序的大小。我知道,如果你来自编译型语言的背景,这种优化可能看起来有点傻,但在这个奇妙的 JavaScript 世界里,我们乐于在任何可能的地方抓住这样的胜利!
现在,在你急着把所有变量都重命名为单个字母之前,我想强调的是,像这样的优化需要谨慎对待。如果一个潜在的优化使你的源代码可读性或可维护性变差,或者需要大量的手动工作,那么它几乎从来都不值得,除非它能带来真正惊人的改进。这里那里节省几个字节固然不错,但很难称得上是惊人的。
如果我们能基本上免费地获得像这样的良好优化,比如说让我们的构建工具自动为我们完成,那么情况就不同了。事实上,像 esbuild 这样的智能工具已经实现了标识符混淆。这意味着我们可以继续写我们的 veryLongAndDescriptiveNamesThatWouldMakeEvenObjectiveCProgrammersBlush
(让Objective-C程序员都脸红的超长描述性名称),然后让我们的构建工具为我们缩短它们!
尽管 esbuild 实现了混淆,但默认情况下,它只在确信混淆不会改变代码行为时才进行。毕竟,让打包工具破坏你的代码真的很糟糕。在实践中,这意味着 esbuild 会混淆局部变量名和参数名。这是安全的,除非你的代码在做一些真正荒谬的事情(在这种情况下,你可能需要担心的远不止代码大小问题)。
然而,esbuild 的保守方法意味着它会跳过混淆许多名称,因为它无法确信更改它们是安全的。举一个可能会出错的简单例子,考虑以下代码:
const obj = { longPropertyName: 123 };
function lookup(prop) {
return obj[prop];
}
console.log(lookup('longPropertyName'));
如果混淆将 longPropertyName
更改为 x
,那么下一行的动态查找将不再起作用:
const obj = { x: 123 }; // Here `longPropertyName` gets rewritten to `x`
function lookup(prop) {
return obj[prop];
}
console.log(lookup('longPropertyName')); // But this reference doesn't and now the lookup is broken
请注意在上面的代码中,我们仍然试图使用 longPropertyName
来访问属性,尽管属性本身在混淆过程中已经被更改了。
虽然这个例子是刻意设计的,但在真实代码中,有很多方式可能导致这类破坏:
- 动态属性访问。
- 序列化对象或将 JSON 解析为预期的对象形状。
- 你暴露的 API(消费者不会知道新的混淆后的名称)。
- 你消费的 API(包括 DOM API)。
虽然你可以强制 esbuild 混淆它找到的几乎每一个名称,但这样做会因为上述原因完全破坏 VS Code。
尽管如此,我总觉得在 VS Code 代码库中我们一定能做得更好。如果我们不能混淆所有名称,或许我们至少可以找到一些可以安全混淆的名称子集。
私有属性的错误尝试
回顾我们压缩后的源代码,另一件让我眼前一亮的是,我看到了很多以 _
开头的长名称。按照惯例,这表示一个私有属性。私有属性肯定可以安全地被混淆,而类外部的代码应该不会察觉,对吧?而且,等等,esbuild 不应该已经为我们做这件事了吗?然而我知道,编写 esbuild 的人绝非等闲之辈。如果 esbuild 没有混淆私有属性,那几乎可以肯定是有充分理由的。
当我更深入地思考这个问题时,我意识到私有属性也受到上面 longPropertyName
示例中所示的动态属性查找问题的影响。我确信像你这样聪明的 TypeScript 程序员绝不会写出这样的代码,但在现实世界的代码库中,动态模式足够常见,以至于 esbuild 选择稳妥行事。
另外请记住,TypeScript 中的 private
关键字实际上只是一个礼貌的建议。当 TypeScript 代码被编译成 JavaScript 时,private
关键字基本上被移除了。这意味着没有什么能阻止类外的“粗鲁”代码随意地伸手访问私有属性:
class Foo {
private bar = 123;
}
const foo: any = new Foo();
console.log(foo.bar);
希望你的代码不会直接做这种可疑的事情,但粗心地更改属性名称可能会以各种意想不到的有趣方式给你带来麻烦,比如对象展开、序列化,以及当不同的类共享相同的属性名时。
幸运的是,我意识到在 VS Code 中我有一个巨大的优势:我正在处理一个(大部分)健全的代码库。我可以做出许多 esbuild 无法做出的假设,比如没有动态的私有属性访问或糟糕的 any
类型访问。这进一步简化了我面临的问题。
于是,我和 Johannes Rieken (@johannesrieken) 一起开始探索私有属性混淆。我们的第一个想法是在我们的代码库中全面采用 JavaScript 的原生 #private
字段。私有字段不仅对上面详述的所有问题都免疫,而且 esbuild 已经会自动对它们进行混淆。向纯粹的 JavaScript 靠拢也很有吸引力。
然而,我们很快就放弃了这种方法,因为它需要大规模(意味着有风险)的代码更改,包括移除我们所有对参数属性的使用。作为一个相对较新的特性,私有字段也尚未在所有运行时中得到优化。使用它们可能会导致从微不足道到大约 95% 的性能下降!虽然从长远来看这可能是正确的改变,但它并不是我们现在所需要的。
接下来我们发现,esbuild 可以选择性地混淆匹配给定正则表达式的属性。然而,这个正则表达式只匹配标识符名称。虽然这意味着我们无法知道该属性在 TypeScript 中是否被声明为 private
,但我们可以尝试混淆所有以 _
开头的属性,我们希望这只会包括私有和受保护的属性。
很快,我们就构建了一个可以正常工作的版本,其中所有以 _
开头的属性都被混淆了。太棒了!这证明了私有属性混淆是可行的,并带来了一些可观的节省,尽管远低于我们的预期。
不幸的是,仅根据名称进行混淆有一些严重的缺点,包括要求我们代码库中所有的私有属性都以 _
开头。VS Code 的代码库并没有始终遵循这个命名约定,而且还有一些地方我们的公共属性也以 _
开头(通常这样做是为了让属性可以在外部访问,但不应被视为 API,例如在测试中)。
我们对混淆后的代码是否真的正确也没有完全的信心。当然,我们可以运行我们的测试或者尝试启动 VS Code,但这很耗时,而且万一我们忽略了不常见的代码路径呢?我们无法 100% 确定我们只混淆了私有属性而没有触及其他代码。这种方法似乎既风险太大又太繁琐,不适合采用。
借助 TypeScript 自信地进行混淆
在思考如何能对混淆构建步骤更有信心时,我们想到了一个新主意:如果 TypeScript 能为我们验证混淆后的代码呢?就像 TypeScript 可以在普通代码中捕获未知的属性访问一样,TypeScript 编译器也应该能够捕获属性被混淆但对其的引用没有被正确更新的情况。我们可以不混淆编译后的 JavaScript,而是混淆我们的 TypeScript 源代码,然后用混淆后的标识符名称编译新的 TypeScript。在混淆后的源代码上进行编译步骤会让我们更有信心,确信我们没有意外地破坏我们的代码。
不仅如此,通过使用 TypeScript,我们可以真正地找到所有 private
属性(而不是那些碰巧以 _
开头的属性)。我们甚至可以利用 TypeScript 现有的 rename
功能来智能地重命名符号,而不会以意想不到的方式改变对象结构。
为了急于尝试这种新方法,我们很快想出了一个新的混淆构建步骤,其工作原理大致如下:
for each private or protected property in codebase (found using TypeScript's AST):
if the property should be mangled:
Compute a new name by looking for an unused symbol name
Use TypeScript to generate a rename edit for all references to the property
Apply all rename edits to our typescript source
Compile the new edited TypeScript sources with the mangled names
对于这样一个看似幼稚的方法,它竟然奏效了!这有点出乎意料。嗯,至少大部分是奏效的。
虽然我们对 TypeScript 能够在我们整个代码库中生成成千上万个正确的编辑感到印象深刻,但我们也必须添加逻辑来处理一些边缘情况:
-
一个新的私有属性名称不仅要在当前类中是唯一的,它还必须在当前类的所有超类和子类中都是唯一的。根本原因还是 TypeScript 的
private
关键字仅仅是一个编译时修饰,并不能真正强制超类和子类不能访问私有属性。如果不小心,重命名可能会引入名称冲突(幸运的是 TypeScript 会将这些报告为错误)。 -
在我们代码的少数地方,子类将继承的受保护属性变为了公共属性。虽然其中许多是错误,但我们也添加了代码来在这些情况下禁用混淆。
在为这些情况添加了代码之后,我们很快就有了可以正常工作的构建。通过混淆私有属性,VS Code 的主 workbench.js
脚本的大小从 12.3 MB 减少到了 10.6 MB,接近 14% 的缩减。这也带来了 5% 的代码加载速度提升,因为需要扫描的源文本更少了。考虑到除了对我们源代码中不安全模式的一些非常小的修复外,这些节省基本上是免费的,这成果相当不错。
经验教训与未来工作
混淆私有属性表明,在不进行大规模代码更改或昂贵的重写的情况下,仍然可以在 VS Code 中找到显著的改进。在这种情况下,我怀疑多年来其他人也曾看过 VS Code 压缩后的源代码,并对那些长名称感到好奇。然而,解决这个问题可能看起来无法安全地做到,或者可能只是觉得不值得投入巨大的工程资源。
这次我们成功的关键在于识别了一个案例(私有属性),在这个案例中,名称混淆很可能是安全的,并且优化仍然会带来显著的改进。然后我们思考如何尽可能安全地进行这一更改。这意味着首先使用 TypeScript 的工具来自信地重命名标识符,然后再次使用 TypeScript 来确保我们新混淆的源代码仍然能够正确编译。在此过程中,我们的代码已经遵循了大多数 TypeScript 的最佳实践,并且已经有覆盖许多常见 VS Code 代码路径的测试,这对我们有很大帮助。这一切结合在一起,使得 Joh 和我可以在业余时间里完成一项相当彻底的改变,而对其他在 VS Code 上工作的开发者几乎没有影响。
但这并不是混淆故事的结局。查看我们新混淆和压缩后的源代码,我沮丧地看到了 provideWorkspaceTrustExtensionProposals
和许多其他冗长的名称。最引人注目的是将近 5000 次出现的 localize
(我们用于显示在 UI 中的字符串的函数)。显然,仍有改进的空间。
使用与混淆私有属性相同的思路和技术,我很快确定了另一个我们可以安全混淆并获得高投资回报的常见代码模式:导出的符号名称。只要这些导出仅在内部使用,我就有信心我们可以缩短它们而不会改变代码的行为。
这在很大程度上被证明是正确的,尽管同样也存在一些复杂情况。例如,我们必须确保不会意外地触及扩展程序使用的 API,并且还必须排除一些从 TypeScript 导出但随后被无类型 JavaScript 调用的符号(通常这些是工作线程或进程的入口点)。
导出混淆的工作在上一个迭代中发布,进一步将 workbench.js
的大小从 10.6 MB 减少到 9.8 MB。总计所有缩减,这个文件现在比没有混淆时小了 20%。在整个 VS Code 中,混淆从我们编译后的源代码中移除了 3.9 MB 的 JavaScript 代码。这不仅是下载大小和安装大小的一次不错的缩减,也意味着每次你启动 VS Code 时,需要扫描的 JavaScript 就少了 3.9 MB。
这张图表显示了 workbench.js
文件大小随时间的变化。注意右侧的两次下降。在 VS Code 1.74 中的第一次大幅下降是混淆私有属性的结果。在 1.80 中的第二次较小下降是来自混淆导出。
我们的混淆实现无疑可以改进,因为我们压缩后的源代码仍然包含大量长名称。如果看起来值得并且我们能想出一个安全的方法,我们可能会进一步研究这些。理想情况下,有一天大部分这项工作将不再必要。原生私有属性已经被自动混淆,我们的构建工具有望在优化我们整个代码库方面变得更好。你可以查看我们当前的混淆实现。
我们一直在努力使 VS Code 和我们的代码库变得更好,我认为混淆工作很好地展示了我们如何处理这个问题。优化是一个持续的过程,而不是一次性的事情。通过持续监控我们的代码大小,我们了解了它是如何随着时间增长的。这种意识无疑有助于防止我们的代码大小扩张得比现在更多,并鼓励我们始终寻找改进之处。尽管混淆是一个看起来很有吸引力的技术,但最初风险太大,难以认真考虑。只有当我们努力降低了这种风险,创建了正确的安全网,并使采用混淆的成本几乎为零时,我们才最终有足够的信心在我们的构建中启用它。我为最终的结果感到非常自豪,也为我们实现它的方式感到同样自豪。
编程愉快,
Matt Bierner, VS Code 团队成员 @mattbierner
感谢 Johannes Rieken 在实现混淆方面的关键工作,感谢 TypeScript 团队构建了让我们能够安全实现混淆的工具,感谢 esbuild 提供了其快如闪电的打包工具,也感谢整个 VS Code 团队构建了一个适合进行此类优化的代码库。最后但同样重要的是,非常感谢 V8 团队和所有其他 JS 引擎,感谢他们总是让我们看起来很快,尽管我们向他们扔去了堆积如山的、被严重混淆的 JavaScript。