通过名称混淆缩小 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 缺少许多人今天认为必不可少的功能(例如编辑器选项卡或内置终端)时,这种增长可能并不像听起来那么糟糕,但也不容忽视。
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 代码。