为了提升用户体验,React 团队提出了 Concurrent 模式。Concurrent 模式可以在应用更新的同时保持浏览器对用户的响应,并根据用户的设备性能和网速进行适当的调整。我们通过一个例子来看看 Legacy 模式和 Concurrent 模式之间的区别:
例子中的页面有个正方形,我们给它加了一个动画效果,会左右来回移动。id 为 root 的 div 为 React 应用的挂载点。
<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 循环:
const Item = ({i}) => { for (let i = 0; i< 999999;i++){} return <span key={i} style={{display: 'inline-block',width: '5px',height: '5px',backgroundColor: `rgb(${255*Math.random()},${255*Math.random()},${255*Math.random()})` }} />}const App = () => { const n = 2000 return (<div style={{fontSize:0}}> {[...new Array(n)].map((_, i) => { return <Item i={i} /> })}</div> ) }复制代码
以下是 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(调度器),这个就是本文所要研究的内容。
SchedulerScheduler(调度器)是 React16 新增的内容,它负责调度任务的优先级。从该库的说明中可看到,该库未来是想要成为一个通用的库:
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,来看看它有些什么功能。
调度任务优先级
import Scheduler from 'react/packages/scheduler'Scheduler.unstable_scheduleCallback(2, function func1() { console.log('1') })const task = Scheduler.unstable_scheduleCallback(1, function func2(didTimeout){ console.log('2') })复制代码
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,该参数表示当前任务是否已经过期。
延迟任务执行
import Scheduler from 'react/packages/scheduler'Scheduler.unstable_scheduleCallback(2, function func1() { console.log('1') }) Scheduler.unstable_scheduleCallback(1, function func2(){ console.log('2') }, {delay: 100})复制代码
Scheduler.unstable_scheduleCallback 第三个参数的 delay 字段可以让当前任务延时执行,即使当前任务优先级较高。所以上面的例子先打印 1,再打印 2。注意到
取消任务
import Scheduler from 'react/packages/scheduler'Scheduler.unstable_scheduleCallback(2, function func1 () { console.log('1') })const task = Scheduler.unstable_scheduleCallback(1, function func2(){ console.log('2') }) Scheduler.unstable_cancelCallback(task)复制代码
通过 Scheduler.unstable_cancelCallback 可以取消某个任务。所以上面的例子只会打印 1。
持续调度
import Scheduler from 'react/packages/scheduler'function func2(didTimeout) { if (!didTimeout) console.log(2) }function func1() { console.log(1) return func2 } Scheduler.unstable_scheduleCallback(1, func1)复制代码
当 Scheduler.unstable_scheduleCallback 所调度的任务的 callback 返回值仍然为函数时,会继续在当前 Task 中执行这个返回的函数。所以上面的例子会先打印 1,当再次执行 func2 的时候由于 didTimeout 为 true,所以不会打印 2。
让出时间
import Scheduler from 'react/packages/scheduler'function work() { while(!Scheduler.unstable_shouldYield()) {console.log('work') } console.log('yield to host') } Scheduler.unstable_scheduleCallback(1, function func2(){ work() })复制代码
通过 Scheduler.unstable_shouldYield 可以判断当前是否还有时间供任务运行。上面的例子会持续打印 work 一段时间后,最后打印 yield to host。
时间切片
了解上述基本用法之后,我们来模拟一下 React 中使用时间切片来进行 Render 的过程:
import Scheduler from 'react/packages/scheduler'function createLinkedList(n) { let p = {value: `Node 1`,next: null } const head = p for (let index = 1; index < n; index++) { p.next = { value: `Node ${index+1}`, next: null} p = p.next } return head }const head = createLinkedList(9000)let workInProgress = headfunction workLoopConcurrent() { while (workInProgress !== null && !Scheduler.unstable_shouldYield()) { performUnitOfWork(workInProgress); } }function workLoopSync() { while (workInProgress !== null) { performUnitOfWork(workInProgress); } }function performUnitOfWork(unitOfWork) { for (let i = 0; i< 999999;i++){} console.log(performance.now(), unitOfWork.value) workInProgress = unitOfWork.next }function run(didTimeout) { // 当前这个 task 已经过期了,啥都不管了,同步把剩下的工作完成 if (didTimeout) workLoopSync() // 当前这个 task 还没过期,可以悠着点,干一会歇一会 else workLoopConcurrent() if (workInProgress !== null) {return run } return null}const NormalPriority = 3; animate() Scheduler.unstable_scheduleCallback(NormalPriority, run)复制代码
该例子首先创建了一个包含 2000 节点的链表,并将表头赋值给 workInProgress,然后调度了一个任务来执行 run,该函数中根据当前任务是否过期分别调用 workLoopSync 或 workLoopConcurrent。两者的区别是,workLoopSync 会一次性同步把整个链表处理完,而 workLoopConcurrent 会在每个时间切片中处理一部分任务,当需要让出时间时,会停止 while 循环。
回到 run 函数,如果 workInProgress 不为空,即链表还未遍历完时,会返回 run 函数继续在当前调度的这个 task 中运行。这样循环了若干次后,当某次再执行 run 时 didTimeout 会为 true,此时会使用同步方式把剩下的任务一次性全部完成。
接下来我们看看这个时间切片到底是怎么实现的吧:
时间切片实现原理
首先,我们先来看看 unstable_scheduleCallback:
function unstable_scheduleCallback(priorityLevel, callback, options) { var currentTime = getCurrentTime(); // 根据 options 确定 startTime var startTime; if (typeof options === 'object' && options !== null) { ... } else { ... } // 根据优先级确定 timeout,优先级越大 timeout 越小,即越早过期 var timeout; switch (priorityLevel) { ... } var expirationTime = startTime + timeout; // 一个新的任务 var newTask = {id: taskIdCounter++, callback, priorityLevel, startTime, expirationTime,sortIndex: -1, }; if (enableProfiling) { newTask.isQueued = false; } if (startTime > currentTime) {// 任务被延迟,即 options 中指定了 delaynewTask.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);if (enableProfiling) { markTaskStart(newTask, currentTime); newTask.isQueued = true; }// 调度一个 host callback.如果当前已经调度了一个则等到下一次让出时间的时候再说if (!isHostCallbackScheduled && !isPerformingWork) { isHostCallbackScheduled = true; requestHostCallback(flushWork); } } return newTask; }复制代码
该方法首先会确定 currentTime、startTime、expirationTime,然后会新建一个 newTask,并将要调度的方法作为该对象的 callback 属性。
接着,根据该任务是否已经开始来确定走不同的分支,如果该任务还未开始,则将其放入 timerQueue 中,如果开始了则放入 taskQueue。其中 timerQueue 和 taskQueue 都是通过最小堆实现的优先级队列,timerQueue 中的元素通过 startTime 来排序,taskQueue 中的元素通过 expirationTime 排序。
我们的时间切片例子中没有指定 delay,所以我们这里会走到 else 中,将 newTask 放入到 taskQueue 中后,会执行 requestHostCallback(flushWork)。这一步会开启一个宏任务,在该任务中执行 flushWork。
查看代码可知 React 是通过 MessageChannel 来实现的:
const channel = new MessageChannel();const port = channel.port2; channel.port1.onmessage = performWorkUntilDeadline;function requestHostCallback(callback) { scheduledHostCallback = callback; if (!isMessageLoopRunning) { isMessageLoopRunning = true; port.postMessage(null); } }复制代码
这里先用 scheduledHostCallback 缓存了传递过来的 flushWork,当执行 port.postMessage(null) 时会触发执行 performWorkUntilDeadline:
const performWorkUntilDeadline = () => { if (scheduledHostCallback !== null) {const currentTime = getCurrentTime();// shouldYieldToHost 中通过这个来判断是否应该让出时间// yieldInterval 为 5ms,即一个时间切片内任务执行超过 5ms 就需要让出deadline = currentTime + yieldInterval;const hasTimeRemaining = true;let hasMoreWork = true;try { hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime); } finally { if (hasMoreWork) { port.postMessage(null); } else { isMessageLoopRunning = false; scheduledHostCallback = null; } } } else { isMessageLoopRunning = false; } needsPaint = false; };复制代码
该函数中首先会更新 deadline,这个变量比较重要,shouldYieldToHost 中就是通过这个来判断是否应该让出时间,其中 yieldInterval 为 5ms,即一个时间切片内任务执行超过 5ms 就需要让出。该函数中最后调用了 scheduledHostCallback 即 flushWork:
function flushWork(hasTimeRemaining, initialTime) { ... isPerformingWork = true; const previousPriorityLevel = currentPriorityLevel; try {if (enableProfiling) { try {return workLoop(hasTimeRemaining, initialTime); } catch (error) { ... } } else { // No catch in prod code path. return workLoop(hasTimeRemaining, initialTime); } } finally { currentTask = null; currentPriorityLevel = previousPriorityLevel; isPerformingWork = false; ... } }复制代码
这里,主要是执行了 workLoop,该函数的工作主要是不断从 taskQueue 中拿出任务 currentTask 进行处理:
function workLoop(hasTimeRemaining, initialTime) { let currentTime = initialTime; // 把 timerQueue 中开始的任务挪到 taskQueue 中 advanceTimers(currentTime); currentTask = peek(taskQueue); while ( currentTask !== null && !(enableSchedulerDebugging && isSchedulerPaused) ) {if ( currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost()) ) { // 当前任务没有过期且时间切片用完了,需要让出 break; }// 这里的 callback 是我们调用 unstable_scheduleCallback 传入的 callbackconst callback = currentTask.callback;if (typeof callback === 'function') { // 重置当前 task 的 callback 为 null currentTask.callback = null; currentPriorityLevel = currentTask.priorityLevel; const didUserCallbackTimeout = currentTask.expirationTime <= currentTime; markTaskRun(currentTask, currentTime); const continuationCallback = callback(didUserCallbackTimeout); currentTime = getCurrentTime(); if (typeof continuationCallback === 'function') {// 调用 unstable_scheduleCallback 传入的 callback 返回值仍然为函数时,继续使用当前任务来调度这个函数,这样一个任务中就会做很多件事情currentTask.callback = continuationCallback; markTaskYield(currentTask, currentTime); } else {// 调用 unstable_scheduleCallback 传入的 callback 返回值不会函数if (enableProfiling) { markTaskCompleted(currentTask, currentTime); currentTask.isQueued = false; }// 这里要判断一下的原因是有可能 callback 中又插入了优先级更高的任务if (currentTask === peek(taskQueue)) { pop(taskQueue); } } advanceTimers(currentTime); } else { pop(taskQueue); } currentTask = peek(taskQueue); } if (currentTask !== null) {// 告诉 performWorkUntilDeadline 还有任务,则 performWorkUntilDeadline 会再开启一个任务:/** try { hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime); } finally { if (hasMoreWork) { port.postMessage(null); } else { isMessageLoopRunning = false; scheduledHostCallback = null; } } **/return true; } else {// taskQueue 中没有任务了,timerQueue 有任务const firstTimer = peek(timerQueue);if (firstTimer !== null) { // requestHostTimeout 就是 setTimeout // 此时我们拿出 timerQueue 第一个任务 firstTimer,即最早开始的那个任务 // 我们延迟 firstTimer.startTime - currentTime 这个时间后,执行 handleTimeout requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); }return false; } }复制代码
当结束循环时,有两种情况:
- currentTask 不为空,此时返回 true 告诉 performWorkUntilDeadline 还有工作,则 performWorkUntilDeadline 会开启一个新的宏任务来继续处理。这样,就又开启了新一轮的 performWorkUntilDeadline -> flushWork -> workLoop。
- currentTask 为空,此时如果 timerQueue 也不为空的话,按理说跟 currentTask 不为空时一样的处理方式也可,因为 timerQueue 中的任务总会在某一次调度的过程中开始,但是这样可能会导致有很多宏任务中什么任务都没有执行,白白造成浪费。于是这里采用了一个更高效的做法,即直接通过 setTimeout 来开启一个宏任务,而 setTimeout 的延迟时间是 timerQueue 第一个任务(即最早开始的那个任务)与当前时间的差值。而 setTimeout 开启的宏任务中,执行的是 handleTimeout:
function handleTimeout(currentTime) { isHostTimeoutScheduled = false; // 将 timerQueue 中已开始的任务移到 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); } } } }复制代码
这里调用了 requestHostCallback(flushWork),剩下的流程就跟之前的一样了。也许你会好奇这里为什么存在 peek(taskQueue) 为空这种情况,因为有可能从 requestHostTimeout 到 handleTimeout 这一段时间内,用户取消掉了最早开始的那个任务。
至此,时间切片的大致运行流程就分析完了,可用下图表示:
总结本文首先通过一个列子引出了 React 的 Concurrent 模式,然后介绍了 Scheduler 的基本使用方法并模拟了 React 在 Concurrent 模式下是如何使用时间切片来进行 Render 的,最后分析了时间切片的实现原理。