React 源码解析(一):Scheduler

222 阅读18分钟

Scheduler 源码解析

1. 引入:为什么 React16 要引入 Scheduler?

1.1. 什么是 Scheduler

Scheduler 是 React 中负责调度任务执行的模块,这里的任务主要是指组件更新的过程。具体来说,Scheduler 主要负责安排两件事:

  1. React 应该在什么时候执行任务?
  2. 如果有多个任务等待执行,应该按什么顺序执行?

由于 Scheduler 是一个独立于 React 的包,并且源码长度较短(600+行),阅读难度较低,所以很多刚接触 React 源码的新手都会从这个模块开始看起。新手开始读源码,往往会产生的问题是:Scheduler 是什么?为什么要有这么个模块?

1.2. 为什么要引入 Scheduler:React15 的问题

Scheduler 是什么我们刚才已经简单介绍过了,就是负责在特定时机选择让哪些任务执行的模块,没什么玄乎的。那么,为什么要有这个模块呢?

其实 Scheduler 模块是在 React16 新增的。在 React15 中,组件更新并渲染到真实 DOM 的任务就是一口气同步完成的,每当有更新任务发生时,React 的 Reconciler 都会做如下工作:

  1. 调用组件的 render 方法,将返回的 JSX 转化为虚拟 DOM
  2. 将虚拟 DOM 和上次更新时的虚拟 DOM 对比
  3. 通过对比找出本次更新中变化的虚拟 DOM
  4. 通知 Renderer 将变化的虚拟 DOM 渲染到页面上

只要组件发生了更新,React15 就一口气把所有改动都更新到真实 DOM 上,所以那时候也不需要 Scheduler,因为就一个更新任务嘛,没什么好调度的。

但是这样做会带来一个很大的问题:就是更新的任务可能执行时间过长,导致浏览器重绘重排频率降低,从而降低画面的帧率。我们应该知道,在浏览器环境中,JavaScript 引擎线程GUI 渲染线程是互斥的,主线程只要在执行 JavaScript 代码,就没办法渲染 UI。在事件循环当中,总是要等待宏任务和微任务执行结束了,才能执行 UI 的渲染。

那假如说 React 要一口气执行完更新任务,这可能会耗费很长时间,以至于超过了一帧的时间(假设你的显示器是 60FPS 的,那么一帧的时间就是 1s / 60 = 16.67ms),那这一帧的时间里浏览器就不会进行 UI 的渲染,从而导致画面的刷新率下降

除了画面刷新率降低以外,更新任务时间过长还可能导致用户的输入/点击等事件得不到响应。举个例子,假设主线程都在执行更新,现在用户想在 input 里面输入什么东西,发现输入的内容总是过一会才显示,因为浏览器没有进行 UI 的渲染,所以输入的内容就无法立即展示出来,同时 input 的监听事件也不会立即触发。因此可以看到,外部输入无法立即得到响应,这会给用户带来不好的体验。

1.3. Scheduler 的功能:时间切片 & 优先级调度

为了解决这两个问题,React16 重构了架构,将更新任务从单个阻塞 UI 的同步任务,变成了多个“异步可中断”的任务。这里涉及了 Reconciler、Scheduler 和 Renderer 三个模块的更新,我们这里不需要管另外两个模块,只需要关注 Scheduler。

React16 为了解决 React15 中存在的上述问题,引入的 Scheduler 实现了时间切片优先级调度两个功能。这两个概念你可能在操作系统中听到过,React 借鉴了这两个概念。那么,它们是什么意思呢?

  • 时间切片:时间切片是指把原来长时间的任务划分成多个执行时间短的小任务。这样原本执行时间超过一帧的任务,就可以划分成多个不到一帧的任务,更新任务可以在多个帧之间执行,避免长时间占用主线程,从而保证浏览器有时间渲染 UI,UI 能及时更新
  • 优先级调度:更新任务的优先级有高有低。比如刚刚我们说到的用户输入行为,用户输入之后 React 理应先更新这部分的真实 DOM,让用户看到反馈。因此需要对任务进行优先级调度,优先执行高优先级任务(如用户交互相关),低优先级任务(如后台数据处理)则可以延后或被中断

2. 时间切片

虽然时间切片的概念是要把长任务划分成小任务,但是 Scheduler 实际上并不负责划分任务,它主要负责两件事:

  1. 在浏览器主线程空闲时执行任务
  2. 执行任务直到时间片用完或任务执行完成,然后让出主线程,等待下一次主线程空闲再执行任务

接下来我们就来看看 Scheduler 是如何实现的。

首先需要在浏览器主线程空闲时执行任务。对于这一点,其实浏览器存在相关的 API requestIdleCallback。该函数接收一个回调函数,这个函数将在浏览器空闲时期被调用,避免影响关键任务的执行,比如动画、输入响应等。但是由于该函数浏览器兼容性不好,并且切换到其他标签页后该函数触发频率低,因此 React 选择不使用该 API,而是自己实现。因此 Scheduler 的时间切片本质上是 requestIdleCallback 的 polyfill。

我们可以看一下下面的事件循环流程:

一个task(宏任务) -- 队列中全部job(微任务) -- 浏览器重排relayout -- requestAnimationFrame -- 重绘repaint -- requestIdleCallback

requestIdleCallback 的回调是在浏览器重排/重绘之后立即执行的,Scheduler 为了模拟 requestIdleCallback,就需要延时执行任务,并在浏览器重排/重绘之后执行任务,但是在这个时间点又没有 API 可用,因此需要在下一个事件循环的尽可能早的时间点执行我们的任务。

为此,在浏览器环境下,Scheduler 选择了使用 MessageChannel,因为这是一个宏任务(比微任务更早执行),并且相比于同为宏任务的 setTimeout 执行时间点更提前(setTimeout 有 4ms 的延迟。更正:setTimeout 是否存在延迟取决于浏览器内核实现,例如 chromium 内核的最小时延是 1ms),本质上是为了实现兼容性更好的 0 延迟宏任务。

// schedulePerformWorkUntilDeadline
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
  // Node 和 IE 环境,使用 setImmediate 延时执行任务
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') {
  // 浏览器环境,使用 MessageChannel 延时执行任务
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

我们可以看到,这段代码的目的就是延迟执行任务 performWorkUntilDeadline,而 schedulePerformWorkUntilDeadline 就是用来延迟执行任务的函数。

在 Node 和 IE 环境下会使用 setImmediate 来延迟执行任务,因为这个 API 运行时间点更早,并且也不会导致 Node.js 进程退出。

而在浏览器环境下,使用了 MessageChannel API,当调用 schedulePerformWorkUntilDeadline 函数延迟执行任务时,会调用 port.postMessage 发出消息(消息内容是什么无所谓,所以这里是 null),而在下一个事件循环中,才会调用 onmessage 的回调 performWorkUntilDeadline,从而实现了延迟执行任务,将线程让给浏览器进行渲染。

那么,performWorkUntilDeadline 做了什么呢?

// performWorkUntilDeadline
const performWorkUntilDeadline = () => {
  if (isMessageLoopRunning) {
    const currentTime = getCurrentTime();
    startTime = currentTime;

    let hasMoreWork = true;
    try {
      // 通过 flushWork 来执行任务
      hasMoreWork = flushWork(currentTime);
    } finally {
      if (hasMoreWork) {
        // 如果任务没有执行完,则在下一次宏任务中执行
        schedulePerformWorkUntilDeadline();
      } else {
        isMessageLoopRunning = false;
      }
    }
  }
};

这个函数首先记录当前的时间到 startTime 中,用于一会判断执行时长是否超过时间片长度。之后通过 flushWork 来执行任务队列中的任务,当时间片耗尽或任务执行完毕时都会返回,从而可以知道任务队列中是否还有待执行的任务,从而决定是否要 schedule 下一次任务执行。

flushWork 基本就只做了调用 workLoop 函数这一件事,因此代码就不放了。workLoop 函数就是执行任务的循环。即执行任务 -> 判断时间片是否有剩余时间 -> 有则继续执行任务,否则退出。具体来说,它主要做了这样几件事:

  1. 对当前所有的任务按过期时间排序,并取出最先过期的任务
  2. 如果任务已经过期了,或者当前时间片还有剩余时间,那就执行该任务;否则退出执行任务。下个事件循环再执行
  3. 执行任务
  4. 执行任务完毕后取出下一个任务,回到 2,形成执行任务的循环

我们一部分一部分看。首先是 1:

function workLoop(initialTime: number) {
  let currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);
  // ...
}

// 把已经开始的任务从 timerQueue 转移到 taskQueue
function advanceTimers(currentTime: number) {
  // Check for tasks that are no longer delayed and add them to the queue.
  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);
    } else {
      // Remaining timers are pending.
      return;
    }
    timer = peek(timerQueue);
  }
}

workLoop 首先会调用 advanceTimers,把已经开始的任务从 timerQueue 移动到 taskQueue 中。taskQueue 是一个优先队列,按照过期时间从小到大排序(即最先过期的排在最前面,从而能够被先执行)。之后取出 taskQueue 中的第一个任务作为 currentTask。

function workLoop(initialTime: number) {
  // ...
  while (
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
      break;
    }
    // 执行任务的具体过程
  }
  // ...
}

function shouldYieldToHost(): boolean {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    // The main thread has only been blocked for a really short amount of time;
    // smaller than a single frame. Don't yield yet.
    return false;
  }
  // Yield now.
  return true;
}

之后会通过 while 循环执行 taskQueue 中的任务,直到没有过期任务,或者时间片已用尽。

我们可以看到 workLoop 执行的条件:taskQueue 不为空,并且当前的任务过期(即 expirationTime <= currentTime)或者 shouldYieldToHost 为 false。如果不满足上面的条件 workLoop 就会退出。

shouldYieldToHost 的逻辑也很简单,如果当前执行任务的时间 timeElapsed 没有超过时间片的长度 frameInterval,那么就可以继续执行,否则就需要中断。

任务过期具体是什么意思我们会在优先级调度中讲解。这里你可以理解为,每一个任务在初始化时,都被设置了必须在某个时间点之前被执行。因此一旦有过期任务就必须执行,即便当前执行的时间已经超过时间片长度。

接下来我们看执行任务的具体流程:

function workLoop(initialTime: number) {
  // ...
    // 执行任务的具体流程!
    const callback = currentTask.callback;
    if (typeof callback === 'function') {
      // 回调是函数,执行这个回调
      currentTask.callback = null;
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      if (typeof continuationCallback === 'function') {
        // If a continuation is returned, immediately yield to the main thread
        // regardless of how much time is left in the current time slice.
        currentTask.callback = continuationCallback;
        advanceTimers(currentTime);
        return true;
      } else {
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
        advanceTimers(currentTime);
      }
    } else {
      // 把这个任务 pop 掉
      pop(taskQueue);
    }
    // 取出 taskQueue 队列顶的任务,并进入下一轮 workLoop
    currentTask = peek(taskQueue);
  // ...
}

所谓执行的过程就是取出任务的回调 callback 并执行(第 9 行)。我们可以注意到执行回调之后还会拿到返回值 continuationCallback,并进行额外的判断。这是因为任务有可能并不是一次执行完的,我们之前说到过任务是可中断的,因此这个任务可能是需要分多次执行的。第九行通过调用 callback 执行了这个任务的一部分内容,得到的 continuationCallback 用来负责完成剩下的任务。

我们注意到只要返回了 continuationCallback,函数就会立即返回,从而让出主线程。这么做的原因主要在于,Scheduler 假定了只要任务返回了 continuationCallback,就是希望终止任务执行并返回到主线程(Yield to main thread if continuation is returned)。

最后会取出 taskQueue 队列顶的任务,并进入下一轮 workLoop。

function workLoop(initialTime: number) {
  // ...
  if (currentTask !== null) {
    return true;
  } else {
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    return false;
  }
}

当 while 循环退出后,返回当前是否还存在待执行任务。如果没有待执行任务,但是 timerQueue 中还有任务,则调用 requestHostTimeout 函数,在该任务要开始的时间调用 handleTimeout 来执行任务。

function handleTimeout(currentTime: number) {
  isHostTimeoutScheduled = false;
  advanceTimers(currentTime);

  if (!isHostCallbackScheduled) {
    if (peek(taskQueue) !== null) {
      isHostCallbackScheduled = true;
      requestHostCallback();
    } else {
      const firstTimer = peek(timerQueue);
      if (firstTimer !== null) {
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
      }
    }
  }
}

handleTimeout 首先通过 advanceTimers 把任务移到 taskQueue 中,如果有任务就通过 requestHostCallback 来 schedule 任务;否则就再次 requestHostTimeout。

到这里我们就讲完 Scheduler 时间切片的实现了,总结一下:

  1. 在浏览器环境下使用 MessageChannel API,从而实现在浏览器空闲时执行任务
  2. 任务按过期时间排序,并循环执行 taskQueue 中的任务,直到没有过期任务,或者时间片已用尽。否则退出循环并让出线程,在下一个事件循环中再执行任务

3. 优先级调度

上面的时间切片通过不断的从 taskQueue 中取出最早过期的任务执行,来实现 workLoop。那么问题来了:

  1. taskQueue 中的任务来自 timerQueue,timerQueue 中的任务来自哪里?
  2. 任务根据 expirationTime 决定执行顺序,这个 expirationTime 是根据什么规则设置的?

上面这些问题的答案就来自于优先级调度。优先级调度负责给出任务的优先级,从而在每个时间片中,能够根据任务的优先级决定任务的执行顺序,保障关键任务(比如用户输入)优先执行。

优先级调度的核心逻辑在 unstable_scheduleCallback 函数中,所以我们只需要关注这个函数。该函数的功能就是创建任务并 push 到 timerQueue 中。由于该函数较长,我们一部分一部分看:

function unstable_scheduleCallback(
  priorityLevel: PriorityLevel,
  callback: Callback,
  options?: {delay: number},
): Task {
  // 第一部分:记录起始时间
  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;
  }
    // 省略其他代码
}

第一部分代码做的事情很简单,就是记录这个任务创建的时间 startTime,因为需要通过记录任务的 startTime,并把 startTime 与 currentTime 进行比较来确定任务是否正在执行。大部分情况下 startTime 都小于 currentTime,但是对于延时任务 startTime = currentTime + delay,因此和正常的任务逻辑不太一样。

// 第二部分:设置 timeout
var timeout;
switch (priorityLevel) {
  case ImmediatePriority:
    // Times out immediately
    timeout = -1;
    break;
  case UserBlockingPriority:
    // Eventually times out
    timeout = userBlockingPriorityTimeout;
    break;
  case IdlePriority:
    // Never times out
    timeout = maxSigned31BitInt;
    break;
  case LowPriority:
    // Eventually times out
    timeout = lowPriorityTimeout;
    break;
  case NormalPriority:
  default:
    // Eventually times out
    timeout = normalPriorityTimeout;
    break;
}

var expirationTime = startTime + timeout;
var newTask: Task = {
  id: taskIdCounter++,
  callback,
  priorityLevel,
  startTime,
  expirationTime,
  sortIndex: -1,
};

这部分代码会根据任务的优先级,设置任务的 timeout,即该任务可以等待多久后才执行。我们可以看到Scheduler 中有五个优先级:

  • ImmediatePriority:立即执行的优先级,优先级最高,因此 timeout 被设置为 -1
  • UserBlockingPriority:用户阻塞优先级,表示任务对用户体验非常重要,需要尽快执行。通常用于用户交互相关的任务
  • IdlePriority: 空闲优先级,优先级最低,表示任务只有在系统完全空闲时才执行,因此 timeout 被设置为了 maxSigned31BitInt,表示永远不会超时。适用于后台任务或不重要的任务
  • LowPriority:低优先级
  • NormalPriority:正常优先级,适用于大多数普通任务

之后会根据任务的起始时间 + timeout 得到过期时间 expirationTime,作为任务排序的依据,并且创建任务对象 newTask。

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.
    requestHostTimeout(handleTimeout, startTime - currentTime);
  }
} 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();
  }
}

return newTask;

如果新创建的任务是延时任务,则将任务放到 timeQueue 中, 如果 taskQueue 为空,则通过 requestHostTimeout 延时执行任务。

  • 为什么 taskQueue 为空才延时执行任务:如果 taskQueue 不为空,没必要延时执行任务,因为 taskQueue 不为空时一定已经通过 requestHostCallback 请求执行任务了。等待那时候调度任务即可
  • 为什么要通过 requestHostTimeout 延时执行任务,而不是通过 requestHostCallback 请求在下一个事件循环执行任务:因为现在没有可以立即执行的任务,并且新创建的任务是最早执行的任务,下一个事件循环肯定还不到执行的时间,因此直接在该任务预计开始的时间(即等待 startTime - currentTime 之后)再调度该任务

如果新创建的任务不是延时任务,即已经准备被执行了,则放到 taskQueue 中。并通过 requestHostCallback 请求调度执行任务。

到这里我们就讲完 Scheduler 优先级调度的实现了,总结一下:

  1. 根据任务的优先级给定 expirationTime,作为 taskQueue 中任务排序的依据
  2. 根据任务是否是延时任务,分别调用 requestHostTimeout 或 requestHostCallback 请求调度执行任务

4. 其他问题

4.1. 为什么和 Vue 的 Scheduler 原理不一样?

如果你曾经了解过 Vue 的 Scheduler 原理,你可能会疑惑为什么两者的实现不尽相同:

  • Vue 的 Scheduler 是通过 Promise 创建微任务来实现的,并在微任务中批量进行所有更新任务
  • React 的 Scheduler 是通过 MessageChannel API 创建宏任务来实现的,并通过时间切片在多个事件循环中分批执行更新任务

这篇文章给出了这个问题的答案:www.bakerchen.top/blogs/React…

虽然都是异步任务,但它们有很大区别。在事件循环中,如果有微任务存在则会先一直执行微任务,直到把微任务队列清空,然后再执行宏任务,并且在每个宏任务执行完毕后,会立即检查并执行所有微任务,然后再进行下一个宏任务的执行。

先明确一点:异步任务执行时是由主线程进行执行的,所以此时它们已经相当于是同步执行了(这个异步实际指的是异步任务在任务队列里面等待的时候不会影响主线程的执行)

微任务执行时不会穿插其它任务(比如浏览器渲染),所以当有大量微任务堆积时可能就会阻塞浏览器渲染(异步任务),但执行完一个宏任务时如果遇到浏览器需要渲染,则不会继续执行下一个宏任务而是转去进行浏览器渲染然后开启新的一轮事件循环。

  • 因为 React 的 Fiber 架构的出现就是为了能够随时打断,把控制权交给主线程,所以 React 采用的是宏任务,而不是会一股脑 “ 全冲完 ” 的微任务,这样可以避免微任务过多而导致的任务堆积和性能问题。
  • 也正是因为 Vue 的理念是追求响应性和即时效果并避免过多的渲染,所以它采用微任务,及时把更新任务处理完,最后让浏览器渲染一次即可。
  • 其实现如今,React 和 Vue 都不是完全使用某一种任务,在一些情况下 React 也会使用微任务,Vue 亦是如此,它们的目标都是想要结合自身情况来创造一个更优秀的框架。

尤雨溪在 2019 年的 VueConf 上也表达过这样一个观点:如果我们可以把更新做得足够快的话,理论上就不需要时间分片了

5. 总结

React Scheduler 总的工作流程如下:

  1. React 将更新任务分解成多个 Fiber 节点(可中断任务)。
  2. Scheduler 根据优先级安排这些任务的执行顺序。
  3. 在每个时间片内,React 执行尽可能多的高优先级任务。执行一个任务 -> 是否超过时间片长度? -> 如果没超过,就再执行一个;如果超过了,则交出线程让浏览器渲染 UI。
  4. 如果时间片结束但还有未完成的工作,React 也会暂停渲染,将控制权交还给浏览器。
  5. 在下一个可用的时间片,React 继续执行任务队列中的任务。