透過名稱重整縮小 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 缺乏許多人今天認為必不可少的功能——比如編輯器標籤頁或內建終端——這個增長或許不像聽起來那麼可怕,但也不是無足輕重。
這 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
(讓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
對於這樣一個看似幼稚的方法,它竟然奏效了!這有點出乎意料。嗯,至少大部分是奏效的。
雖然我們對 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。