深入理解 scheduler 原理

React運行時,如果把別的部分比喻成我們的肢體用來執行具體的動作,那麼scheduler就相當於我們的大腦,調度中心位於scheduler包中,理解清楚scheduler爲我們理解react的工作流程有很大的裨益。

前言

我們都知道react可以運行在node環境中和瀏覽器環境中,所以在不同環境下實現requesHostCallback等函數的時候採用了不同的方式,其中在node環境下采用setTimeout來實現任務的及時調用,瀏覽器環境下則使用MessageChannel。這裏引申出來一個問題,react爲什麼放棄了requesIdleCallbacksetTimeout而採用MessageChannel來實現。這一點我們可以在這個 PR[1] 中看到一些端倪

  1. 由於requestIdleCallback依賴於顯示器的刷新頻率,使用時需要看vsync cycle(指硬件設備的頻率)的臉色

  2. MessageChannel方式也會有問題,會加劇和瀏覽器其它任務的競爭

  3. 爲了儘可能每幀多執行任務,採用了 5ms 間隔的消息event發起調度,也就是這裏真正有必要使用postmessage來傳遞消息

  4. 對於瀏覽器在後臺運行時postmessagerequestAnimationFramesetTimeout的具體差異還不清楚,假設他們擁有同樣的優先級,翻譯不好見下面原文

I'm also not sure to what extent message events are throttled when the tab is backgrounded, relative to requestAnimationFrame or setTimeout. I'm starting with the assumption that message 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;

調度相關

請求或取消調度

這幾個函數的代碼量非常少,它們的作用就是用來通知消息請求調用或者註冊異步任務等待調用。下面我們具體看下 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循環退出的條件,可以看到一共有兩種方式可以退出

  1. 隊列被清空:這種情況就是正常下情況。見 49 行從taskQueue隊列中獲取下一個最緊急的任務來執行,如果這個任務爲null,則表示此任務隊列被清空。退出workLoop循環

  2. 任務執行超時:在執行任務的過程中由於任務本身過於複雜在執行 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