當前位置: 妍妍網 > 碼農

瀏覽器也擁有了原生的 「時間切片」 能力!

2024-02-02碼農

前言


大家好,我是林三心,用最通俗易懂的話講最難的知識點是我的座右銘,基礎是進階的前提是我的初心~

就在 Chrome 115 版本,瀏覽器開始了對 scheduler.yield 的灰度測試。 scheduler.yield scheduler API 中新增的一個功能,它能以更簡單、更好的方式將控制權交還給主執行緒。在開始講解這個 API 之前,我們先來看一個新的效能指標。

下次繪制互動 (INP)

下次繪制互動 (INP) 是一項新的指標,瀏覽器計劃於 2024 年 3 月將其取代取代首次輸入延遲 (FID) ,成為最新的 Web Core Vitals (Web 核心效能指標,可以看我這篇文章: )。

Chrome 使用數據顯示,使用者在頁面上花費的時間有 90% 是在網頁載入完成後花費的,因此,仔細測量整個頁面生命周期的響應能力是非常重要的,這就是 INP 指標評估的內容。

良好的響應能力意味著頁面可以快速響應並且與使用者進行的互動。當頁面響應互動時,最直接的結果就是視覺反饋,由瀏覽器在瀏覽器渲染的下一幀中體現。例如,視覺反饋會告訴我們是否確實添加了購物車的商品、是否快讀開啟了導航選單、伺服器是否正在對登入表單的內容進行身份驗證等等。 INP 的目標就是確保對於使用者進行的所有或大多數互動,從使用者發起互動到繪制下一幀的時間盡可能短。

INP 是一種指標,透過觀察使用者存取頁面的整個生命周期中發生的所有單擊、敲擊和鍵盤互動的延遲來評估頁面對使用者互動的整體響應能力。

互動是在同一邏輯使用者手勢期間觸發的一組事件處理常式。例如,觸控式螢幕裝置上的 「點選」 互動包括多個事件,例如 pointerup、pointerdown click 。互動可以由 JavaScript、CSS 、內建瀏覽器控制項或其組合驅動。

互動的延遲就是由驅動互動的這一組事件處理常式的單個最長持續時間組成的,從使用者開始互動到渲染下一幀視覺反饋的時間。

INP 考慮的是所有頁面的互動,而首次輸入延遲 ( FID ) 只會考慮第一次互動。而且它只測量了第一次互動的輸入延遲,而不是執行事件處理常式所需的時間或下一幀渲染的延遲。

瀏覽器希望使用 INP 替代 FID 就意味著使用者的互動體驗越來越重要了,我們常常聽到的時間切片的概念,實際上就是為了提升網頁的互動響應能力。

時間切片

JavaScript 使用 run-to-completion 模型來處理任務。這意味著,當任務在主執行緒上執行時,該任務將執行必要的時間才能完成。任務完成後,控制權交會還給主執行緒,這樣主執行緒就可以處理佇列中的下一個任務。

除了任務永遠不會完成的極端情況(例如無限迴圈)之外,屈服是 JavaScript 任務排程邏輯不可避免的一個方面。屈服遲早會發生,只是時間問題,而且越早越好。當任務執行時間過長(準確地說超過 50 毫秒)時,它們會被視為長任務。

長任務是頁面響應能力差的一個根源,因為它們延遲了瀏覽器響應使用者輸入的能力。長任務發生的次數越多,而且執行的時間越長,使用者就越有可能感覺到頁面執行緩慢,甚至感覺頁面完全掛掉了。

不過,程式碼在瀏覽器中啟動任務並不意味著必須等到任務完成後才能將控制權交還給主執行緒。你可以透過在任務中明確交出控制權來提高對頁面上使用者輸入的響應速度,這樣就能在下一個合適的時間來完成任務。這樣,其他任務就能更快地在主執行緒上獲得時間,而不必等待長任務的完成。

這張圖可以很直觀的顯示:在上面的執行中,只有在任務執行完成後才會交還控制權,這意味著任務可能需要更長時間才能完成,然後才會將控制權交還給主執行緒。在下面,控制權交還是主動進行的,將一個較長的任務分解成多個較小的任務。這樣,使用者互動可以更快地執行,從而提高輸入響應速度和 INP

當我們想要明確屈服時,就是在告訴瀏覽器 「嘿,我知道我要做的工作可能需要一段時間,並且我不希望你在響應使用者輸入之前必須完成所有這些工作或其他可能也很重要的任務」。

聽起來這個是不是很熟悉?這其實就是我們常說的 「時間切片」 的概念,之前你聽到可能還是在 React 的理念裏,因為它是最早提出這個能力的前端框架。我們再來回顧下面這個典型的例子:

舊版 React 架構是遞迴同步更新的,如果節點非常多,即使只有一次 state 變更, React 也需要進行復雜的遞迴更新,更新一旦開始,中途就無法中斷,直到遍歷完整顆樹,才能釋放主執行緒。

當渲染的層級很深時,遞迴更新時間超過了16ms,如果這時有使用者操作或動畫渲染等,就會表現為卡頓:

後來, React 實作了自己的 Scheduler ,它可以將一次耗時很長的更新任務被拆分成一小段一小段的。這樣瀏覽器就有剩余時間執行樣式布局和樣式繪制,減少掉幀的可能性。

每個小的任務完成後,控制權就會交還給主執行緒,瀏覽器就有了時間去及時的完成使用者的互動或頁面的繪制,所以頁面會很絲滑:

這個思路太棒了,在原生的 JavaScript 程式碼,或者其他框架中我們也想要這樣的能力怎麽辦?

使用 setTimeout

一種常見的過渡方法是使用時間為 0 的 setTimeout 。這種方法之所以有效,是因為傳遞給 setTimeout 的回呼會將剩余工作轉移到一個單獨的任務中,這個任務將排隊等待後續執行,這樣也可以實作把一大塊工作分成更小的部份。

但是,使用 setTimeout 進行屈服可能會帶來不良的副作用:屈服之後的工作將進入任務佇列的最尾部。透過使用者互動安排的任務仍會排在任務佇列的前面,但你想做的剩余工作可能會被排在它前面的其他任務進一步延遲。

我們可以看一個下面的範例:

functionblockingTask (ms = 200{
let arr = [];
const blockingStart = performance.now();
console.log(`Synthetic task running for ${ms} ms`);
while (performance.now() < (blockingStart + ms)) {
arr.push(Math.random() * performance.now / blockingStart / ms);
}
}
functionyieldToMain () {
returnnewPromise(resolve => {
setTimeout(resolve, 0);
});
}
asyncfunctionrunTaskQueueSetTimeout () {
if (typeof intervalId === "undefined") {
alert("Click the button to run blocking tasks periodically first.");
return;
}
clearTaskLog();
for (const item of [12345]) {
blockingTask();
logTask(`Processing loop item ${item}`);
await yieldToMain();
}
}
document.getElementById("setinterval").addEventListener("click", ({ target }) => {
clearTaskLog();
intervalId = setInterval(() => {
if (taskOutputLines < MAX_TASK_OUTPUT_LINES) {
blockingTask();
logTask("Ran blocking task via setInterval");
}
});
target.setAttribute("disabled"true);
}, {
oncetrue
});
document.getElementById("settimeout").addEventListener("click", () => {
runTaskQueueSetTimeout();
});











我們先透過 setinterval 來定期執行一些任務,下面我們來使用 setTimeout 來模擬時間切片,將長任務進行拆解,我們會得到下面這樣的打印結果:

Processing loop item 1
Processing loop item 2
Ran blocking task via setInterval
Processing loop item 3
Ran blocking task via setInterval
Processing loop item 4
Ran blocking task via setInterval
Processing loop item 5
Ran blocking task via setInterval
Ran blocking task via setInterval

很多指令碼(尤其是第三方指令碼)經常會註冊一個定時器函式,在某個時間間隔內執行工作。使用 setTimeout 來拆解長任務意味著,來自其他任務源的工作可能會排在結束事件迴圈後必須完成的剩余工作之前。

這也許能夠起到一定的作用,但在許多情況下,這種行為是開發者不願輕易放棄主執行緒控制權的原因。能主動交出控制權是好事,因為使用者互動有機會更快地執行,但它也會讓其他非使用者互動的工作在主執行緒上獲得時間。這確實是個問題, scheduler.yield 可以幫助解決這個問題!

scheduler.yield

我們需要註意一下,交出主執行緒控制權並不是 setTimeout 的設計目標,它的核心目標是能在未來某個時間完成某個任務,所以它會把任務中的工作排在佇列的最後面。

但是,與之相反,預設情況下, scheduler.yield 會將剩余的工作發送到佇列的前面。這意味著你想要在 yield 後立即恢復的工作不會讓位於其他來源的任務(使用者互動除外)。

scheduler.yield 是一個向主執行緒主動屈服並在呼叫時返回 Promise 的函式。這意味著你可以在異步函式中等待它:

asyncfunctionyieldy () {
// Do some work...
// ...
// Yield!
await scheduler.yield();
// Do some more work...
// ...
}

還是使用前面的例子,這次我們使用 scheduler.yield 進行等待:

asyncfunctionrunTaskQueueSchedulerDotYield () {
if (typeof intervalId === "undefined") {
alert("Click the button to run blocking tasks periodically first.");
return;
}
if ("scheduler"inwindow && "yield"in scheduler) {
clearTaskLog();
for (const item of [12345]) {
blockingTask();
logTask(`Processing loop item ${item}`);
await scheduler.yield();
}
else {
alert("scheduler.yield isn't available in this browser :(");
}
}


我們會發現打印的結果是這樣的:

Processing loop item 1
Processing loop item 2
Processing loop item 3
Processing loop item 4
Processing loop item 5
Ran blocking task via setInterval
Ran blocking task via setInterval
Ran blocking task via setInterval
Ran blocking task via setInterval
Ran blocking task via setInterval

這樣就可以達到兩全其美的效果:既能將長任務進行分割,主動給主執行緒讓出控制權來提高網站的互動響應速度,又能確保讓出主執行緒後要完成的工作不會被延遲。

試用

如果大家對 Scheduler.yield 感興趣並且想嘗試一下,從 Chrome 115 版本開始可以:

開啟 chrome://flags ,然後選擇啟用 Experimental Web Platform Features ,這樣就可以使用 Scheduler.yield 了。也可以嘗試使用官方提供的 Polifill :https://github.com/GoogleChromeLabs/scheduler-polyfill

如果在業務程式碼裏使用,為了相容不支持的低版本瀏覽器,可以在不支持時回退到 setTimeout 寫法:

// A function for shimming scheduler.yield and setTimeout:
functionyieldToMain () {
// Use scheduler.yield if it exists:
if ('scheduler'inwindow && 'yield'in scheduler) {
return scheduler.yield();
}
// Fall back to setTimeout:
returnnewPromise(resolve => {
setTimeout(resolve, 0);
});
}
// Example usage:
asyncfunctiondoWork () {
// Do some work:
// ...
await yieldToMain();
// Do some other work:
// ...
}


當然,如果你不想讓你的任務被其他任務延遲掉,也可以在不支持這個 API 時選擇不屈服:

// A function for shimming scheduler.yield with no fallback:
functionyieldToMain () {
// Use scheduler.yield if it exists:
if ('scheduler'inwindow && 'yield'in scheduler) {
return scheduler.yield();
}
// Fall back to nothing:
return;
}
// Example usage:
asyncfunctiondoWork () {
// Do some work:
// ...
await yieldToMain();
// Do some other work:
// ...
}


結語

我是林三心

  • 一個待過 小型toG型外包公司、大型外包公司、小公司、潛力型創業公司、大公司 的作死型前端選手;

  • 一個偏前端的全幹工程師;

  • 一個不正經的金塊作者;

  • 逗比的B站up主;

  • 不帥的小紅書博主;

  • 喜歡打鐵的籃球菜鳥;

  • 喜歡歷史的乏味少年;

  • 喜歡rap的五音不全弱雞如果你想一起學習前端,一起摸魚,一起研究簡歷最佳化,一起研究面試進步,一起交流歷史音樂籃球rap,可以來俺的摸魚學習群哈哈,點這個,有7000多名前端小夥伴在等著一起學習哦 -->

  • 廣州的兄弟可以約飯哦,或者約球~我負責打鐵,你負責進球,謝謝~