React的调度器是怎么工作的?

66 阅读10分钟

原文地址

jser.dev/react/2022/…

1. 开始

让我们以下面这段代码开始,这部分我们在这系列的第一季已经讲过了。

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

换句话说,React内部工作在fiber树的那个fiber上, workInProgress是跟踪当前位置,遍历算法在我前面的文章里已经讲过了。

workLoopSync()是非常容易去理解的,因为它是同步的,它不会中断我们的进程,所以React会继续在一个循环中工作。

在并发模式下存在一些差异

function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

在并发模式下,有更高的权利的任务可以中止低权利的任务,我们需要一种方法去中断和恢复任务,这就是为什么shouldYield()获得了成功,但显然不止于此。

2.让我们先从一些背景知识开始

2.1 事件循环

老实说,我不能够解释的很好,我建议你读来自javascript.info的解释,观看这个视频。

简单来说,JavaScript引擎将会做下面的事情:

1、从任务队列中获取任务(宏任务)并运行

2、如果是微任务,运行它们

3、 4、如果有更多任务,请重复1或等待更多任务

这个循环是不言自明的,因为这里确实有一个循环

2.2 setimmediation()在不阻塞呈现的情况下调度一个新任务

为了在不阻塞的情况下调度任务,我们已经熟悉了setTimeout(callback, 0)的技巧,it schedules a new macrotask

但是有一个更好的API setimmediation(),但它只在IE和node.js中可用

它更好,因为setTimeout()实际上在嵌套调用中至少有大约4ms, setimmediation()没有延迟

好的,我们已经准备好接触React Scheduler源代码中的第一段代码

let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === "function") {
  // Node.js and old IE.
  // 有几个原因可以解释为什么我们更喜欢setimmediation.
  //
  // 与MessageChannel不同,它不会阻止Node.js进程退出
  // (尽管这是调度器的DOM分支,但您可以混合使用Node.js 15+,其中有一个MessageChannel和     //   jsdom)
  // https://github.com/facebook/react/issues/20756
  //
  // 而且,它运行得更早,这是我们想要的
  // 如果其他浏览器实现了它,最好使用它
  // 尽管这两种方法都不如本机调度
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== "undefined") {
  // DOM和线程的环境
  // 我更喜欢MessageChannel,因为set Timeout有4ms的间隔
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // We should only fallback here in non-browser environments.
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

这里我们可以看到setimmediation()的两种不同回退,分别是MessageChannel和setTimeout

2.3 优先队列

优先队列是一个在调度中常用的数据结构。我建议你尝试自己用JS创建一个优先队列

它非常适合React中的需求。因为有不同优先级的事件发生,我们得尽快找到一个优先级最高的

React使用minheap实现优先队列,你可以在这里找到源代码

3. 调用workLoopConcurrent堆栈

现在,让我们看看如何调用workLoopConcurrent

callstack-workloopconcurrent.png 所有的代码都在ReactFiberWorkLoop.js中,让我们分解一下

我们遇到过很多次ensure erootisscheduled(),它从相当多的地方被使用,顾名思义,如果有任何更新,ensure erootisscheduled()为React安排一个任务

注意,它不会直接调用performConcurrentWorkOnRoot(),而是通过scheduleCallback(优先级,回调)将其视为回调

scheduleCallback()是Scheduler中的一个api

我们将很快深入讨论调度器,但是现在,请记住,调度器将在正确的时间运行任务

3.1 如果中断,performConcurrentWorkOnRoot()返回自身的闭包

请参见performConcurrentWorkOnRoot()根据进度返回不同的结果

如果shouldyfield()为true, workLoopConcurrent将中断,从而导致不完整的更新(RootInComplete), performConcurrentWorkOnRoot()将返回performConcurrentWorkOnRoot.bind(null, root)

如果是complete,则返回null

您可能想知道,如果某个任务被shouldyfield()中断,它将如何恢复?是的,这就是答案。调度器会查看任务回调的返回值,返回值是一种重新调度

我们将很快讨论这个问题

4.调度器

最后,我们进入了调度器的世界。不要害怕,一开始我很害怕,但很快意识到我不需要害怕

消息队列是处理外部控制的一种方式,调度器就是这样做的

上面提到的scheduleCallback()在Scheduler世界中是不稳定的scheduleCallback

4.1 scheduleCallback() -调度器通过exipriationTime调度任务

为了让Scheduler调度任务,它首先需要存储带有优先级标记的任务

这是由优先队列完成的,我们已经在背景知识中介绍过了

它使用expirationTime来反映优先级。这很公平,越早到期,我们就越早处理。下面是在scheduleCallback()中创建任务的代码

var currentTime = getCurrentTime();

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;
}

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,
};

代码非常简单,对于每个优先级我们都有不同的超时,它们在这里定义

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;

所以默认它有5秒的超时,对于用户阻塞它有250ms。我们将很快看到这些优先事项的一些例子

任务已经创建,现在是时候将其放入优先队列中了

if (startTime > currentTime) {
  // 这是一个延迟任务。
  newTask.sortIndex = startTime;
  push(timerQueue, newTask);
  if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
    // 所有任务都被延迟,这是延迟最早的任务。
    if (isHostTimeoutScheduled) {
      // 取消已存在的超时
      cancelHostTimeout();
    } else {
      isHostTimeoutScheduled = true;
    }
    // 安排一个超时时间
    requestHostTimeout(handleTimeout, startTime - currentTime);
  }
} else {
  newTask.sortIndex = expirationTime;
  push(taskQueue, newTask);
  if (enableProfiling) {
    markTaskStart(newTask, currentTime);
    newTask.isQueued = true;
  }
 // 如果需要,计划一个主机回调。如果我们已经在工作了,那就等到下次我们yield的时候再做
  if (!isHostCallbackScheduled && !isPerformingWork) {
    isHostCallbackScheduled = true;
    requestHostCallback(flushWork);
  }
}

哦,对了,当调度一个任务时,它可以有一个延迟选项,比如setTimeout()。让我们先把它放在一边,以后再来

只需要关注else分支。我们可以看到两个重要的调用

1、push(taskQueue, newTask);将任务添加到队列中,这只是优先级队列API,我跳过

2、requestHostCallback(flushWork) 处理它们

requestHostCallback(flushWork)是必要的,因为调度器是主机无关的,它应该只是一些独立的黑盒子,可以在任何主机上运行,所以它需要被请求

4.2 requestHostCallback()

function requestHostCallback(callback) {
  scheduledHostCallback = callback;
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}
const performWorkUntilDeadline = () => {
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime();
    // 跟踪开始时间,这样我们就可以测量主线程被阻塞了多长时间
    startTime = currentTime;
    const hasTimeRemaining = true;

    // 如果调度程序任务抛出,则退出当前浏览器任务,以便可以观察到错误
    // 
    // 有意地不使用try-catch,因为这会使一些调试技术更加困难。相反,如果'    scheduledHostCallback '错误,那么' hasMoreWork '将保持true,我们将继续工作循环
    let hasMoreWork = true;
    try {
      hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
    } finally {
      if (hasMoreWork) {
        // 如果有更多的工作,则将下一个消息事件安排在前一个消息事件的末尾
        schedulePerformWorkUntilDeadline();
      } else {
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      }
    }
  } else {
    isMessageLoopRunning = false;
  }
  // 浏览器将给它一个绘制的机会,所以我们可以重置这个。
  needsPaint = false;
};

在2.2中提到的schedulePerformWorkUntilDeadline()只是performWorkUntilDeadline()的一个包装

scheduledHostCallback设置在requestHostCallback()并且在performWorkUntilDeadline()中马上调用,这是为了给主线程渲染的机会

忽略一些细节,这是最重要的一行,hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime),它意味着flushWork()将被(true, currentTime)调用

4.3 flushWork()

try {
  // 在生产代码中没有捕获
  return workLoop(hasTimeRemaining, initialTime);
} finally {
  //
}

flushWork刚刚结束wraps up workLoop()

4.4 workLoop() - the core of Scheduler

正如workLoopConcurrent()在协调中,workLoop()是调度器的核心。它们有相似的名字是因为它们有相似的进程。

if (
  currentTask.expirationTime > currentTime &&
  (!hasTimeRemaining || shouldYieldToHost())
) {
  // 这个currentTime没有过期, 我们到达了死亡线
  break;
}

就像workLoopConcurrent()一样,shouldYieldToHost()在这里被选中。我们稍后再讨论。

const callback = currentTask.callback;
if (typeof callback === "function") {
  currentTask.callback = null;
  currentPriorityLevel = currentTask.priorityLevel;
  const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
  const continuationCallback = callback(didUserCallbackTimeout);
  currentTime = getCurrentTime();
  if (typeof continuationCallback === "function") {
    currentTask.callback = continuationCallback;
  } else {
    if (currentTask === peek(taskQueue)) {
      pop(taskQueue);
    }
  }
  advanceTimers(currentTime);
} else {
  pop(taskQueue);
}

让我们解构它 currentTask.callback,在这个例子中实际上是performConcurrentWorkOnRoot()

const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
const continuationCallback = callback(didUserCallbackTimeout);

它调用时带有一个标志,以指示它是否过期 如果超时,performConcurrentWorkOnRoot()将退回到同步模式

const shouldTimeSlice =
  !includesBlockingLane(root, lanes) &&
  !includesExpiredLane(root, lanes) &&
  (disableSchedulerTimeoutInWorkLoop || !didTimeout);
let exitStatus = shouldTimeSlice
  ? renderRootConcurrent(root, lanes)
  : renderRootSync(root, lanes);

好的,回到workLoop()

if (typeof continuationCallback === "function") {
  currentTask.callback = continuationCallback;
} else {
  if (currentTask === peek(taskQueue)) {
    pop(taskQueue);
  }
}

重要的是,我们可以看到只有当callback的返回值不是function时任务才会弹出,如果它是函数,它只会更新任务的回调,因为它没有弹出,workLoop()的下一次tick将再次导致相同的任务

这意味着如果这个回调的返回值是一个函数,这意味着这个任务还没有完成,我们应该重新处理它,这里的点连接到3.2

advanceTimers(currentTime);

这是针对延迟任务的,我们稍后再回来

4.5 how shouldYield() work?#

function shouldYieldToHost() {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    // 主线程只被阻塞了很短的时间
    // 比单帧小
    return false;
  }

  // 主线程阻塞了相当长的时间。我们可能希望放弃主线程的控制权,这样浏览器就可以执行高优先级
  // 的任务 主要是绘图和用户输入。如果有一个未决的绘制或未决的输入,那么我们应该让步。 但如
  // 果两者都没有,那么我们可以在保持响应的同时减少让步。 不管怎样,我们最终还是会让步,因为
  // 可能会有一个挂起的绘制没有伴随着对“requestPaint”的调用,或者其他主线程任务,比如网络
  //事件
  if (enableIsInputPending) {
    if (needsPaint) {
      // 有一个挂起的绘制(由' requestPaint '发出信号),现在让步
      return true;
    }
    if (timeElapsed < continuousInputInterval) {
      // 我们还没有阻塞线程那么久。只有在等待离散输入(例如点击)时才让步。
      // 如果有未决的连续输入(例如鼠标悬停),这是可以的。
      if (isInputPending !== null) {
        return isInputPending();
      }
    } else if (timeElapsed < maxInterval) {
      // 如果有一个悬而未决的离散或连续输入,Yield
      if (isInputPending !== null) {
        return isInputPending(continuousOptions);
      }
    } else {
      // 我们已经阻塞线程很长时间了。即使没有待处理的输入,也可能有一些我们不知道的
      // 其他预定工作,比如网络事件。现在Yield
      return true;
    }
  }

  // `isInputPending` 不是可用的。 现在Yield
  return true;
}

其实并不复杂,评论解释了一切。基本线条如下所示

const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
  // 主线程只被阻塞了很短的时间;比单帧小的。不要Yield
  return false;
}
return true;

因此,每个任务都有5ms (frameInterval),如果通过它,那么应该Yield

注意,这是用于在Scheduler中运行任务,而不是用于每个performUnitOfWork(),我们可以看到startTime仅在performWorkUntilDeadline()中设置,这意味着它将为每个flushWork()重置,这意味着如果多个任务可以在flushWork()中处理,则在两者之间没有yield

5 Summary

这真是太多了。让我们画一个总体图

scheduler-all.png 虽然还有一些不足,但我们已经取得了巨大的进步。这已经是一个很大的图表,让我们把其他的东西放在下一集,包括

  1. 在调度器中延迟任务
  2. 如何确定优先级和例子