引言
为了提升用户体验,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
的,最后分析了时间切片的实现原理。