试试 VS Code 中的

使用名称修饰(Name Mangling)缩小 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 这样的智能工具已经实现了标识符名称修饰。这意味着我们可以继续写那些连 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

令人 somewhat 惊讶的是,这种看似幼稚的方法居然奏效了!至少大部分如此。

虽然我们确实对 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 次。显然,仍然有改进的空间。

运用对私有属性进行名称修饰时采用的相同方法和技巧,我很快又发现了一个常见的代码模式,我们可以安全地对其进行名称修饰并获得高回报:导出的符号名称。只要这些导出的内容仅在内部使用,我就有信心我们可以缩短它们,而不会改变代码的行为。

这一点在很大程度上被证明是正确的,尽管 yine 仍然存在一些复杂之处。例如,我们必须确保不会意外地触及扩展使用的 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,但它们总是能让我们看起来飞快。