React源碼Scheduler(三)React的調度算法實現

NO IMAGE

本文源碼基於 React 16.8.6 (March 27, 2019),僅記錄一些個人閱讀源碼的分享與體會。

歡迎大家交流和探討

前言

在上兩節中,筆者介紹了在瀏覽器中存在的 requestAnimationFramerequestIdleCallback 兩種調度方法及在 React 中一個任務的調度流程。同時,讀者也瞭解了 React 團隊採用了 requestIdleCallback 的形式實現調度,但由於該 API 的兼容性和實際渲染頻率的因素,React 團隊最終自己實現了一個內部的該函數。

在本節中,我們就將詳細介紹在瀏覽器中,React 內部是如何實現自己的調度算法。

概覽

本文中所涉及的源碼位於 packages/scheduler/src/forks/SchedulerHostConfig.default.js

為了更好的對文件整體有個好的認知,我們依舊從類圖入手。

React源碼Scheduler(三)React的調度算法實現

從成員變量的 rAFIDrAFTimeoutID 來看,React 使用了 requestAnimationFramesetTimeout 兩種方案模擬 requestIdleCallback。除去幾個 bool 變量外,我們需要關注 4 個時間標識timeoutTime,frameDeadline,previourFrameTime,activeFrameTime 和 1 個 MessageChannel。通過任務到期時間及當前幀與上一幀的時間信息,計算分片時間。之後通過 MessageChannel 的 microTask 執行異步調度,這就是 React 調度實現的一個大體思路。

MessageChannel 形成一個通信管道,允許數據從一端透傳到另一端,應用於 websocket 數據傳遞。

源碼解析

requestHostCallback

調度的入口函數為requestHostCallback,也就是筆者在上一節中遺漏的幾個函數之一。函數接收外部傳入的需調度的任務和超時時間來決定任務是否立即執行或者開啟調度任務。

  requestHostCallback = function(callback, absoluteTimeout) {
scheduledHostCallback = callback;
timeoutTime = absoluteTimeout;
// 已經超時就直接執行無需調度
if (isFlushingHostCallback || absoluteTimeout < 0) {
port.postMessage(undefined);
} else if (!isAnimationFrameScheduled) { // 未超時且沒調度開啟一個調度任務
isAnimationFrameScheduled = true;
requestAnimationFrameWithTimeout(animationTick);
}
};

通過源碼可以看到開啟調度任務的方法實際為 MessageChannel 通信,之所以不採用直接調用方法。筆者猜想一方面是因為調度函數可能存在異步邏輯等阻礙線程執行,另一方面在 js 事件循環隊列裡 microTask 的任務優先級高,便於加快執行。

我們暫且跳過 postMessage 的內容,先看看需要調度時執行的邏輯。

requestAnimationFrameWithTimeout

const requestAnimationFrameWithTimeout = function(callback) {
// 同時調度 setTimeout 和 requestAnimationFrame
rAFID = localRequestAnimationFrame(function(timestamp) {
// 取消 timeout
localClearTimeout(rAFTimeoutID);
callback(timestamp);
});
rAFTimeoutID = localSetTimeout(function() {
// 取消 requestAnimationFrame
localCancelAnimationFrame(rAFID);
callback(getCurrentTime());
}, 100);
};

這裡我們看到,在調度函數中同時使用了 setTimeoutrequestAnimationFrame。一般而言,第一眼看到都會產生:因為兼容性問題,用 setTimout 作為兜底方案的想法。暫且不說 React 實際先通過能力檢查校驗過方法存在,僅 setTimeout 100ms 的參數就告訴了我們降級假設是錯誤的。那麼,是否存在 requestAnimationFrame 無法生效的場景呢? requestAnimationFrame 是根據刷新率每一幀進行調用,當頁面位於後臺不可見時,實際上函數是不會被調用的。因此,為了保證頁面在後臺仍能成功執行任務,採用了低頻率的 setTimeout 方案作為共存。

這裡還有一點,對於當前時間的選擇,採用的方案是以 Performance.now() 優先,Date.now() 兜底的策略。對此,stackoverflow 上有關解釋表示因為 Performance.now() 具有更高的精確度。至於是否還有其它方面的考量,歡迎闡述你的想法。

animationTick

animationTick 顧名思義如時鐘滴答般記錄動畫的時長,也是 React 調度裡對於各個幀時長的計算之處。總的來說,筆者覺得這是一個挺有趣的函數。

const animationTick = function(rafTime) {
if (scheduledHostCallback !== null) {
requestAnimationFrameWithTimeout(animationTick);
} else { /*...*/ return; }
}
// frameDeadline: 上一幀的 rafTime + activeFrameTime
let nextFrameTime = rafTime - frameDeadline + activeFrameTime;
if (
nextFrameTime < activeFrameTime &&
previousFrameTime < activeFrameTime &&
!fpsLocked
) {
if (nextFrameTime < 8) {
// 防禦性代碼,不支持超過 120hz 的頻率
nextFrameTime = 8;
}
// 啟發式動態調整 activeFrameTime
activeFrameTime =
nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime;
} else {
previousFrameTime = nextFrameTime;
}
frameDeadline = rafTime + activeFrameTime;
if (!isMessageEventScheduled) {
isMessageEventScheduled = true;
port.postMessage(undefined);
}
};

筆者之前寫這類遞歸函數,都是在函數尾寫的,而 React 在函數開頭的執行頓時眼前一亮。對此,代碼註釋的官方解釋是這樣的。

將回調放在幀的首部確保了它會在最鄰近的幀內被調用
如果將回調放在函數尾部,將會冒瀏覽器跳過一幀直到下下幀才觸發回調的風險

不得不說,這個細節值得我們學習。

接著往下,我們看到了 React 內部對於每幀執行 js 任務的耗時的計算公式。下一幀的時間(nextFrameTime) = 當前時間(rafTime) - 上一幀的時間(frameDeadline) + 活躍幀的時間(activeFrameTime)。而activeFrameTime 有個初始值為 33,也就是說 1s 約渲染 30 幀。而 React 官方支持的最高幀數是 120。因此必然需要一個啟發式機制來根據屏幕刷新率更改該值,也就是接下來的代碼段。

React 團隊認為,當連續兩個幀的執行時間,都小於我們預設的 activeFrameTime,那麼我們認為我們處於一個高刷新率的機器上運行,因此動態調整為前兩幀中較大一幀的時間。

之後便是記錄當前幀的終止時間,一個很簡單的公式:當前時間(rafTime) + 活躍幀的時間(activeFrameTime)

最後,在沒有任務正處理情況下通過 postMessage 進行任務的調度處理。

onmessage

筆者說過 React 通過 MessageChannel 進行異步調度,通過一個端口 postMessage 發送消息,另一個端口接收處理消息。

channel.port1.onmessage = function(event) {
isMessageEventScheduled = false;
const prevScheduledCallback = scheduledHostCallback;
const prevTimeoutTime = timeoutTime;
scheduledHostCallback = null;
timeoutTime = -1;
const currentTime = getCurrentTime();
let didTimeout = false;
// 如果沒時間了
if (frameDeadline - currentTime <= 0) {
if (prevTimeoutTime !== -1 && prevTimeoutTime <= currentTime) { // 判斷是否任務超時
didTimeout = true;
} else { // 沒時間且沒任務未超時,重新調度
if (!isAnimationFrameScheduled) {
isAnimationFrameScheduled = true;
requestAnimationFrameWithTimeout(animationTick);
}
// 恢復現場
scheduledHostCallback = prevScheduledCallback;
timeoutTime = prevTimeoutTime;
return;
}
}
// 如果有時間或者超時了,執行任務
if (prevScheduledCallback !== null) {
isFlushingHostCallback = true;
try {
prevScheduledCallback(didTimeout);
} finally {
isFlushingHostCallback = false;
}
}
};

在看過上一節的讀者其實會發現,這段和 Scheduler 裡的 flushWork 其實有異曲同工之處。首先獲取 requestHostCallback 裡獲得的回調方法,在有空閒時間或者任務超時的時候執行任務,在沒時間未超時的情況下重新進行調度,等待下一幀的機會。

總結

同樣的,讓筆者以一個流程圖進行總結。

React源碼Scheduler(三)React的調度算法實現

至此,關於 React 調度相關的源碼閱讀也就到此一段落了。其實從源碼的閱讀上筆者發現,大部分情況下軟件開發框架設計其實用不到很高深的數學功底(當然數學好也是很重要的)。更多的關注點在於對細節的把控,如遞歸回調的位置,啟發式幀時間的修改等。還有就是對功能模塊的抽象,這才是一個項目可發展的根基。

相關文章

設計模式之歡迎來到設計模式世界(二)

  設計模式之歡迎來到設計模式世界(一)

學習設計模式前傳

函數式編程在前端權限管理中的應用