现已发布!阅读有关一月份的新功能和修复。

通过名称重整缩小 VS Code

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

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

在本文中,我想分享我们是如何发现这个优化机会、探索解决问题的方法,并最终实现了 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(即使是 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,但这很耗时,万一我们忽略了不常见的代码路径怎么办?我们无法 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 代码。

© . This site is unofficial and not affiliated with Microsoft.