實現(xiàn) React requestIdleCallback 調(diào)度能力
本文轉(zhuǎn)載自微信公眾號「ELab團隊」,作者ELab.lijiayu 。轉(zhuǎn)載本文請聯(lián)系ELab團隊公眾號。
1.前言
Elab掘金: React Fiber架構(gòu)淺析[1] 已對 React Fiber架構(gòu) 實現(xiàn)進(jìn)行了淺析。React內(nèi)部實現(xiàn)了該方法 requestIdleCallback,即一幀空閑執(zhí)行任務(wù),但Schedular + Lane 模式遠(yuǎn)比 requestIdleCallback 復(fù)雜的多。這里我們先通過了解 requestIdleCallback都做了些什么,再嘗試通過 requestAnimationFrame + MessageChannel 來模擬 React 對一幀空閑判斷的實現(xiàn)。
2.requestIdleCallback
window.requestIdleCallback()[2]
2.1 概念理解
圖: 簡單描述幀生命周期
RequestIdleCallback 簡單的說,判斷一幀有空閑時間,則去執(zhí)行某個任務(wù)。
目的是為了解決當(dāng)任務(wù)需要長時間占用主進(jìn)程,導(dǎo)致更高優(yōu)先級任務(wù)(如動畫或事件任務(wù)),無法及時響應(yīng),而帶來的頁面丟幀(卡死)情況。
故RequestIdleCallback 定位處理的是: 不重要且不緊急的任務(wù)。
RequestIdleCallback 參數(shù)說明:
- window.requestIdleCallback(callback[, options]); callback為要執(zhí)行的回調(diào)函數(shù),該函數(shù)會接收deadline作為對象。
- // 回調(diào)函數(shù) 接收 deadline
- type Deadline = {
- timeRemaining: () => number // 當(dāng)前剩余的可用時間。即該幀剩余時間。
- didTimeout: boolean // 是否超時。
- }
- // 接收回調(diào)任務(wù)
- type RequestIdleCallback = (cb: (deadline: Deadline) => void, options?: Options) => number
2.2 實現(xiàn)demo
requestIdleCallback 處理任務(wù)說明:
Demo: https://linjiayu6.github.io/FE-RequestIdleCallback-demo/
Github: RequestIdleCallback 實驗[3]
- const bindClick = id =>
- element(id).addEventListener('click', Work.onAsyncUnit)
- // 綁定click事件
- bindClick('btnA')
- bindClick('btnB')
- bindClick('btnC')
- var Work = {
- // 有1萬個任務(wù)
- unit: 10000,
- // 處理單個任務(wù)需要處理如下
- onOneUnit: function () { for (var i = 0; i <= 500000; i++) {} },
- // 處理任務(wù)
- onAsyncUnit: function () {
- // 空閑時間基準(zhǔn)為 1ms
- const FREE_TIME = 1
- // 執(zhí)行到第幾個任務(wù)
- let _u = 0
- function cb(deadline) {
- // 當(dāng)任務(wù)還沒有被處理完 & 一幀還有的空閑時間 > 1ms
- while (_u < Work.unit && deadline.timeRemaining() > FREE_TIME) {
- Work.onOneUnit()
- _u ++
- }
- // 任務(wù)干完, 執(zhí)行回調(diào)
- if (_u >= Work.unit) {
- // 執(zhí)行回調(diào)
- return
- }
- // 任務(wù)沒完成, 繼續(xù)等空閑執(zhí)行
- window.requestIdleCallback(cb)
- }
- window.requestIdleCallback(cb)
- }
- }
以上是 window.requestIdleCallback 的實現(xiàn)流程。
核心: 即瀏覽器去在一幀有空閑的情況下,去執(zhí)行某個低優(yōu)先級的任務(wù)。
2.3 缺陷
MAY BE OFFTOPIC: requestIdleCallback is called only 20 times per second - Chrome on my 6x2 core Linux machine, it's not really useful for UI work. requestAnimationFrame is called more often, but specific for the task which name suggests.[4]
- 實驗 api,兼容情況一般。
- 實驗結(jié)論: requestIdleCallback FPS只有20ms,正常情況下渲染一幀時長控制在16.67ms (1s / 60 = 16.67ms)。該時間是高于頁面流暢的訴求。
- 個人認(rèn)為: RequestIdleCallback 不重要且不緊急的定位。因為React渲染內(nèi)容,并非是不重要且不緊急。不僅該api兼容一般,幀渲染能力一般,也不太符合渲染訴求,故React 團隊自行實現(xiàn)。
3.React requestIdleCallback 實現(xiàn)實驗
想要實現(xiàn)requestIdleCallback的處理,有2個點需要解決:
- When: 如何判斷一幀是否有空閑?
- Where: 如果有了空閑,在一幀中哪里去執(zhí)行任務(wù)?
3.1 requestAnimationFrame 計算一幀到期時間點
requestAnimationFrame[5]
是由系統(tǒng)來決定回調(diào)函數(shù)的執(zhí)行時機。 它會把每一幀中的所有DOM操作集中起來,在一次重繪或回流中就完成,并且重繪或回流的時間間隔緊緊跟隨屏幕的刷新頻率,不會引起丟幀和卡頓。
瀏覽器刷新率在60Hz, 渲染一幀時長控制在16.67ms (1s / 60 = 16.67ms)。
DOMHighResTimeStamp[6]
requestAnimationFrame 參數(shù)如下:
- // 回調(diào)函數(shù) 接收 rafTime 即 開始執(zhí)行一幀的開始時間
- // 接收回調(diào)任務(wù)
- type RequestAnimationFrame = (cb: (rafTime: number) => void)
計算一幀用到期的時間點。
- // 計算出當(dāng)前幀 結(jié)束時間
- var deadlineTime;
- window.selfRequestIdleCallback = function (cb) {
- requestAnimationFrame(rafTime => {
- // 結(jié)束時間 = 開始時間 + 一幀用時16.667ms
- deadlineTime = rafTime + 16.667
- // ......
- })
- }
以上使用 requestAnimationFrame 來計算結(jié)束的時間點。
我們暫且將空閑時間的判斷放到后面去解決,先來看在時間充裕情況下,在什么時機去執(zhí)行某任務(wù)。
3.2 MessageChannel 宏任務(wù) 執(zhí)行任務(wù)
MessageChannel()[7]
MessageChannel創(chuàng)建了一個通信的管道,這個管道有兩個端口,每個端口都可以通過postMessage發(fā)送數(shù)據(jù),而一個端口只要綁定了onmessage回調(diào)方法,就可以接收從另一個端口傳過來的數(shù)據(jù)。
在看著方法實現(xiàn)之前,你可能有疑問:
為什么使用宏任務(wù)處理呢?
核心是將主進(jìn)程讓出,將瀏覽器去更新頁面。
利用事件循環(huán)機制,在下一幀宏任務(wù)的時候,執(zhí)行未完成的任務(wù)。
為什么不是微任務(wù)?
走遠(yuǎn)了。對一個事件循環(huán)機制來說,在頁面更新前,會將所有的微任務(wù)全部執(zhí)行完,故無法達(dá)成將主線程讓出給瀏覽器的目的。
既然用了宏任務(wù),那為什么不使用 setTimeout 宏任務(wù)執(zhí)行呢?
如果不支持MessageChannel的話,就會去用 setTimeout 來執(zhí)行,只是退而求其次的辦法。
現(xiàn)實情況是: 瀏覽器在執(zhí)行 setTimeout() 和 setInterval() 時,會設(shè)定一個最小的時間閾值,一般是 4ms。
- var i = 0
- var _start = +new Date()
- function fn() {
- setTimeout(() => {
- console.log("執(zhí)行次數(shù), 時間", ++i, +new Date() - _start)
- if (i === 10) {
- return
- }
- fn()
- }, 0)
- }
- fn()
故,利用MessageChannel來執(zhí)行宏任務(wù),且模擬setTimeout(fn, 0),還沒有時延哦。
實現(xiàn)如下:
- // 計算出當(dāng)前幀 結(jié)束時間點
- var deadlineTime
- // 保存任務(wù)
- var callback
- // 建立通信
- var channel = new MessageChannel()
- var port1 = channel.port1;
- var port2 = channel.port2;
- // 接收并執(zhí)行宏任務(wù)
- port2.onmessage = () => {
- // 判斷當(dāng)前幀是否還有空閑,即返回的是剩下的時間
- const timeRemaining = () => deadlineTime - performance.now();
- const _timeRemain = timeRemaining();
- // 有空閑時間 且 有回調(diào)任務(wù)
- if (_timeRemain > 0 && callback) {
- const deadline = {
- timeRemaining, // 計算剩余時間
- didTimeout: _timeRemain < 0 // 當(dāng)前幀是否完成
- }
- // 執(zhí)行回調(diào)
- callback(deadline)
- }
- }
- window.requestIdleCallback = function (cb) {
- requestAnimationFrame(rafTime => {
- // 結(jié)束時間點 = 開始時間點 + 一幀用時16.667ms
- deadlineTime = rafTime + 16.667
- // 保存任務(wù)
- callback = cb
- // 發(fā)送個宏任務(wù)
- port1.postMessage(null);
- })
- }
4.React 源碼 requestHostCallback
SchedulerHostConfig.js[8]
執(zhí)行宏任務(wù)(回調(diào)任務(wù))
- requestHostCallback: 觸發(fā)一個宏任務(wù) performWorkUntilDeadline。
- performWorkUntilDeadline: 宏任務(wù)處理。
- 是否有富裕時間, 有則執(zhí)行。
- 執(zhí)行該回調(diào)任務(wù)后,是否還有下一個回調(diào)任務(wù), 即判斷 hasMoreWork。
- 有則繼續(xù)執(zhí)行 port.postMessage(null);
- let scheduledHostCallback = null;
- let isMessageLoopRunning = false;
- const channel = new MessageChannel();
- // port2 發(fā)送
- const port = channel.port2;
- // port1 接收
- channel.port1.onmessage = performWorkUntilDeadline;
- const performWorkUntilDeadline = () => {
- // 有執(zhí)行任務(wù)
- if (scheduledHostCallback !== null) {
- const currentTime = getCurrentTime();
- // Yield after `yieldInterval` ms, regardless of where we are in the vsync
- // cycle. This means there's always time remaining at the beginning of
- // the message event.
- // 計算一幀的過期時間點
- deadline = currentTime + yieldInterval;
- const hasTimeRemaining = true;
- try {
- // 執(zhí)行完該回調(diào)后, 判斷后續(xù)是否還有其他任務(wù)
- const hasMoreWork = scheduledHostCallback(
- hasTimeRemaining,
- currentTime,
- );
- if (!hasMoreWork) {
- isMessageLoopRunning = false;
- scheduledHostCallback = null;
- } else {
- // If there's more work, schedule the next message event at the end
- // of the preceding one.
- // 還有其他任務(wù), 推進(jìn)進(jìn)入下一個宏任務(wù)隊列中
- port.postMessage(null);
- }
- } catch (error) {
- // If a scheduler task throws, exit the current browser task so the
- // error can be observed.
- port.postMessage(null);
- throw error;
- }
- } else {
- isMessageLoopRunning = false;
- }
- // Yielding to the browser will give it a chance to paint, so we can
- // reset this.
- needsPaint = false;
- };
- // requestHostCallback 一幀中執(zhí)行任務(wù)
- requestHostCallback = function(callback) {
- // 回調(diào)注冊
- scheduledHostCallback = callback;
- if (!isMessageLoopRunning) {
- isMessageLoopRunning = true;
- // 進(jìn)入宏任務(wù)隊列
- port.postMessage(null);
- }
- };
- cancelHostCallback = function() {
- scheduledHostCallback = null;
- };
參考資料
[1]Elab掘金: React Fiber架構(gòu)淺析: https://juejin.cn/post/7005880269827735566
[2]window.requestIdleCallback(): https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback
[3]RequestIdleCallback 實驗: https://github.com/Linjiayu6/FE-RequestIdleCallback-demo
[4]MAY BE OFFTOPIC: requestIdleCallback is called only 20 times per second - Chrome on my 6x2 core Linux machine, it's not really useful for UI work. requestAnimationFrame is called more often, but specific for the task which name suggests.: https://github.com/facebook/react/issues/13206#issuecomment-418923831
[5]requestAnimationFrame: https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame
[6]DOMHighResTimeStamp: https://developer.mozilla.org/zh-CN/docs/Web/API/DOMHighResTimeStamp
[7]MessageChannel(): https://developer.mozilla.org/zh-CN/docs/Web/API/MessageChannel/MessageChannel
[8]SchedulerHostConfig.js: https://github.com/facebook/react/blob/v17.0.1/packages/scheduler/src/forks/SchedulerHostConfig.default.js