如何減少網(wǎng)頁卡頓
點(diǎn)擊上方 前端Q,關(guān)注公眾號(hào)
回復(fù)加群,加入前端Q技術(shù)交流群
前言
經(jīng)常聽人說,“不要阻塞主線程”,或者 “減少長耗時(shí) ",該如何做呢?
聊網(wǎng)站性能的文章有很多,通常為了提高 js 性能,避不開這兩點(diǎn):
不要阻塞主線程
減少長耗時(shí)
該怎么做呢?很明顯,精簡 js 代碼有好處,但更少的代碼量是否就一定意味著用戶界面的體驗(yàn)會(huì)更順暢?可能會(huì),但也可能恰恰相反。
要弄懂優(yōu)化 js 中任務(wù)的重要性,首先需要了解什么是任務(wù)、任務(wù)的角色以及瀏覽器的任務(wù)處理機(jī)制。
瀏覽器中的任務(wù)
瀏覽器執(zhí)行的任務(wù)之間是相互獨(dú)立的,像頁面渲染,html 和 css 的解析,以及執(zhí)行 js 代碼都屬于任務(wù)的范疇。雖然開發(fā)者不能直接控制這些任務(wù),但毫無疑問的是,瀏覽器中的任務(wù)主要源自開發(fā)者編寫和部署的代碼。
圖 1
上圖中的任務(wù)便是 chrome DevToos 性能剖析中點(diǎn)擊事件觸發(fā)的。從圖中能看到,任務(wù)在頂端,任務(wù)下面列出了點(diǎn)擊事件、調(diào)用的函數(shù),此外還調(diào)用很多其他方法。
任務(wù)能影響性能的方式很多,比如在打開網(wǎng)站時(shí)下載 js 代碼,瀏覽器會(huì)把任務(wù)放到隊(duì)列中不執(zhí)行,而是準(zhǔn)備解析和編譯 js 而防止阻塞 js。之后網(wǎng)站上的任務(wù)才會(huì)因?yàn)橛脩艚换ヲ?qū)動(dòng)事件處理器、js 動(dòng)畫以及分析收集的后臺(tái)活
動(dòng)等 js 活動(dòng)而觸發(fā)。(web worker 這種情況例外)
什么是主線程?
瀏覽器絕大多的任務(wù)都發(fā)生在主線程,其主線程名稱的由來也主要是因?yàn)椋簬缀跛?js 都在主線程運(yùn)行。
主線程每次只能處理一個(gè)任務(wù),當(dāng)任務(wù)耗時(shí)超過特定時(shí)間,比如 50ms 就會(huì)被歸類為長耗時(shí)。如果發(fā)生長耗時(shí)時(shí)存在用戶交互,或者關(guān)鍵渲染更新時(shí),瀏覽器就會(huì)延后再處理用戶交互,這會(huì)直接導(dǎo)致用戶交互或者渲染出現(xiàn)延遲。

谷歌性能剖析中的長耗時(shí)如圖所示,一般會(huì)在任務(wù)角上用紅色三角形標(biāo)出來,其中被阻塞的任務(wù)部分用紅色細(xì)斜條紋標(biāo)出來(如上圖所示)。
優(yōu)化長耗時(shí),意味著將單個(gè)長耗時(shí)任務(wù)拆解成幾個(gè)耗時(shí)相對(duì)短的小任務(wù),可以查看下圖。

在上圖中能看到單個(gè)長任務(wù)和被拆分成了 5 個(gè)短任務(wù)。
為什么需要拆分任務(wù)非常重要?因?yàn)椴鸱珠L任務(wù)后,瀏覽器就有了更多的機(jī)會(huì),可以去處理優(yōu)先級(jí)別更高的工作,其中就包括用戶交互行為。

如果任務(wù)非常長,瀏覽器對(duì)用戶交互的展示如圖所示,這時(shí)候?yàn)g覽器就沒法快速處理用戶交互,但拆分長任務(wù)后的從圖中能看到效果就不一樣。
因?yàn)殚L任務(wù)的緣故,用戶交互產(chǎn)生的事件處理就必須排隊(duì),等待長任務(wù)執(zhí)行完后才能執(zhí)行。這個(gè)時(shí)候就會(huì)導(dǎo)致用戶交互的延遲。當(dāng)拆分成較短的任務(wù)后,事件處理器就有機(jī)會(huì)更快的觸發(fā)。
因?yàn)槭录幚砥髂軌蛟诙倘蝿?wù)之間得以執(zhí)行,也就比長任務(wù)耗時(shí)更短。在長耗時(shí)的圖片中,用戶可能就會(huì)感到卡頓;長任務(wù)拆分后,用戶可能就感覺體驗(yàn)很流暢。
然而問題來了,那就是 減少長耗時(shí)到底該怎么做,不要阻塞主線程寫的也不夠明確。這篇文章便為你解開這些神秘的面紗。
任務(wù)管理策略
軟件架構(gòu)中有時(shí)候會(huì)將一個(gè)任務(wù)拆分成多個(gè)函數(shù),這不僅能增強(qiáng)代碼可讀性,也讓項(xiàng)目更容易維護(hù),當(dāng)然這樣也更容易寫測試。
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
在上面的例子中,該函數(shù) saveSettings 調(diào)用了另外 5 個(gè)函數(shù),包括驗(yàn)證表單、展示加載的動(dòng)畫、發(fā)送數(shù)據(jù)到后端等。理論上講,這是很合理的架構(gòu)。如果需調(diào)試這些功能,也只需要在項(xiàng)目中查找每個(gè)函數(shù)即可。
然而,這樣也有問題,就是 js 并不是為每個(gè)方法開辟一個(gè)單獨(dú)的任務(wù),因?yàn)檫@些方法都包含在 saveSetting 這個(gè)函數(shù)中,也就是說這五個(gè)方法在一個(gè)任務(wù)中執(zhí)行
重點(diǎn)提示
js 遵循執(zhí)行才編譯的原理,也就是說,只有一個(gè)任務(wù)結(jié)束才會(huì)執(zhí)行下一個(gè)任務(wù),而且不論這個(gè)任務(wù)會(huì)阻塞主線程多久。

saveSetting 這個(gè)函數(shù)調(diào)用 5 個(gè)函數(shù),這個(gè)函數(shù)的執(zhí)行看起來就像一個(gè)特別長的長的任務(wù)。
在很多場景中,單個(gè)函數(shù)耗時(shí)可能會(huì)超過 50ms,從而使得整體任務(wù)耗時(shí)更長。如果測試場景比較差,特別是在 “資源受限” 場景下測試的設(shè)備,每個(gè)函數(shù)可能耗時(shí)都會(huì)更久。接下來,將會(huì)分享優(yōu)化的策略。
使用代碼延遲任務(wù)執(zhí)行
為了拆分長任務(wù),開發(fā)者經(jīng)常使用定時(shí)器 setTimeout。通過把方法傳遞給 setTimeout,也就等同于重新創(chuàng)建
了一個(gè)新的任務(wù),延遲了回調(diào)的執(zhí)行,而且使用該方法,即便是將 delay 時(shí)間設(shè)定成 0,也是有效的。
function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Defer work that isn't user-visible to a separate task:
setTimeout(() => {
saveToDatabase();
sendAnalytics();
}, 0);
}
如果需執(zhí)行的函數(shù)先后關(guān)系是很明確,這個(gè)方法會(huì)非常有效,然而并不是所有場景都能使用這個(gè)方法。比如,如需要在循環(huán)中處理大數(shù)據(jù)量的數(shù)據(jù),這個(gè)任務(wù)的耗時(shí)可能就會(huì)非常長(假設(shè)有數(shù)百萬的數(shù)據(jù)量)
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
此時(shí),使用 setTimeout 就會(huì)出錯(cuò),因?yàn)樾试驘o法實(shí)行,而且雖然單獨(dú)處理每個(gè)數(shù)據(jù)耗時(shí)很短,但整個(gè)數(shù)組可能花費(fèi)特別長的時(shí)間。綜合來看,setTimeout 就不能算是特別有效的工具。
除了 setTimeout 的方式,確有一些 api 能夠允許延遲代碼到隨后的任務(wù)中執(zhí)行。其中一個(gè)方式便是使用 postMessage 替代定時(shí)器;也可以使用 requestIdleCallback,但是需要注意這個(gè) api 編排的任務(wù)的優(yōu)先級(jí)別最低,而且只會(huì)在瀏覽器空閑時(shí)才會(huì)執(zhí)行。當(dāng)主線程繁忙時(shí),通過 requestIdleCallback 這個(gè) api 編排的任務(wù)可能永遠(yuǎn)不會(huì)執(zhí)行。
使用 async、await 來創(chuàng)造讓步點(diǎn)
在本文會(huì)出現(xiàn)一個(gè)新詞讓步,這個(gè)詞的定義、用法和意義可以通過代碼和介紹進(jìn)行闡述。
重點(diǎn)提示
當(dāng)讓步于主線程后,瀏覽器就有機(jī)會(huì)處理那些更重要的任務(wù),而不是放在隊(duì)列中排隊(duì)。理想狀態(tài)下,一旦出現(xiàn)用戶界面級(jí)別的任務(wù),就應(yīng)該讓步給主線程,讓任務(wù)更快的執(zhí)行完。讓步于主線程讓更重要的工作能更快的完成
分解任務(wù)后,按照瀏覽器內(nèi)部的優(yōu)先級(jí)別劃分,其他的任務(wù)可能優(yōu)先級(jí)別調(diào)整的會(huì)更高。一種讓步于主線程的方式是配合用了 setTimeout 的 promise。
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
注意
盡管這個(gè)例子在返回 promise 中通過 setimeout 來調(diào)用 resolve,但此時(shí)并不是新開一個(gè)任務(wù)讓 promise 執(zhí)行后續(xù)代碼,而是通過 setTimeout 調(diào)用。因?yàn)?promise 的回調(diào)屬于微任務(wù),因此不會(huì)讓步于主線程。
在 saveSettings 的函數(shù)中,可以在每次 await 函數(shù) yieldToMain 后讓步于主線程:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [ validateForm, showSpinner, saveToDatabase, updateUI, sendAnalytics ]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread:
await yieldToMain();
}
}
重要提示
并不是所有函數(shù)調(diào)用都要讓步于主線程。如果兩個(gè)函數(shù)的結(jié)果在用戶界面上有重要的更新,最好就不要這樣做。如果可以,可以想讓任務(wù)執(zhí)行,然后考慮在那些不重要的函數(shù)或者能在后臺(tái)運(yùn)行的函數(shù)之間讓步。
這樣的好處是,就能看到單個(gè)大的長任務(wù)被拆分成了多個(gè)獨(dú)立的任務(wù)。

現(xiàn)在能看到,saveSetting 這個(gè)函數(shù)內(nèi)的函數(shù)現(xiàn)在成為了單獨(dú)的任務(wù)。
通過使用 promise 這種方式,和手動(dòng)寫 setTimeout 這種定時(shí)器方式相比,在工程上有跟多的好處。讓步的時(shí)間點(diǎn)變成了聲明式,因此這種代碼寫起來更容易,閱讀和理解也更輕松。
只在必要時(shí)讓步
假如有一堆的任務(wù),但是只想在用戶交互的時(shí)候才讓步,該怎么辦?正好有這種 api--isInputPending
isInputPending 這個(gè)函數(shù)可以在任何時(shí)候調(diào)用,它能判斷用戶是否要與頁面元素進(jìn)行交互。調(diào)用
isInputPending 會(huì)返回布爾值,true 代表要與頁面元素交互,false 則不交互。
比如說,任務(wù)隊(duì)列中有很多任務(wù),但是不想阻擋用戶輸入,使用 isInputPending 和自定義方法 yieldToMain 方法,就能夠保證用戶交互時(shí)的 input 不會(huì)延遲。
async function saveSettings () {
// 函數(shù)隊(duì)列
const tasks = [ validateForm, showSpinner, saveToDatabase, updateUI, sendAnalytics ];
while (tasks.length > 0) {
// 讓步于用戶輸入
if (navigator.scheduling.isInputPending()) {
// 如果有用戶輸入在等待,則讓步
await yieldToMain();
} else {
// Shift the the task out of the queue:
const task = tasks.shift();
// Run the task:
task();
}
}
}
在 saveSetting 執(zhí)行過程中,會(huì)逐個(gè)循環(huán)隊(duì)列中的任務(wù)。如果循環(huán)時(shí) isInputPending 結(jié)果返回真,saveSetting 就會(huì)調(diào)用 yieldToMain 函數(shù),這樣就能處理用戶輸入的事件,反之,就會(huì)走到隊(duì)列繼續(xù)執(zhí)行下一個(gè),直到隊(duì)列執(zhí)行完。

saveSetting 這個(gè)任務(wù)隊(duì)列中有 5 個(gè)任務(wù),但此時(shí)如果正在執(zhí)行第二個(gè)任務(wù)而用戶想打開某個(gè)菜單,于是點(diǎn)擊了這個(gè)菜單,isInputPending 就會(huì)讓步,讓主線程處理交互事件,同時(shí)也會(huì)稍后執(zhí)行后面剩余的任務(wù)。
用戶輸入后 isInputPending 的返回值不一定總是 “true”, 這是因?yàn)椴僮飨到y(tǒng)需要時(shí)間來通知瀏覽器交互結(jié)束,也就是說其他代碼可能已經(jīng)開始執(zhí)行,比如截圖例子中的 saveToDatabase 這個(gè)方法可能已經(jīng)在執(zhí)行了。即便使用 isInputPending,還是需要在每個(gè)方法限制任務(wù)中的方法數(shù)量。
使用 isInputPending 配合讓步的策略,能讓瀏覽器有機(jī)會(huì)響應(yīng)用戶的重要交互,這在很多情況下,尤其是很多執(zhí)行很多任務(wù)時(shí),能夠提高頁面對(duì)用戶的響應(yīng)能力。
另一種使用 isInputPending 的方式,特別是擔(dān)心瀏覽器不支持該策略,就可以使用另一種結(jié)合時(shí)間的方式。
async function saveSettings () {
// A task queue of functions
const tasks = [ validateForm, showSpinner, saveToDatabase, updateUI, sendAnalytics ];
let deadline = performance.now() + 50;
while (tasks.length > 0) {
// Optional chaining operator used here helps to avoid
// errors in browsers that don't support `isInputPending`:
if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
// There's a pending user input, or the
// deadline has been reached. Yield here:
await yieldToMain();
// Extend the deadline:
deadline += 50;
// Stop the execution of the current loop and
// move onto the next iteration:
continue;
}
// Shift the the task out of the queue:
const task = tasks.shift();
// Run the task:
task();
}
}
使用這種方式,通過結(jié)合時(shí)間來兼容不支持 isInputPending 的瀏覽器,尤其是使用截止時(shí)間或者在特定時(shí)間點(diǎn),讓工作能在適當(dāng)時(shí)候中斷,不論是通過讓步于用戶輸入還是在特定時(shí)間節(jié)點(diǎn)。
幾個(gè) API 的差異
目前提到的 api 對(duì)于拆解任務(wù)都有幫助,但也有弊端:讓步與主線程則意味著延遲代碼稍后執(zhí)行,即該部分代碼被添加到稍后的事件隊(duì)列中去了。
如果能控制頁面中所有的代碼,就可以編排各個(gè)任務(wù)的優(yōu)先級(jí),但是第三方 js 腳本可能不會(huì)服從安排。實(shí)際上,也不可能真正意義上給所有的任務(wù)排優(yōu)先級(jí),而是只能讓他們成堆,或者是讓步于特定的用戶交互行為。
幸運(yùn)的是,有一個(gè)專門編排優(yōu)先級(jí)的 api 正在開發(fā)中,相信能夠解決這些問題。
專門編排優(yōu)先級(jí)的 api
目前在書寫本文時(shí)該 api 提供 postTask 的功能,對(duì)于所有的 chromium 瀏覽器和 firefox 均可使用。
postTask 允許更細(xì)粒度的編排任務(wù),該方法能讓瀏覽器編排任務(wù)的優(yōu)先級(jí),以便地優(yōu)先級(jí)別的任務(wù)能夠讓步于主線程。目前 postTask 使用 promise,接受優(yōu)先級(jí)這個(gè)參數(shù)設(shè)定。
postTask 方法有三個(gè)優(yōu)先級(jí)別:
background 級(jí),適用于優(yōu)先級(jí)別最低的任務(wù)
user-visible 級(jí),適用于優(yōu)先級(jí)別中等的任務(wù),如果沒有入?yún)ⅲ彩窃摵瘮?shù)的默認(rèn)參數(shù)。
user-blocking 級(jí),適用于優(yōu)先級(jí)別最高的任務(wù)。
拿下面的代碼來舉例,postTask 在三處分別都是最高優(yōu)先級(jí)別,其他的另外兩個(gè)任務(wù)優(yōu)先級(jí)別都是最低。
function saveSettings () {
// Validate the form at high priority
scheduler.postTask(validateForm, {priority: 'user-blocking'});
// Show the spinner at high priority:
scheduler.postTask(showSpinner, {priority: 'user-blocking'});
// Update the database in the background:
scheduler.postTask(saveToDatabase, {priority: 'background'});
// Update the user interface at high priority:
scheduler.postTask(updateUI, {priority: 'user-blocking'});
// Send analytics data in the background:
scheduler.postTask(sendAnalytics, {priority: 'background'});
};
在上面例子中,通過這些任務(wù)的優(yōu)先級(jí)的編排方式,能讓高瀏覽器級(jí)別的任務(wù),比如用戶交互等得以觸發(fā)。

當(dāng) saveSettings 方法在執(zhí)行時(shí),會(huì)使用 postTask 來編排每個(gè)方法。關(guān)鍵的用戶側(cè)任務(wù)優(yōu)先級(jí)別高,當(dāng)然用戶并不知道的任務(wù)按照 background 的級(jí)別,這就可以 up 和提高優(yōu)先級(jí)。
【第1920期】如何監(jiān)控網(wǎng)頁的卡頓?
這是如何使用 postTask 的非常簡單的例子??梢杂貌煌?TaskController 對(duì)象來區(qū)分,這樣能在不同的人物之間共享優(yōu)先級(jí)別,也能為不同的 TaskController 的實(shí)例變更優(yōu)先級(jí)。
重點(diǎn)提示
postTask 并不是所有瀏覽器都支持??梢詸z測是否空,或者考慮使用 polyfill。
內(nèi)置不中斷的讓步方法
還有一個(gè)編排 api 目前還在提議階段,還沒有內(nèi)置到任何瀏覽器中。它的用法和本章和開始講到的 yieldToMain 這個(gè)方法類似。
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread with the scheduler
// API's own yielding mechanism:
await scheduler.yield();
}
}
這和之前的代碼大部分相似,但我們也能看到上面代碼并沒有使用 yieldToMain,而是使用了 await scheduler.yield 方法。

下面三幅圖分別是不使用 yield,使用 yield,以及使用 yield 且不中斷。不使用 yield,出現(xiàn)了長耗時(shí)任務(wù)。使用 yield,短任務(wù)數(shù)量變多了,而且還能被其他不相關(guān)的任務(wù)打斷。使用 yield 且不中斷,里面的短任務(wù)更多,但是執(zhí)行順序是固定的。
上面便是三種情況的效果圖。使用 scheduler.yield 方法時(shí),任務(wù)能在每次讓步停止后重新開始。
使用 scheduler.yield 的好處是不中斷,也就意味著如果是在一連串任務(wù)中 yield,那么從 yield 的時(shí)間點(diǎn)開始,其他編排好的任務(wù)的執(zhí)行會(huì)繼續(xù)。這就能避免第三方 js 腳本代碼阻塞代碼的執(zhí)行
結(jié)語
雖然管理任務(wù)富有挑戰(zhàn),但管理任務(wù)卻能受益頗多,網(wǎng)站能有更快的用戶交互體驗(yàn)。管理和調(diào)優(yōu)沒有萬靈藥,但確有一系列不同的技巧。最后總結(jié)重申一下,管理任務(wù)時(shí)主要需要考慮以下幾點(diǎn):
遇到關(guān)鍵任務(wù)和用戶側(cè)的任務(wù)需要讓步于主線程
使用 isInputPending 來讓步主線程讓用戶可以與頁面交互
適應(yīng) postTask 來調(diào)整任務(wù)的優(yōu)先級(jí)
最后,每個(gè)函數(shù)盡可能地減少活動(dòng)
使用以上一個(gè)或多個(gè)方法,就能夠?qū)?yīng)用中的任務(wù)進(jìn)行管理,根據(jù)用戶需要調(diào)整優(yōu)先級(jí),同時(shí)能保證相對(duì)不那么重要的工作得以繼續(xù)執(zhí)行,這樣給創(chuàng)造更好的用戶體驗(yàn),網(wǎng)站響應(yīng)更快,使用更令人愉悅。
關(guān)于本文
譯者:@劉剛
譯文:https://juejin.cn/post/7159807927908302884
原文:https://web.dev/articles/optimize-long-tasks?hl=zh-cn
往期推薦
最后
歡迎加我微信,拉你進(jìn)技術(shù)群,長期交流學(xué)習(xí)...
歡迎關(guān)注「前端Q」,認(rèn)真學(xué)前端,做個(gè)專業(yè)的技術(shù)人...
