引言
为了提升用户体验,React 团队提出了 Concurrent 模式。Concurrent 模式可以在应用更新的同时保持浏览器对用户的响应,并根据用户的设备性能和网速进行适当的调整。我们通过一个例子来看看 Legacy 模式和 Concurrent 模式之间的区别:
例子中的页面有个正方形,我们给它加了一个动画效果,会左右来回移动。id 为 root 的 div 为 React 应用的挂载点。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21<style>
@keyframes move {
from {
margin-left: 0;
}
to {
margin-left: 200px;
}
}
#square {
width: 100px;
height: 100px;
margin-top: 10px;
background-color: red;
animation: move 2s ease 0s infinite alternate;
}
</style>
<body>
<div id="square"></div>
<div id="root"></div>
</body>
我们的 React 应用比较简单,渲染了 2000 个颜色不一的正方形,为了模拟繁重的渲染工作,我们让每一个 Item 函数组件执行的时候运行一个比较耗时的 for 循环:
1 | const Item = ({i}) => { |
以下是 Legacy (ReactDOM.render(<App />, rootEle)) 和 Concurrent (ReactDOM.unstable_createRoot(rootEle).render(<App />)) 两种模式渲染效果的对比:
| Legacy | Concurrent |
|---|---|
![]() |
![]() |
可以看到,Legacy 模式下,正方形出现后就不动了,一直要等到渲染过程完全结束后动画才开始进行,而 Concurrent 模式下则没有出现这种情况。
通过浏览器的 performance 面板,我们发现 Legacy 模式下 Render 阶段(详见React 源码解读之首次渲染流程)都在一个 Task 中完成,导致该 Task 执行时间过长,阻塞了浏览器的其他工作:

而 Concurrent 模式下, Render 阶段被分成了一个个的小任务:

实现时间切片这个功能,少不了 React 新加入的 Scheduler(调度器),这个就是本文所要研究的内容。
Scheduler
Scheduler(调度器)是 React16 新增的内容,它负责调度任务的优先级。从该库的说明中可看到,该库未来是想要成为一个通用的库:
1 | This is a package for cooperative scheduling in a browser environment. It is currently used internally by React, but we plan to make it more generic. |
所以我们这里也先抛开 React,来看看它有些什么功能。
调度任务优先级
1 | import Scheduler from 'react/packages/scheduler' |
Scheduler.unstable_scheduleCallback 第一个参数为任务的优先级(越小越高)。所以上面的例子先打印 2,再打印 1。
这里有几个点需要注意:
1 Scheduler.unstable_scheduleCallback 会返回一个 task,该 task 有如下属性:
| 属性 | 说明 |
|---|---|
| id | |
| callback | 传入 unstable_scheduleCallback 的函数 |
| priorityLevel | 传入 unstable_scheduleCallback 的优先级 |
| startTime | 任务的开始时间 |
| expirationTime | 任务的过期时间 |
| sortIndex | 任务用于排序的字段,一般为 startTime 或 expirationTime 的值 |
2 任务回调函数在执行时会传入一个参数,即上述代码中的 didTimeout,该参数表示当前任务是否已经过期。
延迟任务执行
1 | import Scheduler from 'react/packages/scheduler' |
Scheduler.unstable_scheduleCallback 第三个参数的 delay 字段可以让当前任务延时执行,即使当前任务优先级较高。所以上面的例子先打印 1,再打印 2。注意到
取消任务
1 | import Scheduler from 'react/packages/scheduler' |
通过 Scheduler.unstable_cancelCallback 可以取消某个任务。所以上面的例子只会打印 1。
持续调度
1 | import Scheduler from 'react/packages/scheduler' |
当 Scheduler.unstable_scheduleCallback 所调度的任务的 callback 返回值仍然为函数时,会继续在当前 Task 中执行这个返回的函数。所以上面的例子会先打印 1,当再次执行 func2 的时候由于 didTimeout 为 true,所以不会打印 2。
让出时间
1 | import Scheduler from 'react/packages/scheduler' |
通过 Scheduler.unstable_shouldYield 可以判断当前是否还有时间供任务运行。上面的例子会持续打印 work 一段时间后,最后打印 yield to host。
时间切片
了解上述基本用法之后,我们来模拟一下 React 中使用时间切片来进行 Render 的过程:
1 | import Scheduler from 'react/packages/scheduler' |
该例子首先创建了一个包含 2000 节点的链表,并将表头赋值给 workInProgress,然后调度了一个任务来执行 run,该函数中根据当前任务是否过期分别调用 workLoopSync 或 workLoopConcurrent。两者的区别是,workLoopSync 会一次性同步把整个链表处理完,而 workLoopConcurrent 会在每个时间切片中处理一部分任务,当需要让出时间时,会停止 while 循环。
回到 run 函数,如果 workInProgress 不为空,即链表还未遍历完时,会返回 run 函数继续在当前调度的这个 task 中运行。这样循环了若干次后,当某次再执行 run 时 didTimeout 会为 true,此时会使用同步方式把剩下的任务一次性全部完成。
接下来我们看看这个时间切片到底是怎么实现的吧:
时间切片实现原理
首先,我们先来看看 unstable_scheduleCallback:
1 | function unstable_scheduleCallback(priorityLevel, callback, options) { |
该方法首先会确定 currentTime、startTime、expirationTime,然后会新建一个 newTask,并将要调度的方法作为该对象的 callback 属性。
接着,根据该任务是否已经开始来确定走不同的分支,如果该任务还未就绪,则将其放入 timerQueue 中,如果开始了则放入 taskQueue。其中 timerQueue 和 taskQueue 都是通过最小堆实现的优先级队列,timerQueue 中的元素通过 startTime 来排序,taskQueue 中的元素通过 expirationTime 排序。
我们的时间切片例子中没有指定 delay,所以我们这里会走到 else 中,将 newTask 放入到 taskQueue 中后,会执行 requestHostCallback(flushWork)。这一步会开启一个宏任务,在该任务中执行 flushWork。
查看代码可知 React 是通过 MessageChannel 来实现的:
1 | const channel = new MessageChannel(); |
这里先用 scheduledHostCallback 缓存了传递过来的 flushWork,当执行 port.postMessage(null) 时会触发执行 performWorkUntilDeadline:
1 | const performWorkUntilDeadline = () => { |
该函数中首先会更新 deadline,这个变量比较重要,shouldYieldToHost 中就是通过这个来判断是否应该让出时间,其中 yieldInterval 为 5ms,即一个时间切片内任务执行超过 5ms 就需要让出。该函数中最后调用了 scheduledHostCallback 即 flushWork:
1 | function flushWork(hasTimeRemaining, initialTime) { |
这里,主要是执行了 workLoop,该函数的工作主要是不断从 taskQueue 中拿出任务 currentTask 进行处理:
1 | function workLoop(hasTimeRemaining, initialTime) { |
当结束循环时,有两种情况:
currentTask不为空,此时返回true告诉performWorkUntilDeadline还有工作,则performWorkUntilDeadline会开启一个新的宏任务来继续处理。这样,就又开启了新一轮的performWorkUntilDeadline->flushWork->workLoop。currentTask为空,此时如果timerQueue也不为空的话,按理说跟currentTask不为空时一样的处理方式也可,因为timerQueue中的任务总会在某一次调度的过程中开始,但是这样可能会导致有很多宏任务中什么任务都没有执行,白白造成浪费。于是这里采用了一个更高效的做法,即直接通过setTimeout来开启一个宏任务,而setTimeout的延迟时间是timerQueue第一个任务(即最早开始的那个任务)与当前时间的差值。而setTimeout开启的宏任务中,执行的是handleTimeout:
1 | function handleTimeout(currentTime) { |
这里调用了 requestHostCallback(flushWork),剩下的流程就跟之前的一样了。也许你会好奇这里为什么存在 peek(taskQueue) 为空这种情况,因为有可能从 requestHostTimeout 到 handleTimeout 这一段时间内,用户取消掉了最早开始的那个任务。
至此,时间切片的大致运行流程就分析完了,可用下图表示:
总结
本文首先通过一个列子引出了 React 的 Concurrent 模式,然后介绍了 Scheduler 的基本使用方法并模拟了 React 在 Concurrent 模式下是如何使用时间切片来进行 Render 的,最后分析了时间切片的实现原理。

