尝试以扩展 VS Code 中的代理模式!

通过名称重整缩小 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 缺少许多人今天认为必不可少的功能——比如编辑器标签页或内置终端——这个增长也许并不像听起来那么糟糕,但也不是无足轻重。

The size of 'workbench.js' has slowly increased over the past eight years

这 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 中的第二次较小下降是混淆导出的结果。

Zoomed in chart showing the drops from mangling

The size of 'workbench.js' over all VS Code releases, including the mangling work

我们的混淆实现无疑还有改进的空间,因为我们压缩后的源代码中仍然包含大量的长名称。如果看起来值得,并且我们能想出一种安全的方法,我们可能会进一步研究这些。理想情况下,有一天大部分这项工作将不再必要。原生私有属性已经被自动混淆,我们的构建工具有望在优化我们整个代码库方面变得更好。你可以查看我们当前的混淆实现

我们一直在努力使 VS Code 和我们的代码库变得更好,我认为混淆工作很好地展示了我们处理这个问题的方式。优化是一个持续的过程,而不是一次性的事情。通过持续监控我们的代码大小,我们意识到了它随时间的增长。这种意识无疑帮助我们控制了代码大小的扩张,甚至比现在更多,也鼓励我们始终寻找改进的机会。尽管混淆是一个看似有吸引力的技术,但最初因为它风险太大而无法认真考虑。只有在我们努力降低了这种风险、创建了正确的安全网,并使采用混淆的成本几乎为零之后,我们才最终有足够的信心在我们的构建中启用它。我为最终的结果感到非常自豪,也为我们实现它的方式感到同样自豪。

编程愉快,

Matt Bierner,VS Code 团队成员 @mattbierner


感谢 Johannes Rieken 在实现混淆方面的关键工作,感谢 TypeScript 团队构建了让我们能够安全实现混淆的工具,感谢 esbuild 提供了其快如闪电的打包器,以及感谢整个 VS Code 团队构建了一个适合进行此类优化的代码库。最后但同样重要的是,非常感谢 V8 团队和所有其他 JS 引擎,感谢他们总是让我们看起来很快,尽管我们向他们扔去了堆积如山的、经过可怕混淆的 JavaScript。