现已发布!了解 11 月份的新功能和修复。

通过名称混淆缩小 VS Code 的体积

2023 年 7 月 20 日,作者:Matt Bierner,@mattbierner

我们最近将 Visual Studio Code 发布版 JavaScript 的大小减少了 20%。这相当于节省了略高于 3.9 MB 的空间。当然,这比我们发布说明中的某些单个 gif 文件要小,但这仍然不容小觑!这种减少不仅意味着您需要下载和存储在磁盘上的代码更少,而且还提高了启动时间,因为在运行 JavaScript 之前需要扫描的源代码更少。考虑到我们在不删除任何代码且不在我们的代码库中进行任何重大重构的情况下实现了这种减少,这还算不错。相反,这一切只需要一个新的构建步骤:名称混淆。

在这篇文章中,我想分享我们如何识别这种优化机会、探索解决问题的方法,并最终实现这 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 实际上在某些情况下通过一个称为“混淆”的过程来缩短标识符(一个 JavaScript 工具可能从 仅与编译语言的类似过程 中借用的术语)。

在缩小过程中,混淆会缩短长的标识符名称,从而转换诸如

const someLongVariableName = 123;
console.log(someLongVariableName);

变为更短的

const x = 123;
console.log(x);

由于 JavaScript 是以源代码文本的形式发布的,因此减少标识符名称的长度实际上会减小程序的大小。我知道,如果您来自编译语言,那么这种优化可能显得有些愚蠢,但在美妙的 JavaScript 世界中,我们会欣然接受我们能找到的任何此类胜利!

现在,在您急于将所有变量重命名为单个字母之前,我想强调的是,这种优化需要谨慎对待。如果潜在的优化使您的源代码可读性或可维护性降低,或者需要大量的手动工作,那么除非它能带来真正惊人的改进,否则几乎永远不值得。在这里和那里节省一些字节是好的,但很难说是惊人的。

如果我们能够基本上免费获得像这样的出色优化,例如让我们的构建工具自动为我们执行它们,那么这种权衡就会改变。事实上,像 esbuild 这样的智能工具已经实现了标识符混淆。这意味着我们可以继续编写我们的 veryLongAndDescriptiveNamesThatWouldMakeEvenObjectiveCProgrammersBlush,并让我们的构建工具为我们缩短它们!

尽管 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 和许多其他冗长的名称。最值得注意的是 localize(我们用于 UI 中显示的字符串的函数)出现了近 5000 次。显然,仍有改进的空间。

使用与混淆私有属性相同的方法和技术,我很快就确定了另一个常见的代码模式,我们可以安全地混淆该模式,并且具有很高的投资回报:导出的符号名称。只要导出仅在内部使用,我就确信我们可以在不改变代码行为的情况下缩短它们。

这在很大程度上被证明是正确的,尽管又出现了一些复杂情况。例如,我们必须确保不意外地触及扩展使用的 API,并且还必须豁免一些从 TypeScript 导出但随后从无类型 JavaScript 调用的符号(通常这些是工作线程或进程的入口点)。

导出混淆工作在上一个迭代中发布,进一步将 workbench.js 的大小从 10.6 MB 减少到 9.8 MB。总共减少了,该文件现在比没有混淆时小 20%。在所有 VS Code 中,混淆从我们编译的源代码中删除了 3.9 MB 的 JavaScript 代码。这不仅可以减少下载大小和安装大小,还可以减少每次启动 VS Code 时需要扫描的 3.9 MB 的 JavaScript 代码。

此图表显示了 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,但它们始终让我们看起来很快。