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

使用名称混淆缩小 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 在网络 (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 时,还有 3.9 MB 的 JavaScript 不需要扫描。

此图表显示了 workbench.js 的大小随时间的变化。请注意右侧的两次下降。VS Code 1.74 中的第一次大幅下降是混淆私有属性的结果。VS Code 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 代码,它们仍然使我们看起来很快。