通过名称混淆缩小 VS Code
2023 年 7 月 20 日,Matt Bierner,@mattbierner
我们最近将 Visual Studio Code 发布的 JavaScript 大小减少了 20%。这相当于节省了 3.9MB 多一点。当然,这比我们发布说明中的一些单独的 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 缺少许多人今天认为必不可少的功能(例如编辑器选项卡或内置终端)时,这种增长可能并不像听起来那么可怕,但也不是微不足道的。
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 时都需要扫描的 JavaScript 也减少了 3.9 MB。
此图表显示了 workbench.js
随时间推移的大小。请注意右侧的两个下降。VS Code 1.74 中的第一个大幅下降是混淆私有属性的结果。1.80 中的第二个较小下降是来自混淆导出。
我们的混淆实现无疑可以改进,因为我们缩小的源代码仍然包含大量长名称。如果这样做似乎值得并且我们可以提出安全的方法,我们可能会进一步研究这些。理想情况下,有一天,大部分这项工作将不再必要。原生私有属性已经自动混淆,我们的构建工具有望在优化我们整个代码库中的代码方面变得更好。您可以查看我们当前的 混淆实现。
我们始终致力于使 VS Code 和我们的代码库变得更好,我认为混淆工作很好地展示了我们如何处理这个问题。优化是一个持续的过程,而不是一次性的事情。通过不断监控我们的代码大小,我们意识到了它随时间的增长。这种意识无疑有助于防止我们的代码大小比现在更大地扩展,并且还鼓励我们始终寻找改进。尽管混淆是一种看起来很有吸引力的技术,但最初它太冒险而无法认真考虑。只有在我们努力降低这种风险、创建正确的安全网并使采用混淆的成本几乎为零之后,我们才最终感到足够自信,可以在我们的构建中启用它。我为最终结果感到非常自豪,也为我们实现它的方式感到自豪。
编码愉快,
Matt Bierner,VS Code 团队成员 @mattbierner
感谢 Johannes Rieken 在实施混淆方面的关键工作,感谢 TypeScript 团队构建了使我们能够安全地实施混淆的工具,感谢 esbuild 提供的快速捆绑器,以及感谢整个 VS Code 团队构建了一个适合此类优化的代码库。最后但同样重要的是,非常感谢 V8 团队和所有其他 JS 引擎,他们始终让我们看起来很快,尽管我们向他们抛出了堆积如山的 ужасно 混淆的 JavaScript。