關于 VS Code 優化啟動性能的實踐
本文主要是對 CovalenceConf 2019: Visual Studio Code – The First Second 這次分享的介紹,CovalenceConf 是一個以 Electron 構建桌面軟件為主題的技術會議,這也是 VS Code 團隊為數不多的對外分享之一(質量較高),主要分享了 VS Code 是如何優化啟動性能的。
TL ; DR
▐ 開頭的一些內容
-
VS Code 的指導原則之一是盡可能快的讓用戶可以進入編輯狀態
-
啟動速度優化并不復雜,但它是許許多多小改進的總和,沒有銀彈
-
Monaco Editor 最早是 2011 年底開始的一個實驗項目,目的是構建一款在瀏覽器中運行的開發人員工具
▐ 關于啟動性能優化
-
性能優化基本的法則
-
測量,測量,還是測量,并基于此建立一個基準線 (VS Code 使用 Performance API,并對整個啟動過程中的關鍵節點打點)
-
建立監控,針對每個版本的性能變化快速做出優化措施
-
用一臺7年前(現在來說是9年前)的 ThinkPad 做測試,確保它能在1.8秒內啟動 VS Code
-
不要過多的專注于 Electron、V8 這些底層依賴,因為有一群聰明的人在不斷的優化它們,專注于加載代碼以及運行程序。
-
確保代碼盡可能快的加載
-
使用 Rollup、Webpack 等構建工具將代碼打包成單文件,這可以節省約 400ms
-
壓縮代碼,可以節省約 100ms
-
使用 V8 Cached Data 將一些模塊代碼編譯成字節碼文件(一種中間態),這可以節省約 400ms, VS Code 自己使用 AMD Loader 實現了這個緩存,也可以直接用 v8-compile-cache 這個包。
-
生命周期階段(Lifecycle Phases),分先后順序來做應該做的事?不要一股腦全部執行
-
梳理清楚所有關于啟動階段事情的優先級
-
保證資源管理器和編輯器初始化,然后再做其他不是非常重要的事
-
requestIdleCallback, 將不那么重要的工作放在瀏覽器空閑時間執行
-
參考 Idle Until Urgent 這篇文章
-
通過一些小技巧使得界面「體感上」較快
-
切換編輯器時,使用 MouseDown 來替代 MouseUp / Click 事件,先確保 Tab 很快的切換
-
打開耗時較大的文件時,首先將面包屑、狀態欄等其他 UI 部分渲染出來,使得用戶感覺 UI 反應很快
-
重復以上步驟
沒有銀彈
VS Code 是少有的核心功能完全使用 Web 技術構建的桌面編輯器,在這之前是 Atom,但師出同門(Electron) 的 Atom 最為人詬病的就是其性能問題。VS Code 自誕生那天起,保證 性能優先 就是最重要的一條準則,誠然相比老牌的 Sublime Text,VS Code 性能表現并不能稱得上優秀,但相比之下已經完全是可以接受的水平了。
社區也有很多開源編輯器采用了前后端分離技術,也就是使用 Web 技術構建編輯器 UI 部分,而核心的 TextBuffer 都使用 Native 實現,這類編輯器甚至可以替換 UI 層的技術實現,例如使用我們常見的 Electron,又或是 QT 等桌面端技術,因為編輯器涉及面太廣,這里暫時不再贅述。
最開始我是抱著來找黑魔法的想法來看這次分享的,然而當我結合代碼看了三遍分享后滿屏都是四個字: 沒有銀彈。
總結下來就 是幾個點 * 按照優先級劃分啟動順序,永遠確保文件樹和編輯器最快渲染出來,并且光標第一時間在編輯器內跳動(這意味著用戶可以開始編輯文件了) * 測量監控性能數據,每個版本都收集盡可能多的數據來直觀的表現性能 * 對于出現的性能瓶頸快速做出改進。
性能優化是一個長期的過程,并不是某個時間段集中精力優化一波就高枕無憂了,你可以在 VS Code 的 issue 列表里找到一系列標簽為 perf 和 startup-perf 相關的 issue,并且這些 issue 都有人長期跟蹤解決的。
從哪開始?
在這之前我們需要明確幾個首屏啟動性能相關的概念,這里列舉的并不是全部,有興趣的可以自行在 Web.Dev 查找其他指標。
| 縮寫 | 英文全稱 | 說明 | | -- | ----- | ------ | | FCP | First Contentful Paint | 瀏覽器渲染DOM內容的第一個字節 | | LCP | Largest Contentful Paint | 可是區內最大塊的文本或圖像渲染時間 | | FID | First Input Delay | 用戶實現交互操作的相應時間,例如點擊事件有反應 | | FCI | First CPU Idle | 首次CPU空閑時間 | | TTI | Time to Interactive | 頁面開始加載到穩定可交互的時間,所有按鈕點擊都有反應 |
我們不一定關注以上所有的指標,但有幾個對用戶體感差異較為明顯的指標可以重點關注一下,例如 LCP 、 FID 以及 TTI。
還有另一項指標 FMP (First Meaningful Paint 首次有效渲染時間) 不是很推薦,因為它無法直觀的識別頁面的主體內容是否加載完成,例如某些網站會在有意義的內容渲染前展示一個全屏的 Loading 動畫,這對用戶來講顯然是沒有任何意義的,而相比之下 LCP 更為純粹,它只看頁面主體內容、圖像是否加載完成。
這與 VS Code 的原則不謀而合,對于文本編輯器來說,性能好壞最直接的問題就是從點開圖標到我可以輸入文本需要多久?VS Code 的答案是 1 秒 (熱啟動在 500 毫秒左右)。
所以第一步永遠是測量,不管是 console.time 還是新的 Performance API,在關鍵的節點添加這些性能標記,通過大量的數據收集可以得到一個真實的性能指標。VS Code 選擇了 Performance API ,這樣更方便匯總上報數據。運行 Startup Performance 命令可以看到這些性能指標的耗時 (總耗時2s+, 實際上 TTI 是 977ms)。
數據收集除了能看到當前真實的性能指標,更能幫助我們發現耗時花在了哪些地方。要做到這一點,需要找到這些關鍵節點。VS Code 是基于 Electron ,除了常規的頁面渲染之外,還有一包括等待 Electron App Ready、創建窗口、LoadURL 等耗時,這部分的性能有專業的團隊來保障(Electron、V8),不需要關心太多。所以重點需要關心的是 UI 部分的呈現及可交互時間。
盡可能快地加載并執行 JavaScript
回到我們擅長的前端領域,當我們談到性能優化時,總是逃不開幾條金科玉律:
-
減小包體積,包括對 HTML、CSS、JavaScript 代碼的壓縮等
-
減小 HTTP 請求,使用服務端 gzip 壓縮
-
使用 Webpack、Rollup 等現代構建工具來抽離公共代碼,Code Splitting 代碼拆分等
所有這些優化的目的,都是為了盡可能快的加載并執行 JavaScript 代碼。在 SPA 大行其道的今天,JavaScript 加載越慢,就意味著用戶看到的白屏時間更久(于是又催生出了 SSR 這種方案)。
▐ V8 Code Cache
V8 Code Cache 的目的是減少對 JavaScript 代碼的解析與編譯開銷,我們知道 V8 使用 JIT (Just in time compilation) 來執行 JavaScript 代碼,也就是說在 JS 腳本執行之前,必須對其進行解析和編譯,這一步的開銷是較大的。而 Code Cache 技術是在首次編譯時將結果緩存下來,下一次加載相同的腳本時直接讀取磁盤上的緩存來執行,省去了解析、編譯的過程,從而使腳本執行更快。V8 提供了開放的 API,因此,任何使用 V8 的軟件都可以調用該 API,同時 Node.js 5.7.0 版本起 vm 模塊也提供了對該 API 的 包裝 。由于 VS Code 使用 AMD Loader 作為模塊加載器,所以內置實現了 V8 Code Cache 。
不過對于大多數應用來說,沒必要自己實現一遍緩存邏輯,直接使用 v8-compile-cache ,在入口處引入 v8-compile-cache 即可。
- import 'v8-compile-cache';
經過一系列的優化,VS Code 的 JS Bundle 加載速度從一開始的接近 1.5 秒優化到了 0.5 秒。
生命周期,更聰明的排序
編輯器的啟動包含許多邏輯,例如快捷鍵、編輯器、文件瀏覽器、調試器等功能的初始化與事件綁定等等,每個看起來都是非常重要的核心功能,而當軟件體積不斷增大時,這些邏輯可能會像高速公路上的車輛一樣,如果毫無秩序,每一輛車都想以最快的速度通過,反而會導致所有車輛停滯不前,造成擁堵。
拆分生命周期的一個重要目的就是將這些核心功能的優先級進行排序,黃金原則就是盡可能快的讓用戶最關心的界面先渲染出來。對于 VS Code 來說,就是文件資源管理器和編輯器。VS Code 的核心功能都是通過 Contribution 來注冊的。在早期的版本中,這些貢獻點會在啟動時就全部一起進行注冊,這直接導致編輯器的加載被阻塞,最直觀的表現就是界面所有 UI 都已經渲染出來并且可操作時,編輯器內的文本還沒有加載出來(它們可能很大)。
拆分生命周期階段本質上就是將這些貢獻點分階段來實例化,具體來說,VS Code 將整個啟動的生命周期分為了四個階段
-
Starting 應用開始啟動階段,非常底層的依賴需要在該階段實例化
-
Ready 核心服務已經實例化完成
-
Restored 編輯器、UI 狀態已經恢復完成(前一次關閉時緩存的狀態)
-
Eventually 準備就緒,意味著編輯器完全可用
生命周期執行的核心代碼 ```typescript // src/vs/workbench/common/contributions.ts
start(accessor: ServicesAccessor): void { const instantiationService = this.instantiationService = accessor.get(IInstantiationService); const lifecycleService = this.lifecycleService = accessor.get(ILifecycleService);
[LifecyclePhase.Starting, LifecyclePhase.Ready, LifecyclePhase.Restored, LifecyclePhase.Eventually].forEach(phase => { this.instantiateByPhase(instantiationService, lifecycleService, phase); }); }
instantiateByPhase(instantiationService: IInstantiationService, lifecycleService: ILifecycleService, phase: LifecyclePhase): void { // 當達到對應的階段時直接實例化貢獻點 if (lifecycleService.phase >= phase) { this.doInstantiateByPhase(instantiationService, phase); }
// 未達到對應階段時一直等待 else { lifecycleService.when(phase).then(() => this.doInstantiateByPhase(instantiationService, phase)); } } ```
正如分享的作者 Johannes Rieken 所說,這并不是非常復雜的技術問題,而是以一種更聰明的方式來對啟動過程重新排序。這樣一來,整體的啟動過程會更加的有序,對于一些不是那么重要的任務,將它們的優先級靠后一些,從而確保能在第一時間將編輯器呈現出來,使用戶進入可以編輯的狀態。
IdleCallback
- // busy busy busy busy
- // busy busy busy busy
- // busy busy busy busy
- requestIdleCallback((dealline) => {
- // idle idle idle idle
- // idle idle idle idle
- // idle idle idle idle
- })
- // busy busy busy busy
- // busy busy busy busy
- // busy busy busy busy
requestIdleCallback 是一個瀏覽器提供的 API,用于在 CPU 空閑時間執行一些任務。相比 setTimeout,requestIdleCallback 的執行時機由瀏覽器來控制,因為瀏覽器知道何時才是空閑時間。利用 requestIdleCallback,可以將一些必要但不緊急的工作延后處理,例如常見的一些埋點上報邏輯,可能會在觸發某些高頻率的交互操作時執行,而如果將這些邏輯與事件處理放在一起,很容易影響操作體驗。
requestIdl eCallback 可以傳入第二個參數,表示超時時間。表示最晚多久以后來執行回調函數。typescript requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 }); `
一般來說應該將執行時機交還給瀏覽器,讓瀏覽器自行決定何時調用回調,如果設置了超時時間,則可能因為執行順序被打亂。
Perceived Performance ,讓體感更快的小技巧
對于一些耗時的確會很長的操作,例如打開一個巨大的文件,顯然即便是性能最好的的優化手段,也無法將這種耗時降到毫秒級。但我們可以通過一些小的手段讓這種交互 感覺更快。例如在這個 Case 中,點擊打開一個大文件(2.5m)時,先將編輯器 Tab 以及面包屑渲染出來。
除此之外,對于切換編輯器 Tab 時,使用 MouseDown 而非 MouseUp 事件,一次點擊事件從觸發 MouseDown 到 MouseUp 中間的耗時平均是50ms,這意味著在切換編輯器時,鼠標點擊至少 50ms 后包括 Tab 以及面包屑才會有反應。我們可以寫一個很簡單的 Demo 來觀察這兩者的區別。
例如這張截圖中,點擊 package.json 時,文件內容還是另一個文件,而面包屑已經變成了 package.json。使用這種小技巧,在 VS Code 中切換編輯器時,會令用戶覺得「反應好快」,
不建議在所有點擊事件觸發的地方都使用 MouseDown 來代替 MouseUp,因為復雜的 UI 可能還需要處理如拖動等事件,這會讓事件處理更加復雜。
最后
這篇分享的內容沒有太多看起來非常硬核的技術手段,更多的是對當前性能瓶頸的測量,以及更聰明的「重新排列組合」,或者說采用了一系列使體驗更好的策略,這對用戶體驗的提升是巨大的。