React 的調度系統 Scheduler

大家好,我是前端西瓜哥。今天來學習 React 的調度系統 Scheduler。

React 版本爲 18.2.0

React 使用了全新的 Fiber 架構,將原本需要一次性遞歸找出所有的改變,並一次性更新真實 DOM 的流程,改成通過時間分片,先分成一個個小的異步任務在空閒時間找出改變,最後一次性更新 DOM。

這裏需要使用調度器,在瀏覽器空閒的時候去做這些異步小任務。

Scheduler

做這個調度工作的在 React 中叫做 Scheduler(調度器)模塊。

其實瀏覽器是提供一個 requestIdleCallback 的方法,讓我們可以在瀏覽器空閒的時去調用傳入去的回調函數。但因爲兼容性不好,給的優先級可能太低,執行是在渲染幀執行等缺點。

所以 React 實現了 requestIdleCallback 的替代方案,也就是這個 Scheduler。它的底層是 基於 MessageChannel 的。

爲什麼是 MessageChannel?

選擇 MessageChannel 的原因,是首先異步得是個宏任務,因爲宏任務中會在下次事件循環中執行,不會阻塞當前頁面的更新。MessageChannel 是一個宏任務。

沒選常見的 setTimeout,是因爲 MessageChannel 能較快執行,在 0~1ms 內觸發,像 setTimeout 即便設置 timeout 爲 0 還是需要 4~5ms。相同時間下,MessageChannel 能夠完成更多的任務。

若瀏覽器不支持 MessageChannel,還是得降級爲 setTimeout。

其實如果 setImmediate 存在的話,會優先使用 setImmediate,但它只在少量環境(比如 IE 的低版本、Node.js)中存在。

邏輯是在 packages/scheduler/src/forks/Scheduler.js 中實現的:

// Capture local references to native APIs, in case a polyfill overrides them.
const localSetTimeout = typeof setTimeout === 'function' ? setTimeout : null;
const localClearTimeout =
  typeof clearTimeout === 'function' ? clearTimeout : null;
const localSetImmediate =
  typeof setImmediate !== 'undefined' ? setImmediate : null; // IE and Node.js + jsdom

/***** 異步選擇策略 *****/
// 【1】 優先使用 setImmediate
if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  schedulePerformWorkUntilDeadline = () ={
    localSetImmediate(performWorkUntilDeadline);
  };
} 
// 【2】 然後是 MessageChannel
else if (typeof MessageChannel !== 'undefined') {
  // DOM and Worker environments.
  // We prefer MessageChannel because of the 4ms setTimeout clamping.
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () ={
    port.postMessage(null);
  };
} 
// 【3】 最後是 setTimeout(兜底)
else {
  // We should only fallback here in non-browser environments.
  schedulePerformWorkUntilDeadline = () ={
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

另外,也沒有選擇使用 requestAnimationFrame,是因爲它的機制比較特別,是在更新頁面前執行,但更新頁面的時機並沒有規定,執行時機並不穩定。

底層的異步循環

requestHostCallback 方法,用於請求宿主(指瀏覽器)去執行函數。該方法會將傳入的函數保存起來到 scheduledHostCallback 上,

然後調用 schedulePerformWorkUntilDeadline 方法。

schedulePerformWorkUntilDeadline 方法一調用,就停不下來了。

它會異步調用 performWorkUntilDeadline,後者又調用回 schedulePerformWorkUntilDeadline,最終實現 不斷地異步循環執行 performWorkUntilDeadline

// 請求宿主(指瀏覽器)執行函數
function requestHostCallback(callback) {
  scheduledHostCallback = callback;
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}

isMessageLoopRunning 是一個 flag,表示是否正在走循環。防止同一時間調用多次 schedulePerformWorkUntilDeadline。

React 會調度 workLoopSync / workLoopConcurrent

我們在 React 項目啓動後,執行一個更新操作,會調用 ensureRootIsScheduled 方法。

function ensureRootIsScheduled(root, currentTime) {
  // 最高優先級
  if (newCallbackPriority === SyncLane) {
    // Special case: Sync React callbacks are scheduled on a special
    // internal queue
    if (root.tag === LegacyRoot) {
      // Legacy Mode,即 ReactDOM.render() 啓用的同步模式
      scheduleLegacySyncCallback(performSyncWorkOnRoot.bind(null, root));
    } else {
      scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
    }
    // 立即執行優先級,去清空需要同步執行的任務
    scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks);
  } else {
    // 初始化 schedulerPriorityLevel 並計算出 Scheduler 支持的優先級值
    let schedulerPriorityLevel;
    // ...
    
    scheduleCallback(
      schedulerPriorityLevel, 
      performConcurrentWorkOnRoot.bind(null, root), // 併發模式
    );
  }
}

該方法有很多分支,最終會根據條件調用:

  1. performSyncWorkOnRoot(立即執行)

  2. performConcurrentWorkOnRoot(併發執行,且會用 scheduler 的 scheduleCallback 進行異步調用)

performSyncWorkOnRoot 最終會執行重要的 workLoopSync 方法:

// 調用鏈路:
// performSyncWorkOnRoot -> renderRootSync -> workLoopSync
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

workInProgress 表示一個需要進行處理的 FiberNode。

performUnitOfWork 方法用於處理一個 workInProgress,進行調和操作,計算出新的 fiberNode。

同樣,performConcurrentWorkOnRoot 最終會執行重要的 workLoopConcurrent 方法。

// 調用鏈路:
// performConcurrentWorkOnRoot -> performConcurrentWorkOnRoot -> renderRootConcurrent
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

和 workLoopSync 很相似,但循環條件裏多了一個來自 Scheduler 的 shouldYield() 決定是否將進程讓出給瀏覽器,這樣就能做到中斷 Fiber 的調和階段,做到時間分片。

scheduleCallback

上面的 workLoopSync 和 workLoopConcurrent 都是通過 scheduleCallback 去調度的。

scheduleCallback 方法傳入優先級 priorityLevel、需要指定的回調函數 callback ,以及一個可選項 options。

scheduleCallback 的實現如下(做了簡化):

function unstable_scheduleCallback(priorityLevel, callback, options) {
  var currentTime = getCurrentTime();

  var startTime;
  if (options?.delay) {
    startTime = currentTime + options.delay;
  }
  // 有效期時長,根據優先級設置。
  var timeout;
  // ...
  // 計算出 過期時間點
  var expirationTime = startTime + timeout;

  // 創建一個任務
  var newTask = {
    id: taskIdCounter++,
    callback, // 這個就是任務本身
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  };
  
  // 說明新任務是加了 option.delay 的任務,需要延遲執行
  // 我們會放到未逾期隊列(timerQueue)中
  if (startTime > currentTime) {
    newTask.sortIndex = startTime;
    push(timerQueue, newTask);
    // 沒有需要逾期的任務,且優先級最高的未逾期任務就是這個新任務
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      // 那,用 setTimeout 延遲 options.delay 執行 handleTimeout
     requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } 
  // 立即執行的任務,加入到逾期隊列(taskQueue)
  else {
    newTask.sortIndex = expirationTime;
    push(taskQueue, newTask);
 
    // 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);
    }
  }
}

push / peek / pop 這些是 scheduler 提供的操作 優先級隊列 的操作方法。

優先級隊列的底層實現是小頂堆,實現原理不展開講。我們只需要記住優先級隊列的特性:就是出隊的時候,會取優先級最高的任務。在 scheduler 中,sortIndex 最小的任務的優先級最高

push(queue, task) 表示入隊,加一個新任務;peek(queue) 表示得到最高優先級(不出隊);pop(queue) 表示將最高優先級任務出隊。

taskQueue 爲逾期的任務隊列,需要趕緊執行。新生成的任務(沒有設置 options.delay)會放到 taskQueue,並以 expirationTime 作爲優先級(sortIndex)來比較。

timerQueue 是還沒逾期的任務隊列,以 startTime 作爲優先級來比較。如果逾期了,就會 取出放到 taskQueue 裏。

handleTimeout

// 如果沒有逾期的任務,且優先級最高的未逾期任務就是這個新任務
// 延遲執行 handleTimeout
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
  requestHostTimeout(handleTimeout, startTime - currentTime);
}

requestHostTimeout 其實就是 setTimeout 定時器的簡單封裝,在 newTask 過期的時間點(startTime - currentTime 後)執行 handleTimeout。

function handleTimeout(currentTime) {
  isHostTimeoutScheduled = false;
  advanceTimers(currentTime); // 更新 timerQueue 和 taskQueue

  if (!isHostCallbackScheduled) {
    if (peek(taskQueue) !== null) { // 有要執行的逾期任務
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork); // 清空 taskQueue 任務
    } else { // 沒有逾期任務
      const firstTimer = peek(timerQueue);
      if (firstTimer !== null) { // 但有未逾期任務,用 setTimeout 晚點再調用自己
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
      }
    }
  }
}

handleTimeout 下會調用 advanceTimers 方法,根據當前時間要將 timerTask 中逾期的任務搬到 taskQueue 下。

(advanceTimers 這個方法會在多個位置被調用。搬一搬,更健康)

搬完後,看看 taskQueue 有沒有任務要做,有的話就調用 flushWork 清空 taskQueue 任務。沒有的話看看有沒有未逾期任務,用定時器在它過期的時間點再遞歸執行 handleTimeout。

workLoop

flushWork 會 調用 workLoop。flushWork 還需要做一些額外的修改模塊文件變量的操作。

function flushWork(hasTimeRemaining, initialTime) {
  // ...
 return workLoop(hasTimeRemaining, initialTime); 
}

workLoop  會不停地從 taskQueue 取出任務來執行。其核心邏輯爲:

function workLoop(hasTimeRemaining, initialTime) {
  // 更新 taskQueue,並取出一個任務
  let currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);

  while (currentTask !== null) {
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // This currentTask hasn't expired, and we've reached the deadline.
      break;
    }
    // 執行任務
    const callback = currentTask.callback;
    callback();
 
    // 更新 taskQueue,並取出一個任務
    currentTime = getCurrentTime();
    advanceTimers(currentTime);
    currentTask = peek(taskQueue);
  }
  return currentTask !== null;
}

shouldYieldToHost

上面的循環並不是一直會執行到 currentTask 爲 null 爲止,在必要的時候還是會跳出的。我們是通過 shouldYieldToHost 方法判斷是否要跳出。

此外,Fiber 異步更新的 workLoopConcurrent 方法用到的 shouldYield,其實就是這個 shouldYieldToHost。

shouldYieldToHost 核心實現:

const frameYieldMs = 5;
var frameInterval = frameYieldMs;

function shouldYieldToHost() {
  var timeElapsed = getCurrentTime() - startTime;
  // 經過的時間小於 5 ms,不需要讓出進程
  if (timeElapsed < frameInterval) {
    return false;
  }
  return true;
}

export {
  // 會重命名爲 unstable_shouldYield 導出
  shouldYieldToHost as unstable_shouldYield,
}

計算經過的時間,如果小於幀間隔時間(frameInterval,通常爲 5ms),不需要讓出進程,否則讓出。

startTime 是模塊文件的最外層變量,會在 performWorkUntilDeadline 方法中賦值,也就是任務開始調度的時候。

流程圖

試着畫一下 Scheduler 的調度流程圖。

結尾

Scheduler 一套下來還是挺複雜的。

首先是 Scheduler 底層大多數情況下會使用 MessageChannel,作爲循環執行異步任務的能力。通過它來不斷地執行任務隊列中的任務。

任務隊列是特殊的優先級隊列,特性是出隊時,拿到優先級最高的任務(在 Scheduler 中對比的是 sortIndex,值是一個時間戳)。

任務隊列在 Scheduler 中有兩種。一種是逾期任務 taskQueue,需要趕緊執行,另一種是延期任務 timerQueue,還不到時間執行。Scheduler 會根據當前時間,將逾期的 timerQueue 任務放到 taskQueue 中,然後從 taskQueue 取出優先級最高的任務去執行。

Scheduler 向外暴露 scheduleCallback 方法,該方法接受一個優先級和一個函數(就是任務),對於 React 來說,它通常是 workLoopSync 或 workLoopConcurrent。

scheduleCallback 會設置新任務的過期時間(根據優先級),並判斷是否爲延時任務(根據 options.delay)決定放入哪個任務隊列中。然後啓用循環執行異步任務,不斷地清空執行 taskQueue。

Scheduler 也向外暴露了 shouldYield,通過它可以知道是否執行時間過長,應該讓出進程給瀏覽器。該方法同時也在 Scheduler 內部的循環執行異步任務中作爲一種打斷循環的判斷條件。

React 的併發模式下,可以用它作爲暫停調和階段的依據。

我是前端西瓜哥,歡迎關注我,學習更多前端知識。


本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/uIR74VrvLl1j2jD131yj2Q