尝试以扩展 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

四倍的大小增长也是在大量持续的性能工程工作之后。这项工作之所以发生,很大程度上是因为我们持续跟踪代码大小,并且非常不喜欢看到它增加。我们已经做了许多简单的代码大小优化,包括通过 esbuild 压缩代码。多年来,寻找进一步的节省变得越来越具有挑战性。许多潜在的节省也不值得它们引入的风险,或者实现和维护它们所需的额外工程工作。这意味着我们不得不眼睁睁看着我们的 JavaScript 文件大小慢慢增加。

然而,去年在 vscode.dev 上调试我们压缩后的源代码时,我注意到了一些令人惊讶的事情:我们压缩后的 JavaScript 仍然包含大量长标识符名称,例如 extensionIgnoredRecommendationsService。这让我很惊讶。我原以为 esbuild 应该已经缩短了这些标识符。事实证明,esbuild 确实在某些情况下通过一个名为“混淆”(“mangling”)的过程来缩短标识符(这个术语 JavaScript 工具可能借鉴自编译语言中一个大致相似的过程)。

在压缩过程中,混淆会缩短长标识符名称,将代码(例如:)

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

转换为更短的形式(例如:)

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

由于 JavaScript 作为源代码文本发布,缩短标识符名称的长度实际上会减小程序的体积。我知道如果你来自编译语言,这种优化可能看起来有点傻,但在 JavaScript 的精彩世界里,我们乐于接受任何能找到的这种胜利!

现在,在你急于将所有变量重命名为单个字母之前,我想强调的是,像这样的优化需要谨慎对待。如果一项潜在的优化会降低你的源代码的可读性或可维护性,或者需要大量的 G手工工作,那么除非它能带来真正惊人的改进,否则几乎不值得。这里那里节省几个字节固然不错,但远算不上惊人。

如果我们能基本免费地获得像这样的出色优化,例如让我们的构建工具自动完成,那么这种考量就会改变。实际上,像 esbuild 这样的智能工具已经实现了标识符混淆。这意味着我们可以继续编写我们那些即使是 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,但这很耗时,而且如果我们忽略了不常见的代码路径怎么办?我们无法百分之百确定我们只混淆了私有属性而没有触及其他代码。这种方法看起来既风险太大又过于繁重,难以采纳。

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

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 代码,它们总是让我们看起来速度飞快。