作者 | 柳千

本文主要是對 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 查找其他名額。
我們不一定關注以上所有的名額,但有幾個對使用者體感差異較為明顯的名額可以重點關注一下,例如 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 準備就緒,意味着編輯器完全可用
生命周期執行的核心代碼
// 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,可以将一些必要但不緊急的工作延後處理,例如常見的一些埋點上報邏輯,可能會在觸發某些高頻率的互動操作時執行,而如果将這些邏輯與事件處理放在一起,很容易影響操作體驗。
requestIdleCallback 可以傳入第二個參數,表示逾時時間。表示最晚多久以後來執行回調函數。
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 可能還需要處理如拖動等事件,這會讓事件處理更加複雜。
最後,以及預告
實際上這篇分享的内容沒有太多看起來非常硬核的技術手段,更多的是對目前性能瓶頸的測量,以及更聰明的「重新排列組合」,或者說采用了一系列使體驗更好的政策,這對使用者體驗的提升是巨大的。這也給了 KAITIAN 很大的啟發,基于這些,我們也重新審視并優化了 KAITIAN 的啟動速度,使 KAITIAN 的啟動由之前的接近3秒達到了500ms以内的速度,後面會分享一下基于這個分享的 KAITIAN 啟動速度優化過程。
References
- CovalenceConf 2019: Visual Studio Code – The First Second
- Web Dev : https://web.dev/
- Idle Until Urgent
- Using requestIdleCallback
- New JavaScript techniques for rapid page loads