在 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 确实在某些情况下通过一个称为“混淆”(mangling)(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,但这很耗时,而且如果我们忽略了不常见的代码路径怎么办?我们无法百分之百确定我们只混淆了私有属性而没有触及其他代码。这种方法似乎既有风险又过于繁琐,难以采纳。

使用 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 中仍然可以找到显著的改进,而无需 resorting to massive code changes or costly rewrites。在这种情况下,我怀疑这些年来其他人也曾查看过 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 时,需要扫描的 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。