🚀 在 VS Code 中获取

通过名称混淆缩小 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 和许多其他冗长的名称。最值得注意的是近 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。