實現 React requestIdleCallback 調度能力
本文轉載自微信公眾號「ELab團隊」,作者ELab.lijiayu 。轉載本文請聯系ELab團隊公眾號。
1.前言
Elab掘金: React Fiber架構淺析[1] 已對 React Fiber架構 實現進行了淺析。React內部實現了該方法 requestIdleCallback,即一幀空閑執行任務,但Schedular + Lane 模式遠比 requestIdleCallback 復雜的多。這里我們先通過了解 requestIdleCallback都做了些什么,再嘗試通過 requestAnimationFrame + MessageChannel 來模擬 React 對一幀空閑判斷的實現。
2.requestIdleCallback
window.requestIdleCallback()[2]
2.1 概念理解
圖: 簡單描述幀生命周期
RequestIdleCallback 簡單的說,判斷一幀有空閑時間,則去執行某個任務。
目的是為了解決當任務需要長時間占用主進程,導致更高優先級任務(如動畫或事件任務),無法及時響應,而帶來的頁面丟幀(卡死)情況。
故RequestIdleCallback 定位處理的是: 不重要且不緊急的任務。
RequestIdleCallback 參數說明:
- window.requestIdleCallback(callback[, options]); callback為要執行的回調函數,該函數會接收deadline作為對象。
- // 回調函數 接收 deadline
- type Deadline = {
- timeRemaining: () => number // 當前剩余的可用時間。即該幀剩余時間。
- didTimeout: boolean // 是否超時。
- }
- // 接收回調任務
- type RequestIdleCallback = (cb: (deadline: Deadline) => void, options?: Options) => number
2.2 實現demo
requestIdleCallback 處理任務說明:
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萬個任務
- unit: 10000,
- // 處理單個任務需要處理如下
- onOneUnit: function () { for (var i = 0; i <= 500000; i++) {} },
- // 處理任務
- onAsyncUnit: function () {
- // 空閑時間基準為 1ms
- const FREE_TIME = 1
- // 執行到第幾個任務
- let _u = 0
- function cb(deadline) {
- // 當任務還沒有被處理完 & 一幀還有的空閑時間 > 1ms
- while (_u < Work.unit && deadline.timeRemaining() > FREE_TIME) {
- Work.onOneUnit()
- _u ++
- }
- // 任務干完, 執行回調
- if (_u >= Work.unit) {
- // 執行回調
- return
- }
- // 任務沒完成, 繼續等空閑執行
- window.requestIdleCallback(cb)
- }
- window.requestIdleCallback(cb)
- }
- }
以上是 window.requestIdleCallback 的實現流程。
核心: 即瀏覽器去在一幀有空閑的情況下,去執行某個低優先級的任務。
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,兼容情況一般。
- 實驗結論: requestIdleCallback FPS只有20ms,正常情況下渲染一幀時長控制在16.67ms (1s / 60 = 16.67ms)。該時間是高于頁面流暢的訴求。
- 個人認為: RequestIdleCallback 不重要且不緊急的定位。因為React渲染內容,并非是不重要且不緊急。不僅該api兼容一般,幀渲染能力一般,也不太符合渲染訴求,故React 團隊自行實現。
3.React requestIdleCallback 實現實驗
想要實現requestIdleCallback的處理,有2個點需要解決:
- When: 如何判斷一幀是否有空閑?
- Where: 如果有了空閑,在一幀中哪里去執行任務?
3.1 requestAnimationFrame 計算一幀到期時間點
requestAnimationFrame[5]
是由系統來決定回調函數的執行時機。 它會把每一幀中的所有DOM操作集中起來,在一次重繪或回流中就完成,并且重繪或回流的時間間隔緊緊跟隨屏幕的刷新頻率,不會引起丟幀和卡頓。
瀏覽器刷新率在60Hz, 渲染一幀時長控制在16.67ms (1s / 60 = 16.67ms)。
DOMHighResTimeStamp[6]
requestAnimationFrame 參數如下:
- // 回調函數 接收 rafTime 即 開始執行一幀的開始時間
- // 接收回調任務
- type RequestAnimationFrame = (cb: (rafTime: number) => void)
計算一幀用到期的時間點。
- // 計算出當前幀 結束時間
- var deadlineTime;
- window.selfRequestIdleCallback = function (cb) {
- requestAnimationFrame(rafTime => {
- // 結束時間 = 開始時間 + 一幀用時16.667ms
- deadlineTime = rafTime + 16.667
- // ......
- })
- }
以上使用 requestAnimationFrame 來計算結束的時間點。
我們暫且將空閑時間的判斷放到后面去解決,先來看在時間充裕情況下,在什么時機去執行某任務。
3.2 MessageChannel 宏任務 執行任務
MessageChannel()[7]
MessageChannel創建了一個通信的管道,這個管道有兩個端口,每個端口都可以通過postMessage發送數據,而一個端口只要綁定了onmessage回調方法,就可以接收從另一個端口傳過來的數據。
在看著方法實現之前,你可能有疑問:
為什么使用宏任務處理呢?
核心是將主進程讓出,將瀏覽器去更新頁面。
利用事件循環機制,在下一幀宏任務的時候,執行未完成的任務。
為什么不是微任務?
走遠了。對一個事件循環機制來說,在頁面更新前,會將所有的微任務全部執行完,故無法達成將主線程讓出給瀏覽器的目的。
既然用了宏任務,那為什么不使用 setTimeout 宏任務執行呢?
如果不支持MessageChannel的話,就會去用 setTimeout 來執行,只是退而求其次的辦法。
現實情況是: 瀏覽器在執行 setTimeout() 和 setInterval() 時,會設定一個最小的時間閾值,一般是 4ms。
- var i = 0
- var _start = +new Date()
- function fn() {
- setTimeout(() => {
- console.log("執行次數, 時間", ++i, +new Date() - _start)
- if (i === 10) {
- return
- }
- fn()
- }, 0)
- }
- fn()
故,利用MessageChannel來執行宏任務,且模擬setTimeout(fn, 0),還沒有時延哦。
實現如下:
- // 計算出當前幀 結束時間點
- var deadlineTime
- // 保存任務
- var callback
- // 建立通信
- var channel = new MessageChannel()
- var port1 = channel.port1;
- var port2 = channel.port2;
- // 接收并執行宏任務
- port2.onmessage = () => {
- // 判斷當前幀是否還有空閑,即返回的是剩下的時間
- const timeRemaining = () => deadlineTime - performance.now();
- const _timeRemain = timeRemaining();
- // 有空閑時間 且 有回調任務
- if (_timeRemain > 0 && callback) {
- const deadline = {
- timeRemaining, // 計算剩余時間
- didTimeout: _timeRemain < 0 // 當前幀是否完成
- }
- // 執行回調
- callback(deadline)
- }
- }
- window.requestIdleCallback = function (cb) {
- requestAnimationFrame(rafTime => {
- // 結束時間點 = 開始時間點 + 一幀用時16.667ms
- deadlineTime = rafTime + 16.667
- // 保存任務
- callback = cb
- // 發送個宏任務
- port1.postMessage(null);
- })
- }
4.React 源碼 requestHostCallback
SchedulerHostConfig.js[8]
執行宏任務(回調任務)
- requestHostCallback: 觸發一個宏任務 performWorkUntilDeadline。
- performWorkUntilDeadline: 宏任務處理。
- 是否有富裕時間, 有則執行。
- 執行該回調任務后,是否還有下一個回調任務, 即判斷 hasMoreWork。
- 有則繼續執行 port.postMessage(null);
- let scheduledHostCallback = null;
- let isMessageLoopRunning = false;
- const channel = new MessageChannel();
- // port2 發送
- const port = channel.port2;
- // port1 接收
- channel.port1.onmessage = performWorkUntilDeadline;
- const performWorkUntilDeadline = () => {
- // 有執行任務
- 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 {
- // 執行完該回調后, 判斷后續是否還有其他任務
- 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.
- // 還有其他任務, 推進進入下一個宏任務隊列中
- 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 一幀中執行任務
- requestHostCallback = function(callback) {
- // 回調注冊
- scheduledHostCallback = callback;
- if (!isMessageLoopRunning) {
- isMessageLoopRunning = true;
- // 進入宏任務隊列
- port.postMessage(null);
- }
- };
- cancelHostCallback = function() {
- scheduledHostCallback = null;
- };
參考資料
[1]Elab掘金: React Fiber架構淺析: 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