通过名称混淆缩减 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,但这很费时间,如果我们漏掉了不太常用的代码路径怎么办?在不触及其他代码的情况下,我们无法 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 调用的符号(通常这些是 worker 线程或进程的入口点)。

导出混淆工作在上一迭代中发布,将 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,它们却总能让我们的运行速度看起来飞快。

English 한국어 中文(简体) 中文(繁體)
© . This website operates independently and is not affiliated with or endorsed by Microsoft. All brand names, logos, and trademarks are the property of their respective owners.