背景
系統(tǒng)網(wǎng)站應(yīng)用出現(xiàn)過(guò)卡頓,但卻不知道如何優(yōu)化。本文是國(guó)內(nèi)第一篇講如何減少卡頓的代碼級(jí)別詳細(xì)文章,也是有關(guān)性能優(yōu)化系列文章中的一篇。
正文
經(jīng)常聽(tīng)人說(shuō),“不要阻塞主線程”,或者“減少長(zhǎng)耗時(shí)",該如何做呢?
聊網(wǎng)站性能的文章有很多,通常為了提高 js 性能,避不開(kāi)這兩點(diǎn):
不要阻塞主線程
減少長(zhǎng)耗時(shí)
該怎么做呢?很明顯,精簡(jiǎn) js 代碼有好處,但更少的代碼量是否就一定意味著用戶(hù)界面的體驗(yàn)會(huì)更順暢?可能會(huì),但也可能恰恰相反。
要弄懂優(yōu)化 js 中任務(wù)的重要性,首先需要了解什么是任務(wù)、任務(wù)的角色以及瀏覽器的任務(wù)處理機(jī)制。
瀏覽器中的任務(wù)
瀏覽器執(zhí)行的任務(wù)之間是相互獨(dú)立的,像頁(yè)面渲染,html 和 css 的解析,以及執(zhí)行 js 代碼都屬于任務(wù)的范疇。雖然開(kāi)發(fā)者不能直接控制這些任務(wù),但毫無(wú)疑問(wèn)的是,瀏覽器中的任務(wù)主要源自開(kāi)發(fā)者編寫(xiě)和部署的代碼。

上圖中的任務(wù)便是 chrome DevToos 性能剖析中點(diǎn)擊事件觸發(fā)的。從圖中能看到,任務(wù)在頂端,任務(wù)下面列出了點(diǎn)擊事件、調(diào)用的函數(shù),此外還調(diào)用很多其他方法。
任務(wù)能影響性能的方式很多,比如在打開(kāi)網(wǎng)站時(shí)下載 js 代碼,瀏覽器會(huì)把任務(wù)放到隊(duì)列中不執(zhí)行,而是準(zhǔn)備解析和編譯 js 而防止阻塞 js。之后網(wǎng)站上的任務(wù)才會(huì)因?yàn)橛脩?hù)交互驅(qū)動(dòng)事件處理器、js 動(dòng)畫(huà)以及分析收集的后臺(tái)活動(dòng)等 js 活動(dòng)而觸發(fā)。( web worker 這種情況例外)
什么是主線程?
瀏覽器絕大多的任務(wù)都發(fā)生在主線程,其主線程名稱(chēng)的由來(lái)也主要是因?yàn)椋簬缀跛?js 都在主線程運(yùn)行。
主線程每次只能處理一個(gè)任務(wù),當(dāng)任務(wù)耗時(shí)超過(guò)特定時(shí)間,比如 50ms 就會(huì)被歸類(lèi)為長(zhǎng)耗時(shí)。如果發(fā)生長(zhǎng)耗時(shí)時(shí)存在用戶(hù)交互,或者關(guān)鍵渲染更新時(shí),瀏覽器就會(huì)延后再處理用戶(hù)交互,這會(huì)直接導(dǎo)致用戶(hù)交互或者渲染出現(xiàn)延遲。

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

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

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

saveSetting這個(gè)函數(shù)調(diào)用 5 個(gè)函數(shù),這個(gè)函數(shù)的執(zhí)行看起來(lái)就像一個(gè)特別長(zhǎng)的長(zhǎng)的任務(wù)。
在很多場(chǎng)景中,單個(gè)函數(shù)耗時(shí)可能會(huì)超過(guò) 50ms,從而使得整體任務(wù)耗時(shí)更長(zhǎng)。如果測(cè)試場(chǎng)景比較差,特別是在“資源受限”場(chǎng)景下測(cè)試的設(shè)備,每個(gè)函數(shù)可能耗時(shí)都會(huì)更久。接下來(lái),將會(huì)分享優(yōu)化的策略。
使用代碼延遲任務(wù)執(zhí)行
為了拆分長(zhǎng)任務(wù),開(kāi)發(fā)者經(jīng)常使用定時(shí)器 setTimeout 。通過(guò)把方法傳遞給 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ì)非常有效,然而并不是所有場(chǎng)景都能使用這個(gè)方法。比如,如需要在循環(huán)中處理大數(shù)據(jù)量的數(shù)據(jù),這個(gè)任務(wù)的耗時(shí)可能就會(huì)非常長(zhǎng)(假設(shè)有數(shù)百萬(wàn)的數(shù)據(jù)量)
function?processData?()?{?for?(const?item?of?largeDataArray)?{?//?Process?the?individual?item?here.?}?}
此時(shí),使用 setTimeout 就會(huì)出錯(cuò),因?yàn)樾试驘o(wú)法實(shí)行,而且雖然單獨(dú)處理每個(gè)數(shù)據(jù)耗時(shí)很短,但整個(gè)數(shù)組可能花費(fèi)特別長(zhǎng)的時(shí)間。綜合來(lái)看,setTimeout 就不能算是特別有效的工具。
除了setTimeout的方式,確有一些 api 能夠允許延遲代碼到隨后的任務(wù)中執(zhí)行。其中一個(gè)方式便是使用postMessage替代定時(shí)器;也可以使用requestIdleCallback,但是需要注意這個(gè) api 編排的任務(wù)的優(yōu)先級(jí)別最低,而且只會(huì)在瀏覽器空閑時(shí)才會(huì)執(zhí)行。當(dāng)主線程繁忙時(shí),通過(guò)requestIdleCallback這個(gè) api 編排的任務(wù)可能永遠(yuǎn)不會(huì)執(zhí)行。
使用 asycn、await 來(lái)創(chuàng)造讓步點(diǎn)
在本文會(huì)出現(xiàn)一個(gè)新詞讓步,這個(gè)詞的定義、用法和意義可以通過(guò)代碼和介紹進(jìn)行闡述。
重點(diǎn)提示
當(dāng)讓步于主線程后,瀏覽器就有機(jī)會(huì)處理那些更重要的任務(wù),而不是放在隊(duì)列中排隊(duì)。理想狀態(tài)下,一旦出現(xiàn)用戶(hù)界面級(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中通過(guò)setimeout來(lái)調(diào)用resolve,但此時(shí)并不是新開(kāi)一個(gè)任務(wù)讓promise執(zhí)行后續(xù)代碼,而是通過(guò)setTimeout調(diào)用。因?yàn)閜romise的回調(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é)果在用戶(hù)界面上有重要的更新,最好就不要這樣做。如果可以,可以想讓任務(wù)執(zhí)行,然后考慮在那些不重要的函數(shù)或者能在后臺(tái)運(yùn)行的函數(shù)之間讓步。
這樣的好處是,就能看到單個(gè)大的長(zhǎng)任務(wù)被拆分成了多個(gè)獨(dú)立的任務(wù)。

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

saveSetting這個(gè)任務(wù)隊(duì)列中有 5 個(gè)任務(wù),但此時(shí)如果正在執(zhí)行第二個(gè)任務(wù)而用戶(hù)想打開(kāi)某個(gè)菜單,于是點(diǎn)擊了這個(gè)菜單,isInputPending就會(huì)讓步,讓主線程處理交互事件,同時(shí)也會(huì)稍后執(zhí)行后面剩余的任務(wù)。
用戶(hù)輸入后isInputPending的返回值不一定總是“ true ”,這是因?yàn)椴僮飨到y(tǒng)需要時(shí)間來(lái)通知瀏覽器交互結(jié)束,也就是說(shuō)其他代碼可能已經(jīng)開(kāi)始執(zhí)行,比如截圖例子中的saveToDatabase這個(gè)方法可能已經(jīng)在執(zhí)行了。即便使用isInputPending,還是需要在每個(gè)方法限制任務(wù)中的方法數(shù)量。
使用isInputPending配合讓步的策略,能讓瀏覽器有機(jī)會(huì)響應(yīng)用戶(hù)的重要交互,這在很多情況下,尤其是很多執(zhí)行很多任務(wù)時(shí),能夠提高頁(yè)面對(duì)用戶(hù)的響應(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();
? }
}
使用這種方式,通過(guò)結(jié)合時(shí)間來(lái)兼容不支持isInputPending的瀏覽器,尤其是使用截止時(shí)間或者在特定時(shí)間點(diǎn),讓工作能在適當(dāng)時(shí)候中斷,不論是通過(guò)讓步于用戶(hù)輸入還是在特定時(shí)間節(jié)點(diǎn)。
幾個(gè)API的差異
目前提到的 api 對(duì)于拆解任務(wù)都有幫助,但也有弊端:讓步與主線程則意味著延遲代碼稍后執(zhí)行,即該部分代碼被添加到稍后的事件隊(duì)列中去了。
如果能控制頁(yè)面中所有的代碼,就可以編排各個(gè)任務(wù)的優(yōu)先級(jí),但是第三方 js 腳本可能不會(huì)服從安排。實(shí)際上,也不可能真正意義上給所有的任務(wù)排優(yōu)先級(jí),而是只能讓他們成堆,或者是讓步于特定的用戶(hù)交互行為。
幸運(yùn)的是,有一個(gè)專(zhuān)門(mén)編排優(yōu)先級(jí)的 api 正在開(kāi)發(fā)中,相信能夠解決這些問(wèn)題。
專(zhuān)門(mén)編排優(yōu)先級(jí)的api
目前在書(shū)寫(xiě)本文時(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ù),如果沒(méi)有入?yún)ⅲ彩窃摵瘮?shù)的默認(rèn)參數(shù)。
user-blocking級(jí),適用于優(yōu)先級(jí)別最高的任務(wù)。
拿下面的代碼來(lái)舉例,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'}); };
在上面例子中,通過(guò)這些任務(wù)的優(yōu)先級(jí)的編排方式,能讓高瀏覽器級(jí)別的任務(wù),比如用戶(hù)交互等得以觸發(fā)。

當(dāng)saveSettings方法在執(zhí)行時(shí),會(huì)使用postTask來(lái)編排每個(gè)方法。關(guān)鍵的用戶(hù)側(cè)任務(wù)優(yōu)先級(jí)別高,當(dāng)然用戶(hù)并不知道的任務(wù)按照background的級(jí)別,這就可以 up 和提高優(yōu)先級(jí)。
這是如何使用postTask的非常簡(jiǎn)單的例子??梢杂貌煌腡askController對(duì)象來(lái)區(qū)分,這樣能在不同的人物之間共享優(yōu)先級(jí)別,也能為不同的TaskController的實(shí)例變更優(yōu)先級(jí)。
重點(diǎn)提示
postTask()is not supported in all browsers. You can use feature detection to see if it's available, or consider using?a polyfill.
postTask并不是所有瀏覽器都支持。可以檢測(cè)是否空,或者考慮使用polyfill。
內(nèi)置不中斷的讓步方法
還有一個(gè)編排 api 目前還在提議階段,還沒(méi)有內(nèi)置到任何瀏覽器中。它的用法和本章和開(kāi)始講到的yieldToMain這個(gè)方法類(lèi)似。
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();
? }
}
這和之前的代碼大部分相似,但我們也能看到上面代碼并沒(méi)有使用yieldToMain,而是使用了await scheduler.yield方法。

下面三幅圖分別是不使用 yield,使用 yield,以及使用 yield 且不中斷。不使用 yield,出現(xiàn)了長(zhǎng)耗時(shí)任務(wù)。使用 yield,短任務(wù)數(shù)量變多了,而且還能被其他不相關(guān)的任務(wù)打斷。使用 yield 且不中斷,里面的短任務(wù)更多,但是執(zhí)行順序是固定的。
上面便是三種情況的效果圖。使用scheduler.yield方法時(shí),任務(wù)能在每次讓步停止后重新開(kāi)始。
使用scheduler.yield的好處是不中斷,也就意味著如果是在一連串任務(wù)中 yield ,那么從yield的時(shí)間點(diǎn)開(kāi)始,其他編排好的任務(wù)的執(zhí)行會(huì)繼續(xù)。這就能避免第三方j(luò)s腳本代碼阻塞代碼的執(zhí)行
結(jié)語(yǔ)
雖然管理任務(wù)富有挑戰(zhàn),但管理任務(wù)卻能受益頗多,網(wǎng)站能有更快的用戶(hù)交互體驗(yàn)。管理和調(diào)優(yōu)沒(méi)有萬(wàn)靈藥,但確有一系列不同的技巧。最后總結(jié)重申一下,管理任務(wù)時(shí)主要需要考慮以下幾點(diǎn):
遇到關(guān)鍵任務(wù)和用戶(hù)側(cè)的任務(wù)需要讓步于主線程
使用isInputPending來(lái)讓步主線程讓用戶(hù)可以與頁(yè)面交互
適應(yīng)postTask來(lái)調(diào)整任務(wù)的優(yōu)先級(jí)
最后,每個(gè)函數(shù)盡可能地減少活動(dòng)
使用以上一個(gè)或多個(gè)方法,就能夠?qū)?yīng)用中的任務(wù)進(jìn)行管理,根據(jù)用戶(hù)需要調(diào)整優(yōu)先級(jí),同時(shí)能保證相對(duì)不那么重要的工作得以繼續(xù)執(zhí)行,這樣給創(chuàng)造更好的用戶(hù)體驗(yàn),網(wǎng)站響應(yīng)更快,使用更令人愉悅。
參考鏈接
【 long task?】https://developer.mozilla.org/zh-CN/docs/Web/API/Long_Tasks_API
【 promise?】https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Promise
【 event loop?】https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop
【 main thread?】https://developer.mozilla.org/en-US/docs/Glossary/Main_thread
【 task controller?】https://developer.mozilla.org/en-US/docs/Web/API/TaskController
【 web performance?】https://developer.mozilla.org/en-US/docs/Web/Performance