🚀 在 VS Code 中

透過名稱混淆縮減 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 除了桌面應用程式之外,也開始在網頁 (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 應該已經透過稱為「混淆」的過程(JavaScript 工具可能從 僅與編譯語言的類似過程 借用的一個術語)縮短了這些識別碼。事實證明,esbuild 實際上確實會在某些情況下縮短識別碼。

在最小化處理期間,混淆會縮短長識別碼名稱,將程式碼轉換為如下形式:

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 時,也減少了 3.9 MB 需要掃描的 JavaScript。

此圖表顯示了 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。