【React】调度系统 - Scheduler

1,492 阅读4分钟

在最新发布的 18 版本中,React 为我们提供了一种新的能力:“让局部的更新渲染优先完成”。

所谓的 “让局部的更新渲染优先完成” ,其实本质上就是利用 React18 带来的并发特性,让高优先级的更新渲染先于低优先级的更新渲染,甚至可以中断低优先级的渲染来进行高优先级的渲染,从而保证了局部 UI 的及时响应,以此来提升我们的用户体验。

关于这一部分,可以参照我们在《Concurrent 的奥秘》中的例子:为了保证输入框的及时响应,不被列表的耗时渲染所拖累,我们设置输入框的状态更新为高优先级,列表的状态更新为低优先级。如此一来,输入框的交互响应以及UI更新都非常迅速,体验非常的流畅。

而在例子的背后,React 其实是将我们所产生的更新以及所要进行的渲染任务做了优先级的区分,然后让调度系统 Scheduler 依据优先级来进行任务的调度。

Scheduler

先来整体的看一下调度系统是如何工作的:

调度系统的工作机制基本如图中所示:

  1. 我们在数据更新的同时,会注册一个 “渲染任务” 到调度系统中

  2. 调度系统将 “渲染任务” 加入到 “任务队列” 中进行排序(实际上是个最小堆),等待执行

  3. 同时,调度系统会注册 “调度任务” 到 **macrotask**

  4. “调度任务” 执行时,会按顺序执行 “任务队列” 中的任务,直到为空或者本次 “调度任务” 的时间片到时。

  5. 如果时间片到时,而 “任务队列” 依然存在为执行的任务,需要再次注册 “调度任务” 到 **macrotask**

任务“队列”

React 是通过 **scheduleCallback** 接口来向调度系统中来注册任务。其中 **priority** 为本次任务的优先级,**callback** 为等待执行的任务。

这样的数据在调度系统内部会被转化成任务 **Task** 来进行保存。其中值得一说的是,**priority** 优先级在这里会被映射为超时时间 **timeout**,即该任务在多久内必须要被执行。

可以看到图中 Task 记录的是任务的过期时间 **expirationTime** ,其实 **expirationTime = now() + timeout**

最终任务被添加到任务“队列”(其实是个堆结构)中,并按照任务的过期时间 **expirationTime** 进行排序,这样就可以每次从 “队列” 中取出最早要过期的任务先执行。

调度任务的工作

调度任务是指:将任务“队列”中的任务拿出来,依次执行的工作。具体的话,大家可以看 Scheduler 模块中的 **flushWork** 函数。

如上图所示,当有任务被添加到调度系统中时,调度系统不是立刻去执行 “调度任务”,而是将 “调度任务” 添加到 **macrotask** 中,等待执行。

这样一来,就不会因为任务执行太久,而阻塞同步代码的执行了。

其实,这也就变相地帮助 React 实现了 Automatic Batching

调度任务呢其实就是遍历任务“队列”去依次执行,如果所有任务都执行完成,那么调度任务自然可以结束。

除此之外呢,调度任务还有另外一种停止机制:时间切片

时间切片

什么是时间切片呢?其实每次调度任务的执行都有限额的时间(比如 5 毫秒),当执行超过这个时间的时候,调度系统就需要先停下,注册下次的调度任务到 **macrotask**

这意味着,这个时候调度任务交出了JS线程的 “控制权” ,我们可以去处理交互产生的回调、页面渲染等事情了。

我们再引用一下《Concurrent 的奥秘》中的例子,它们的执行情况如下图:

上图这个是没有进行时间切片的同步渲染过程。可以看到,React 的 “渲染” 过程花了 50 毫秒才完成。在此期间,我们无法做任何事情,页面卡在了呢个地方,直到 JS 执行结束。

这个则是使用了时间切片的并发渲染过程,可以看到,对于一些耗时的 “渲染” 过程,React 将它 “拆分” 成了很多个 5 毫秒的小任务。

每个小任务执行完,都会把 “控制权” 交还,这时浏览器可以查看是否有交互回调需要执行,或者可以让浏览器渲染一帧画面。这样就使得我们页面始终保持了响应能力,不会卡住。

对于调度任务来说,它会在每处理完一个任务后,检查本次执行是否超时。如果超时就停下来,注册下一次的任务,如此循环往复... 时间切片就是这样在调度任务中实际应用。

总结

这篇文章相对独立地介绍 React 的调度系统 Scheduler 的工作情况。如果大家对 React 的并发渲染有兴趣,可以从更完整角度《Concurrent 的奥秘》来理解并发模型。