前端性能優(yōu)化:讓你的長任務保持在50ms 內(nèi)
雖然之前有跟大家分享過不少卡頓相關的內(nèi)容,實際上網(wǎng)頁里卡頓的產(chǎn)生基本上都是由于長任務導致的。當然,能阻塞用戶操作的,我們說的便是主線程上的長任務。
瀏覽器中的長任務可能是 JavaScript 的編譯、解析 HTML 和 CSS、渲染頁面,或者是我們編寫的 JavaScript 中產(chǎn)生了長任務導致。
讓你的長任務保持在 50 ms 內(nèi)
之前在介紹前端性能優(yōu)化--卡頓篇時,提到可以將大任務進行拆解:
考慮將任務執(zhí)行耗時控制在 50 ms 左右。每執(zhí)行完一個任務,如果耗時超過 50 ms,將剩余任務設為異步,放到下一次執(zhí)行,給到頁面響應用戶操作和更新渲染的時間。
為什么是 50 毫秒呢?
這個數(shù)值并不是隨便寫的,主要來自于 Google 員工開發(fā)的 RAIL 模型。
1.RAIL 模型
RAIL 表示 Web 應用生命周期的四個不同方面:響應(Response)、動畫(Animation)、空閑(Idel)和加載(Load)。由于用戶對每種情境有不同的性能預期,因此,系統(tǒng)會根據(jù)情境以及關于用戶如何看待延遲的用戶體驗調(diào)研來確定效果目標。
人機交互學術研究由來已久,在 Jakob Nielsen’s work on response time limits 中提出三個閾值:
- 100 毫秒:大概是讓用戶感覺系統(tǒng)立即做出反應的極限,這意味著除了顯示結果之外不需要特殊的反饋
- 1 秒:大概是用戶思想流保持不間斷的極限,即使用戶會注意到延遲。一般情況下,大于0.1秒小于1.0秒的延遲不需要特殊反饋,但用戶確實失去了直接操作數(shù)據(jù)的感覺
- 10 秒:大概是讓用戶的注意力集中在對話上的極限。對于較長的延遲,用戶會希望在等待計算機完成的同時執(zhí)行其他任務,因此應該向他們提供反饋,指示計算機預計何時完成。如果響應時間可能變化很大,則延遲期間的反饋尤其重要,因為用戶將不知道會發(fā)生什么。
在此基礎上,如今機器性能都有大幅度的提升,因此基于用戶的體驗,RAIL 增加了一項:
- 0-16 ms:大概是用戶感受到流暢的動畫體驗的數(shù)值。只要每秒渲染 60 幀,這類動畫就會感覺很流暢,也就是每幀 16 毫秒(包括瀏覽器將新幀繪制到屏幕上所需的時間),讓應用生成一幀大約 10 毫秒
由于這篇文章我們討論的是長任務相關,因此主要考慮生命周期中的響應(Response),目標便是要求 100 毫秒內(nèi)獲得可見響應。
2.在 50 毫秒內(nèi)處理事件
RAIL 的目標是在 100 毫秒內(nèi)完成由用戶輸入發(fā)起的轉換,讓用戶感覺互動是瞬時完成的。
目標是 100 毫秒,但是頁面運行時除了輸入處理之外,通常還會執(zhí)行其他工作,并且這些工作會占用可用于獲得可接受輸入響應的部分時間。
因此,為確保在 100 毫秒內(nèi)獲得可見響應,RAIL 的準則是在 50 毫秒內(nèi)處理用戶輸入事件:
為確保在 100 毫秒內(nèi)獲得可見響應,請在 50 毫秒內(nèi)處理用戶輸入事件。這適用于大多數(shù)輸入,例如點擊按鈕、切換表單控件或啟動動畫。這不適用于輕觸拖動或滾動。
除了響應之外,RAIL 對其他的生命周期也提出了對應的準則,總體為:
- 響應(Response):在 50 毫秒內(nèi)處理事件
- 動畫(Animation):在 10 毫秒內(nèi)生成一幀
- 空閑(Idel):最大限度地延長空閑時間
- 加載(Load):提交內(nèi)容并在 5 秒內(nèi)實現(xiàn)互動
具體每個行為的目標和準則是如何考慮和確定的,大家可以自行學習,這里不再贅述。
長任務優(yōu)化
網(wǎng)頁加載時,長時間任務可能會占用主線程,使頁面無法響應用戶輸入(即使頁面看起來已就緒)。點擊和點按通常不起作用,因為尚未附加事件監(jiān)聽器、點擊處理程序等。
基于前面介紹的 RAIL 模型,我們可以將超過 50 毫秒的任務稱之為長任務,即:任何連續(xù)不間斷的且主 UI 線程繁忙 50 毫秒及以上的時間區(qū)間。
實際上,Chrome 瀏覽器中的 Performance 面板也是如此定義的,我們錄制一段 Performance,當主線程同步執(zhí)行的任務超過 50 毫秒時,該任務塊會被標記為紅色。
識別長任務
一般來說,在前端網(wǎng)頁中容易出現(xiàn)的長任務包括:
- 大型的 JavaScript 代碼加載
- 解析 HTML 和 CSS
- DOM 查詢/DOM 操作
- 運算量較大的 JavaScript 腳本的執(zhí)行
(1) 使用 Chrome Devtools
我們可以在 Chrome 開發(fā)者工具中,通過錄制 Performance 的方式,手動查找時長超過 50 毫秒的腳本的“長紅/黃色塊”,然后分析這些任務塊的執(zhí)行內(nèi)容,來識別出長任務。
我們可以選擇 Bottom-Up 和 Group by Activity 面板來分析這些長任務(關于如何使用 Performance 面板,可以參考分析運行時性能一文):
比如在上圖中,導致任務耗時較長的原因是一組成本高昂的 DOM 查詢。
(2) 使用 Long Tasks API
我們還可以使用 Long Tasks API 來確定哪些任務導致互動延遲:
new PerformanceObserver(function (list) {
const perfEntries = list.getEntries();
for (let i = 0; i < perfEntries.length; i++) {
// 分析長任務
}
}).observe({ entryTypes: ["longtask"] });
(3) 識別大型腳本
大型腳本通常是導致耗時較長的任務的主要原因,我們可以想辦法來識別。
除了使用上述的方法,我們還可以使用PerformanceObserver識別:
new PerformanceObserver((resource) => {
const entries = resource.getEntries();
entries.forEach((entry: PerformanceResourceTiming) => {
// 獲取 JavaScript 資源
if (entry.initiatorType !== 'script') return;
const startTime = new Date().getTime();
window.requestAnimationFrame(() => {
// JavaScript 資源加載完成
const endTime = new Date().getTime();
// 如果此時耗時大于 50 ms,則可任務出現(xiàn)了長任務
const isLongTask = endTime - startTime > 50;
});
});
}).observe({entryTypes: ['resource']});
這種方式我們還可以通過entry.name拿到對應的加載資源,針對性地進行處理。
(4) 自定義性能指標
除此之外,我們還可以通過在代碼中埋點,自行計算執(zhí)行耗時,從而針對可預見的場景識別出長任務:
// 可預見的大任務執(zhí)行前打點
performance.mark('bigTask:start');
await doBigTask();
// 執(zhí)行后打點
performance.mark('bigTask:end');
// 測量該任務
performance.measure('bigTask', 'bigTask:start', 'bigTask:end');
再配合PerformanceObserver獲取對應的性能數(shù)據(jù),大于 50 毫秒則可以判斷為長任務、
優(yōu)化長任務
發(fā)現(xiàn)長任務之后,我們就可以進行對應的長任務優(yōu)化。
1.過大的 JavaScript 腳本
大型腳本通常是導致耗時較長的任務的主要原因,尤其是首屏加載時盡量避免加載不必要的代碼。
我們可以考慮拆分這些腳本:
- 首屏加載,僅加載必要的最小 JavaScript 代碼。
- 其他 JavaScript 代碼進行模塊化,進行分包加載。
- 通過預加載、閑時加載等方式,完成剩余所需模塊的代碼加載。
拆分 JavaScript 腳本,使得用戶打開頁面時,只發(fā)送初始路由所需的代碼。這樣可以最大限度地減少需要解析和編譯的腳本量,從而縮短網(wǎng)頁加載時,也有助于提高 First Input Delay (FID) 和 Interaction to Next Paint (INP) 時間。
有很多工具可以幫助我們完成這項工作:
- webpack
- Parcel
- Rollup
這些熱門的模塊打包器,都支持動態(tài)加載的方式來拆分 JavaScript 腳本。我們甚至可以限制每個構建模塊的大小,來防止某個模塊的 JavaScript 腳本過大,具體的使用方式大家可以自行搜索。
2.過長的 JavaScript 執(zhí)行任務
主線程一次只能處理一個任務。如果任務的延時時間超過某一點(確切來說是 50 毫秒),則會被歸類為耗時較長的任務。
對于這種過長的執(zhí)行任務,優(yōu)化方案也十分直接:任務拆分,直觀來看就是這樣:
一般來說,任務拆分可以分為兩種:
- 串行執(zhí)行的不同執(zhí)行任務。
- 單個超大的執(zhí)行任務。
(1) 串行任務的拆分
對于串行執(zhí)行的不同任務,可以將不同任務的調(diào)用從同步改成異步即可,比如 Optimize long tasks 這篇文章中詳細介紹的:
saveSettings()的函數(shù),該函數(shù)會調(diào)用五個函數(shù)來完成某些工作:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
對這些串行任務進行拆分有很多種方式,比如:
- 使用setTimeOut()/postTask()實現(xiàn)異步
- 自行實現(xiàn)任務管理器,管理串行任務執(zhí)行,每執(zhí)行一個任務后釋放主線程,再執(zhí)行下一個任務(還需考慮優(yōu)先級執(zhí)行任務)
具體的代碼可以參考 Optimize long tasks 該文章,理想的優(yōu)化效果為:
(2) 單個超大任務的拆分
有時候我們的應用中需要做大量的運算,比如對上百萬個數(shù)據(jù)做一系列的計算,此時我們可以考慮進行分批拆分。
拆分的時候需要注意幾個事情:
- 盡量將每個小任務拆成 50 毫秒左右的執(zhí)行時間。
- 大任務分批執(zhí)行,會由同步執(zhí)行變?yōu)楫惒綀?zhí)行,需要考慮中間態(tài)(是否有新的任務插入,是否會重復執(zhí)行)。
之前在介紹復雜渲染引擎的時候,有詳細講解使用分批計算的方法進行性能優(yōu)化,具體可以參考《復雜渲染引擎架構與設計--5.分片計算》一文。
結束語
對于大型復雜的前端應用來說,卡頓和長任務都是家常便飯。
性能優(yōu)化沒有捷徑,有的都是一步步定位,一點點分析,一處處解決。每一個問題都是獨立的問題,但我們還可以識別它們的共性,提供更高效的解決路徑。