通过名称混淆缩小VS Code

2023年7月20日,由Matt Bierner发布,@mattbierner

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

在这篇文章中,我想分享我们是如何识别这个优化机会的,探索了解决问题的方法,并最终实现了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缺乏许多今天被认为是必不可少的功能——比如编辑器标签或内置终端——这种增加可能并不像听起来那么糟糕,但也不是无关紧要的。

'workbench.js' 的大小在过去八年中逐渐增加

那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,但这很耗时,如果我们忽略了不太常见的代码路径怎么办?我们不能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时都需要扫描的JavaScript也减少了3.9 MB。

此图表显示了workbench.js的大小随时间的变化。请注意右侧的两个下降。VS Code 1.74中的第一次大幅下降是由于混淆私有属性的结果。1.80中的第二次较小下降是由于混淆导出。

放大后的图表显示了由于混淆导致的下降

所有VS Code版本中'workbench.js'的大小,包括混淆工作

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

我们一直在努力使VS Code和我们的代码库变得更好,我认为代码混淆工作是我们如何实现这一目标的一个很好的展示。优化是一个持续的过程,而不是一次性的事情。通过持续监控我们的代码大小,我们意识到它是如何随着时间的推移而增长的。这种意识无疑有助于防止我们的代码大小进一步扩大,并鼓励我们始终寻找改进的方法。尽管混淆是一种看似有吸引力的技术,但最初它太冒险,无法认真考虑。只有在我们努力降低这种风险,创建适当的安全网,并使采用混淆的成本几乎为零之后,我们才最终有信心在我们的构建中启用它。我对最终结果感到非常自豪,对我们如何实现这一目标同样感到自豪。

编码愉快,

Matt Bierner,VS Code 团队成员 @mattbierner


感谢Johannes Rieken在实现名称混淆方面的关键工作,感谢TypeScript团队构建了让我们能够安全实现名称混淆的工具,感谢esbuild提供了极其快速的打包工具,感谢整个VS Code团队构建了一个适合此类优化的代码库。最后但同样重要的是,非常感谢V8团队和所有其他JS引擎,尽管我们向他们扔去了大量糟糕的混淆JavaScript代码,他们始终让我们看起来很快。