我们最近将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的大小慢慢增加。
去年在vcode .dev上调试我们的迷你源代码时,我注意到一些令人惊讶的事情: 我们压缩后的JavaScript仍然包含大量长的标识符名称,例如 extensionIgnoredRecommendationsService
。这让我很吃惊。我们一直以为esbuild已经缩短了这些标识符。事实证明,在某些情况下,esbuild实际上通过一个称为“mangling”的过程来缩短标识符(JavaScript工具可能从编译语言的一个大致相似的过程中借用了这个术语)。
在缩小过程中,mangling会缩短长标识符名称,转换如下代码:
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'));
如果mangling将 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
来访问属性,即使属性本身在mangling期间已经更改。
虽然这个例子是人为的,但实际上在实际代码中有很多方式可以发生这些中断:
-
动态属性访问。
-
序列化对象或将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一起开始探索私有属性混淆。我们的第一个想法是尝试在代码库的所有地方采用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
功能来巧妙地重命名符号,而不会以意想不到的方式改变对象的形状。
急于尝试这种新方法,我们很快就想出了新的mangling构建步骤,大致如下所示:
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 nameUse TypeScript to generate a rename edit for all references to the propertyApply all rename edits to our typescript sourceCompile the new edited TypeScript sources with the mangled names
令人惊讶的是,这种看似天真的方法居然奏效了! 至少大部分是这样。
虽然我们对TypeScript在整个代码库中生成成千上万个正确编辑的能力印象深刻,但我们还必须添加逻辑来处理一些边缘情况:
-
新的私有属性名在当前类中唯一是不够的,它还必须在当前类的所有父类和子类中唯一。再一次,根本原因是TypeScript的
private
关键字只是一个编译时的装饰,实际上并没有强制父类和子类不能访问私有属性。如果不小心,重命名可能会引入名称冲突(谢天谢地,TypeScript会将其报告为错误)。 -
在代码中的一些地方,子类使继承的受保护属性成为公共的。虽然其中许多都是错误的,但我们还添加了代码来禁用这些情况下的混淆。
在为这些案例添加了代码之后,我们很快就有了可工作的构建。通过修改私有属性,VS Code的main workbench.js
脚本的大小从12.3 MB减少到10.6 MB,减少了近14%。这也使代码加载速度提高了5%,因为需要扫描的源文本更少了。考虑到这一点,除了对源代码中不安全模式的一些非常小的修复之外,这些节省基本上是免费的。
学习和进一步的工作
修改私有属性表明,在VS Code中仍然可以找到显著的改进,而无需诉诸大规模的代码修改或昂贵的重写。在这种情况下,我怀疑多年来其他人看过VS Code压缩后的源码并对这些长名称感到好奇。然而,解决这个问题似乎不可能安全地做到,或者可能只是不值得进行潜在的大规模工程投资。
这次我们成功的关键是确定了一种情况(私有属性),在这种情况下,名称混淆可能是安全的,并且优化仍然可以做出重大改进。然后我们考虑如何尽可能安全地进行这种改变。这意味着首先使用TypeScript的工具来自信地重命名标识符,然后再次使用TypeScript来确保我们新修改的源代码仍然可以正确编译。在此过程中,我们的代码已经遵循了大多数TypeScript的最佳实践,并且已经测试了许多常见的VS code代码路径,这给了我们很大的帮助。这一切都汇聚在一起,这样john和我就可以在业余时间发布一个相当大的变化,而几乎不会对其他从事VS Code的开发人员产生影响。
然而,这并不是这个混淆故事的结束。在浏览我们刚刚被混淆和缩小的源码时,我很沮丧地看到 provideWorkspaceTrustExtensionProposals
和许多其他冗长的名称。最值得注意的是出现了近5000次的 localize
(我们在UI中用于字符串的函数)。显然,仍有改进的余地。
使用与修改私有属性相同的方法和技术,我很快确定了另一种常见的代码模式,我们可以安全地修改它并获得高投资回报: 导出符号名称。只要导出只在内部使用,我相信我们可以在不改变代码行为的情况下缩短它们。
这在很大程度上被证明是正确的,尽管也有一些复杂的情况。例如,我们必须确保不意外地触及扩展使用的api,并且还必须豁免一些从TypeScript导出但随后从未类型化的JavaScript调用的符号(通常这些符号是工作线程或进程的入口点)。
在上一个迭代中导出的混淆工作,进一步将 workbench.js
的大小从10.6 MB减少到9.8 MB。所有的减少,这个文件现在比没有混淆的情况下小20%。在所有VS Code中,mangling从编译的源代码中删除了3.9 MB的JavaScript代码。这不仅减少了下载大小和安装大小,而且每次启动VS Code时需要扫描的JavaScript也减少了3.9 MB。
该图显示了 workbench.js
随时间变化的大小。注意右边的两次下降。第一次是在VS Code 1.74上应用了私有属性的混淆。第二个较小降幅来自在1.80上应用了导出符号名称混淆。
我们的mangling实现无疑可以得到改进,因为我们的最小化源代码仍然包含大量的长名称。如果这样做似乎是值得的,如果我们能想出一个安全的方法,我们可能会进一步研究这些。理想情况下,有一天这些工作中的大部分将完全不需要。原生私有属性已经被自动修改了,我们的构建工具有望在优化整个代码库方面做得更好。您可以查看我们当前的mangling实现(https://github.com/microsoft/vscode/blob/48cd8e0c1b142a46f0956b593d8331145634658e/build/lib/mangle/index.ts)。
我们一直在努力让VS Code和我们的代码库变得更好,我认为修改工作很好地展示了我们是如何做到这一点的。优化是一个持续的过程,而不是一次性的事情。通过持续监控我们的代码大小,我们知道它是如何随时间增长的。这种意识无疑有助于防止我们的代码规模进一步扩大,并鼓励我们始终寻求改进。虽然混淆看起来是一种很有吸引力的技术,但一开始风险太大,不值得认真考虑。只有当我们努力减少这种风险,创建正确的安全策略,并使采用mangling的成本几乎为零时,我们才最终有足够的信心在我们的构建中启用它。我为最终的结果感到骄傲,也为我们实现它的方式感到骄傲。
快乐的编码,
Matt Bierner, VS Code团队成员 @mattbierner
感谢Johannes Rieken为实现mangling所做的关键工作,感谢TypeScript团队为我们构建了安全实现mangling的工具,感谢esbuild的快速打包器,感谢整个VS Code团队为我们构建了一个适合这样优化的代码库。最后但并非最不重要的是,非常感谢V8团队和所有其他JS引擎,尽管我们扔给他们成堆的可怕的JavaScript,他们总是让我们看起来很快。
欢迎关注公众号:文本魔术,了解更多