🚀 在 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 已經縮短了這些識別符。事實證明,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 呼叫的符號(通常這些是 Worker 執行緒或進程的進入點)。

匯出修飾工作在上一個迭代中發布,進一步將 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。