深入理解 scheduler 原理
React
運行時,如果把別的部分比喻成我們的肢體用來執行具體的動作,那麼scheduler
就相當於我們的大腦,調度中心位於scheduler
包中,理解清楚scheduler
爲我們理解react
的工作流程有很大的裨益。
前言
我們都知道react
可以運行在node
環境中和瀏覽器環境中,所以在不同環境下實現requesHostCallback
等函數的時候採用了不同的方式,其中在node
環境下采用setTimeout
來實現任務的及時調用,瀏覽器環境下則使用MessageChannel
。這裏引申出來一個問題,react
爲什麼放棄了requesIdleCallback
和setTimeout
而採用MessageChannel
來實現。這一點我們可以在這個 PR[1] 中看到一些端倪
-
由於
requestIdleCallback
依賴於顯示器的刷新頻率,使用時需要看vsync cycle(指硬件設備的頻率)
的臉色 -
MessageChannel
方式也會有問題,會加劇和瀏覽器其它任務的競爭 -
爲了儘可能每幀多執行任務,採用了 5ms 間隔的消息
event
發起調度,也就是這裏真正有必要使用postmessage
來傳遞消息 -
對於瀏覽器在後臺運行時
postmessage
和requestAnimationFrame
、setTimeout
的具體差異還不清楚,假設他們擁有同樣的優先級,翻譯不好見下面原文
I'm also not sure to what extent message events are throttled when the tab is backgrounded, relative to
requestAnimationFrame
orsetTimeout
. I'm starting with the assumption thatmessage
events fire with at least the same priority as timers, but I'll need to confirm.
由此我們可以看到實現方式並不是唯一的,可以猜想。react
團隊做這一改動可能是react
團隊更希望控制調度的頻率,根據任務的優先級不同,提高任務的處理速度,放棄本身對於瀏覽器幀的依賴。優化react
的性能(concurrent
)
什麼是 Messagechannel
見 MDN[2]
調度的實現
調度中心比較重要的函數在 SchedulerHostConfig.default.js 中
該 js 文件一共導出了 8 個函數
export let requestHostCallback;//請求及時回調
export let cancelHostCallback;
export let requestHostTimeout;
export let cancelHostTimeout;
export let shouldYieldToHost;
export let requestPaint;
export let getCurrentTime;
export let forceFrameRate;
調度相關
請求或取消調度
-
requestHostCallback
詳情見:源碼 [3] -
cancelHostCallbac
詳情見:源碼 [4] -
requestHostTimeout
詳情見:源碼 [5] -
requestHostTimeout
詳情見:源碼 [6]
這幾個函數的代碼量非常少,它們的作用就是用來通知消息請求調用或者註冊異步任務等待調用。下面我們具體看下 scheduler 的整個流程
ScheduleCallback 註冊任務
這個函數註冊了一個任務並開始調度。
function unstable_scheduleCallback(priorityLevel, callback, options) {
var currentTime = getCurrentTime();
// 確定當前時間 startTime 和延遲更新時間 timeout
var startTime;
if (typeof options === 'object' && options !== null) {
var delay = options.delay;
if (typeof delay === 'number' && delay > 0) {
startTime = currentTime + delay;
} else {
startTime = currentTime;
}
} else {
startTime = currentTime;
}
// 根據優先級不同timeout不同,最終導致任務的過期時間不同,而任務的過期時間是用來排序的唯一條件
// 所以我們可以理解優先級最高的任務,過期時間越短,任務執行的靠前
var timeout;
switch (priorityLevel) {
case ImmediatePriority:
timeout = IMMEDIATE_PRIORITY_TIMEOUT;
break;
case UserBlockingPriority:
timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
break;
case IdlePriority:
timeout = IDLE_PRIORITY_TIMEOUT;
break;
case LowPriority:
timeout = LOW_PRIORITY_TIMEOUT;
break;
case NormalPriority:
default:
timeout = NORMAL_PRIORITY_TIMEOUT;
break;
}
var expirationTime = startTime + timeout;
var newTask = {
id: taskIdCounter++,
// 任務本體
callback,
// 任務優先級
priorityLevel,
// 任務開始的時間,表示任務何時才能執行
startTime,
// 任務的過期時間
expirationTime,
// 在小頂堆隊列中排序的依據
sortIndex: -1,
};
if (enableProfiling) {
newTask.isQueued = false;
}
// 如果是延遲任務則將 newTask 放入延遲調度隊列(timerQueue)並執行 requestHostTimeout
// 如果是正常任務則將 newTask 放入正常調度隊列(taskQueue)並執行 requestHostCallback
if (startTime > currentTime) {
// This is a delayed task.
newTask.sortIndex = startTime;
push(timerQueue, newTask);
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
// All tasks are delayed, and this is the task with the earliest delay.
if (isHostTimeoutScheduled) {
// Cancel an existing timeout.
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}
// Schedule a timeout.
// 會把handleTimeout放到setTimeout裏,在startTime - currentTime時間之後執行
// 待會再調度
requestHostTimeout(handleTimeout, startTime - currentTime);
}
} else {
newTask.sortIndex = expirationTime;
// taskQueue是最小堆,而堆內又是根據sortIndex(也就是expirationTime)進行排序的。
// 可以保證優先級最高(expirationTime最小)的任務排在前面被優先處理。
push(taskQueue, newTask);
if (enableProfiling) {
markTaskStart(newTask, currentTime);
newTask.isQueued = true;
}
// Schedule a host callback, if needed. If we're already performing work,
// wait until the next time we yield.
// 調度一個主線程回調,如果已經執行了一個任務,等到下一次交還執行權的時候再執行回調。
// 立即調度
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
}
}
return newTask;
}
requestHostCallback 調度任務
開始調度任務,在這裏我們可以看到scheduleHostCallback
這個變量被賦值成爲了flushWork
見上段代碼 90 行。
const channel = new MessageChannel();
const port = channel.port2;
// 收到消息之後調用performWorkUntilDeadline來處理
channel.port1.onmessage = performWorkUntilDeadline;
requestHostCallback = function(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
port.postMessage(null);
}
};
performWorkUntilDeadline
可以看到這個函數主要的邏輯設置 deadline 爲當前時間加上 5ms 對應前言提到的 5ms,同時開始消費任務並判斷是否還有新的任務以決定後續的邏輯
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.
// yieldInterval 5ms
deadline = currentTime + yieldInterval;
const hasTimeRemaining = true;
try {
// scheduledHostCallback 由requestHostCallback 賦值爲flushWork
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;
};
flushWork 消費任務
可以看到消費任務的主要邏輯是在workLoop
這個循環中實現的,我們在React
工作循環一文中有提到的任務調度循環。
function flushWork(hasTimeRemaining, initialTime) {
// 1. 做好全局標記, 表示現在已經進入調度階段
isHostCallbackScheduled = false;
isPerformingWork = true;
const previousPriorityLevel = currentPriorityLevel;
try {
// 2. 循環消費隊列
return workLoop(hasTimeRemaining, initialTime);
} finally {
// 3. 還原標記
currentTask = null;
currentPriorityLevel = previousPriorityLevel;
isPerformingWork = false;
}}
workLoop 任務調度循環
function workLoop(hasTimeRemaining, initialTime) {
let currentTime = initialTime;
advanceTimers(currentTime);
// 獲取taskQueue中最緊急的任務
currentTask = peek(taskQueue);
while (
currentTask !== null &&
!(enableSchedulerDebugging && isSchedulerPaused)
) {
if (
currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())
) {
// This currentTask hasn't expired, and we've reached the deadline.
// 當前任務沒有過期,但是已經到了時間片的末尾,需要中斷循環
break;
}
const callback = currentTask.callback;
if (typeof callback === 'function') {
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
markTaskRun(currentTask, currentTime);
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// 檢查callback的執行結果返回的是不是函數,如果返回的是函數,則將這個函數作爲當前任務新的回調。
// concurrent模式下,callback是performConcurrentWorkOnRoot,其內部根據當前調度的任務
// 是否相同,來決定是否返回自身,如果相同,則說明還有任務沒做完,返回自身,其作爲新的callback
// 被放到當前的task上。while循環完成一次之後,檢查shouldYieldToHost,如果需要讓出執行權,
// 則中斷循環,走到下方,判斷currentTask不爲null,返回true,說明還有任務,回到performWorkUntilDeadline
// 中,判斷還有任務,繼續port.postMessage(null),調用監聽函數performWorkUntilDeadline,
// 繼續執行任務
currentTask.callback = continuationCallback;
markTaskYield(currentTask, currentTime);
} else {
if (enableProfiling) {
markTaskCompleted(currentTask, currentTime);
currentTask.isQueued = false;
}
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
}
advanceTimers(currentTime);
} else {
pop(taskQueue);
}
currentTask = peek(taskQueue);
}
// Return whether there's additional work
// return 的結果會作爲 performWorkUntilDeadline 中hasMoreWork的依據
// 高優先級任務完成後,currentTask.callback爲null,任務從taskQueue中刪除,此時隊列中還有低優先級任務,
// currentTask = peek(taskQueue) currentTask不爲空,說明還有任務,繼續postMessage執行workLoop,但它被取消過,導致currentTask.callback爲null
// 所以會被刪除,此時的taskQueue爲空,低優先級的任務重新調度,加入taskQueue
if (currentTask !== null) {
return true;
} else {
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
return false;
}
}
解讀:workLoop
本身是一個大循環,這個循環非常重要。此時實現了時間切片和 fiber 樹的可中斷渲染。首先我們明確一點task
本身採用最小堆根據sortIndex
也即expirationTime
。並通過
peek
方法從taskQueue
中取出來最緊急的任務。
每次 while 循環的退出就是一個時間切片,詳細看下while
循環退出的條件,可以看到一共有兩種方式可以退出
-
隊列被清空:這種情況就是正常下情況。見 49 行從
taskQueue
隊列中獲取下一個最緊急的任務來執行,如果這個任務爲null
,則表示此任務隊列被清空。退出workLoop
循環 -
任務執行超時:在執行任務的過程中由於任務本身過於複雜在執行 task.callback 之前就會判斷是否超時(
shouldYieldToHost
)。如果超時也需要退出循環交給performWorkUntilDeadline
發起下一次調度,與此同時瀏覽器可以有空閒執行別的任務。因爲本身MessageChannel
監聽事件是一個異步任務,故可以理解在瀏覽器執行完別的任務後會繼續執行performWorkUntilDeadline
。
這段代碼中還包含了十分重要的邏輯(見 19~36 行),這段代碼是實現可中斷渲染的關鍵。具體它們是怎麼工作的呢以concurrent
模式下performConcurrentWorkOnRoot
舉例:
function performConcurrentWorkOnRoot(root) {
//省略無關代碼
const originalCallbackNode = root.callbackNode;
// 省略無關代碼
ensureRootIsScheduled(root, now());
if (root.callbackNode === originalCallbackNode) {
// The task node scheduled for this root is the same one that's
// currently executed. Need to return a continuation.
return performConcurrentWorkOnRoot.bind(null, root);
}
return null;
}
這段代碼中我們可以看到,在callbackNode === originalCallBackNode
的時候會返回performConcurrentWorkOnRoot
本身,也即workLoop
中 19~36 行中的continuationCallback
。那麼我們可以大概猜測callbackNode
值在ensureRootIsScheduled
函數中被修改了
ensureRootIsScheduled
從這裏我們可以看到,callbackNode 是如何被賦值並且修改的。詳細見 15 行,43 行註釋
function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
const existingCallbackNode = root.callbackNode;
// Check if any lanes are being starved by other work. If so, mark them as
// expired so we know to work on those next.
markStarvedLanesAsExpired(root, currentTime);
// Determine the next lanes to work on, and their priority.
const nextLanes = getNextLanes(
root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
);
// This returns the priority level computed during the `getNextLanes` call.
const newCallbackPriority = returnNextLanesPriority();
// 在fiber樹構建、更新完成後。nextLanes會賦值爲NoLanes 此時會將callbackNode賦值爲null, 表示此任務執行結束
if (nextLanes === NoLanes) {
// Special case: There's nothing to work on.
if (existingCallbackNode !== null) {
cancelCallback(existingCallbackNode);
root.callbackNode = null;
root.callbackPriority = NoLanePriority;
}
return;
}
// 節流防抖
// Check if there's an existing task. We may be able to reuse it.
if (existingCallbackNode !== null) {
const existingCallbackPriority = root.callbackPriority;
if (existingCallbackPriority === newCallbackPriority) {
// The priority hasn't changed. We can reuse the existing task. Exit.
return;
}
// The priority changed. Cancel the existing callback. We'll schedule a new
// one below.
cancelCallback(existingCallbackNode);
}
// Schedule a new callback.
let newCallbackNode;
if (newCallbackPriority === SyncLanePriority) {
// Special case: Sync React callbacks are scheduled on a special
// internal queue
// 開始調度返回newCallbackNode,也即scheduler中的task.
newCallbackNode = scheduleSyncCallback(
performSyncWorkOnRoot.bind(null, root),
);
} else if (newCallbackPriority === SyncBatchedLanePriority) {
newCallbackNode = scheduleCallback(
ImmediateSchedulerPriority,
performSyncWorkOnRoot.bind(null, root),
);
} else {
const schedulerPriorityLevel = lanePriorityToSchedulerPriority(
newCallbackPriority,
);
newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root),
);
}
// 更新標記
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
}
到這裏我們管中窺豹看到了中斷渲染原理是如何做的,以及註冊調度任務部分、節流防抖部分的代碼。下面我們總結下:
時間切片原理:
消費任務隊列的過程中, 可以消費1~n
個 task, 甚至清空整個 queue
. 但是在每一次具體執行task.callback
之前都要進行超時檢測, 如果超時可以立即退出循環並等待下一次調用。
可中斷渲染原理:
在時間切片的基礎之上, 如果單個callback
執行的時間過長。就需要task.callback
在執行的時候自己判斷下是否超時,所以concurrent
模式下,fiber 樹每構建完一個單元都會判斷是否超時。如果超時則退出循環並返回回調,等待下次調用,完成之前沒有完成的fiber
樹構建。
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
附言:
其實上面的workLoop
中還有 3 個相對重要的函數沒分析,這裏我們簡單看下
advanceTimers & handleTimeout
function advanceTimers(currentTime) {
// Check for tasks that are no longer delayed and add them to the queue.
// 檢查過期任務隊列中不應再被推遲的,放到taskQueue中
let timer = peek(timerQueue);
while (timer !== null) {
if (timer.callback === null) {
// Timer was cancelled.
pop(timerQueue);
} else if (timer.startTime <= currentTime) {
// Timer fired. Transfer to the task queue.
pop(timerQueue);
timer.sortIndex = timer.expirationTime;
push(taskQueue, timer);
if (enableProfiling) {
markTaskStart(timer, currentTime);
timer.isQueued = true;
}
} else {
// Remaining timers are pending.
return;
}
timer = peek(timerQueue);
}
}
function handleTimeout(currentTime) {
// 這個函數的作用是檢查timerQueue中的任務,如果有快過期的任務,將它
// 放到taskQueue中,執行掉
// 如果沒有快過期的,並且taskQueue中沒有任務,那就取出timerQueue中的
// 第一個任務,等它的任務快過期了,執行掉它
isHostTimeoutScheduled = false;
// 檢查過期任務隊列中不應再被推遲的,放到taskQueue中
advanceTimers(currentTime);
if (!isHostCallbackScheduled) {
if (peek(taskQueue) !== null) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
} else {
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
}
}
}
shouldYieldToHost
shouldYieldToHost = function() {
const currentTime = getCurrentTime();
if (currentTime >= deadline) {
// There's no time left. We may want to yield control of the main
// thread, so the browser can perform high priority tasks. The main ones
// are painting and user input. If there's a pending paint or a pending
// input, then we should yield. But if there's neither, then we can
// yield less often while remaining responsive. We'll eventually yield
// regardless, since there could be a pending paint that wasn't
// accompanied by a call to `requestPaint`, or other main thread tasks
// like network events.
if (needsPaint || scheduling.isInputPending()) {
// There is either a pending paint or a pending input.
return true;
}
// There's no pending input. Only yield if we've reached the max
// yield interval.
return currentTime >= maxYieldInterval;
} else {
// There's still time left in the frame.
return false;
}
};
總結:
到這裏我們大致闡述了react
Scheduler
任務調度循環的流程,以及時間切片和可中斷渲染的原理。這部分是react
的核心,此外甚至在註冊調度任務之前還做了節流和防抖等操作。由此我們看的核心的代碼並不總是龐大的。respesct!!!
參考資料
[1]
PR: https://github.com/facebook/react/pull/16214
[2]
見 MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/MessageChannel
[3]
源碼: https://github.com/facebook/react/blob/v17.0.2/packages/scheduler/src/forks/SchedulerHostConfig.default.js#L224-L230
[4]
源碼: https://github.com/facebook/react/blob/v17.0.2/packages/scheduler/src/forks/SchedulerHostConfig.default.js#L232-L234
[5]
源碼: https://github.com/facebook/react/blob/v17.0.2/packages/scheduler/src/forks/SchedulerHostConfig.default.js#L236-L240
[6]
源碼: https://github.com/facebook/react/blob/v17.0.2/packages/scheduler/src/forks/SchedulerHostConfig.default.js#L242-L245
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/0vomFnPPNb27E76LBIQcsA