通俗易懂的React原理(十):Scheduler调度器
代码以React v19.1.0为例,https://github.com/facebook/react/tree/v19.1.0 ,并且经过简化,仅保留了关键的逻辑,以便于理解流程
所谓调度,就是有一个任务,不一定会立刻执行,而是安排在某个时机再去执行。
前面几篇也多次提到过了,React会通过调度器来执行一些任务。例如并发渲染被中断后的恢复执行,useEffect部分场景下的执行,都是由Scheduler调度执行的。React团队自己实现了一个Scheduler调度器,单独把逻辑放到一个npm包里。这个调度器是有泛用性的,而React只是通过这个Scheduler包,来完成自己的调度需求。
unstable_scheduleCallback
react-reconciler包里面,需要用调度器安排一个任务的时候,会调用scheduleCallback方法,并且把需要执行的任务传给这个方法。
回顾一下之前commitRoot里,用调度器安排useEffect的执行的时候,是这样写的。
1 | |
执行这个方法,意思就是说,我有个工作要做,你看看什么时候有空,你安排个时间做一下。
scheduleCallback对应着Scheduler npm包里面,unstable_scheduleCallback方法。
我们分段来看一下这个方法的逻辑
调度准备
1 | |
方法的入参,分别是priorityLevel,表示callback的紧急程度,粗略的理解就是紧急的任务会被优先调度。这个优先级和前面render和commit提到的lanes不完全相同,而是有着一定的对应关系。对应逻辑如下
1 | |
第二个入参是callback,就是要被安排执行的任务。第三个参数是可选参数,可以传入delay字段,表示延时,具体作用我们马上就能见到。
如果没有传入delay,那么startTime就是开始调度的时间。如果传入了大于0的delay,则startTime是当前时加伤delay
接下来,要将传入的priorityLevel,转换成对应的timeout。timeout会和startTime相加,得到expirationTime作为过期时限。调度器的设计思想是,如果任务过期了,那么任务会被更优先的执行。
startTime和experationTime是两个关键的概念,在后面还会多次出现。它们分别表示这个任务开始的时间,和过期的时间。
我们可以看到,ImmediatePriority优先级的,timeout是-1,确保这个任务已经过期,从而被优先执行。而如果是用户交互级别的优先级,则过期时间设为userBlockingPriorityTimeout,值是250ms,以此类推,不逐个介绍。
接下来,当执行这个调度的时候,会生成一个Task对象,用于存放被调度的任务。具体每个字段的命名都是自解释的,我们直接看后半部分。
安排调度
1 | |
安排调度,分为了两个情况处理。
非延时任务
我们先看else里的逻辑,即新任务不是延时任务。那么Task对象上的sortIndex会被赋值为experationTime,然后推到taskQueue里。
taskQueue从数据结构上来说,是一个以sortIndex来排序的最小堆。如果不了解最小堆的概念,需要自行学习。从含义上来说,它以最小堆的形式,以过期时间为依据,存放着所有超过了startTime的任务,并且堆顶的元素是过期时间最早的。
推入最小堆后,如果当前不是正在调度中,则会将isHostCallbackScheduled设为true,并调用requestHostCallback方法,取一个任务去执行。
延时任务
如果是延时任务,那么Task对象上的sortIndex会赋值为startTime,然后推到timerQueue里。timerQueue和taskQueue类似,也是个最小堆。只不过它的排序依据是开始时间,而不是过期时间。含义上来讲,它存放的是所有没到开始时间的任务。
taskQueue里面的任务,都是优先于timerQueue的,因为timerQueue里面的任务都还没到开始时间。
如果此时,taskQueue空了,并且timerQueue的堆顶是刚创建的这个任务,那么说明当前最优先的任务就是刚创建的这个任务了。如果不是它的话,说明其他任务开始时间比他更早,已经被调度过了,我们不需要对刚插入的任务做处理。下面看一下newTask === peek(timerQueue)的处理。
如果isHostTimeoutScheduled为true,表示已经有延时任务在等计时器的倒计时。那么应该取消这个计时器,转而去调度刚创建的任务。它才是最高优先级。如果isHostTimeoutScheduled为false,则置为true。
然后调用requestHostTimeout(handleTimeout, startTime - currentTime);去安排在一定的时机去执行刚创建的任务
requestHostCallback
我们优先看非延时任务的调度。如果isMessageLoopRunning是false(初始值就是false),那么会将它设成true,并且调用schedulePerformWorkUntilDeadline方法
1 | |
而schedulePerformWorkUntilDeadline方法,react区分了多种运行环境。通常在浏览器里,schedulePerformWorkUntilDeadline的实现是借助于MessageChannel。
1 | |
MessageChannel有两个port,分别是port1和port2。一个port可以设置一个监听方法,另一个port可以发送消息。当一个端口发送消息的时候,就会触发另一个端口的监听方法。
所以上面代码的意思就是,schedulePerformWorkUntilDeadline方法会发送消息,从而来触发另一个端口的onmessage,也就是performWorkUntilDeadline方法的执行。
为什么要绕这么一圈呢,其实MessageChannel触发onmessage的时候,是会在下一个宏任务里执行。所以下面两段代码,大致是等价的
MessageChannel实现
1 | |
setTimeout实现
1 | |
而实际上,setTimeout实现就是react在不支持MessageChannel环境的一种降级实现。只不过浏览器环境里,setTimeout有个缺陷就是,多层嵌套的setTimeout,即使delay是0ms,也会强制间隔4ms。详见这里
那其实我们也就看出来了,调度器取出一个任务,决定要执行它的时候,也需要隔一个宏任务。所以以前几篇中,我们多次提到,调度器会安排在下一个宏任务里去执行。
而实际执行任务的方法,就是performWorkUntilDeadline
performWorkUntilDeadline
1 | |
enableRequestPaint被设置为true。将needsPaint设为false,表明当前正在执行调度任务中,需要占据主线程,不会让浏览器接管主线程去渲染页面。
如果isMessageLoopRunning还是true,则将startTime更新为此刻的时间,然后调用flushWork去执行任务。hasMoreWork用于区分是否还有剩余的任务没执行完。注释里写了不catch是怕debug太麻烦。因此只是try了之后,在finally块里处理后续的逻辑。当抛出异常时,react会按有剩余任务去处理。
如果有剩余任务没完成,则再调用一遍schedulePerformWorkUntilDeadline,也就是再下一个宏任务,去执行剩余的任务。
如果所有任务都执行完了,则将isMessageLoopRunning改为false,结束这一次任务的执行。
下面,我们需要看下flushWork的逻辑
flushWork
1 | |
此时相当于已经开始进入了任务的执行阶段了,已经实际开始执行了,所以将isHostCallbackScheduled设为false。也就是说,isHostCallbackScheduled在决定执行任务,到下一个宏任务开始执行它的这个期间,是true。
然后如果此时还有个计时器在倒计时,准备调度一个延时任务,那么也取消这个计时器,因为当前有立即执行的任务了。
并且将isPerformingWork设成true,提醒一下,isPerformingWork实际被使用到,是在unstable_scheduleCallback最后,判断是否要安排调度的时候。
接下来,要不停的循环,取出任务并执行了。真正复杂的逻辑在workLoop里面
workLoop
1 | |
调用workLoop时传入的initialTime,其实就是前面调用performWorkUntilDeadline时候的currentTime。
avdancedTimers方法我们后面会展开讲,它的作用就是更新一下timerQueue和taskQueue,把已经过期的延时任务从timerQueue移到taskQueue里面去。
循环执行任务
然后我们不停循环,从taskQueue中,取出过期时间最早的任务去执行。
如果没有开启enableAlwaysYieldScheduler这个flag(当前版本应该是默认开启了),那么会在每次取出任务的时候判断一下,当前是否满足让出主线程的条件。
这个flag的含义就是,开启了之后,如果当前任务没过期,那么每次执行完都要让出主线程。但如果flag关闭,则即使任务没过期,也可以继续执行后面的任务,直到霸占了主线程一段时间,达成了shouldYieldToHost的条件,才让出主线程,让浏览器去绘制页面。
如果不让出主线程,就要执行取出的任务了。
如果任务的callback不是个函数,那么就直接把这个任务从最小堆中移除。否则,就执行这个任务的callback。
当callback返回的东西也是一个函数的时候,这个函数会被认为是遗留的任务,替换原来的callback,并且立刻结束这一次workLoop
而如果callback没返回一个函数,那么要检查一下堆顶的任务是不是之前取出来的任务。这是为了避免这种情况:执行callback过程中新插入了一个更早过期的任务,导致堆顶发生变化,然后pop的时候没有把刚才执行的任务pop出来,而是把刚插入的任务pop出来了。
这里也不需要担心说这个任务没有pop出来,会不会被重复执行。因为执行它之前,他的callback已经被设成null了,下一次取到它的时候忙会走到前面提到的,callback不是函数的逻辑,直接将它pop出来。
执行完一个任务后,取出下一个任务
然后,还会在执行完这个callback之后,拿新的时间去更新一下timerQueue队列和taskQueue队列。因为你执行完了一个调度任务,肯定要花时间的,可能还花了很久,可能有些任务已经过了开始时间了,自然要更新一下两个队列。
接下来,重新从堆顶取出来新的任务。前面提到过enableAlwaysYieldSchedulerflag是开启的,也就是每执行完一个任务,如果下一个任务没过期,那么就先休息了,跳出循环。但是如果下一个任务已经过期了,就没办法了,在while循环里进入下一轮,去执行下一个任务。
结束循环
当最终结束循环时,有两种可能。
一是上一段讲的下一个任务没过期,跳出循环了。此时要return true,表示还有任务没做完,我只是暂时不做了,你得让我一会接着做。对应的就是performWorkUntilDeadline方法里,hasMoreWork为true,然后继续调度,在下一个宏任务继续执行的场景。
另一种可能就是,taskQueue里的所有任务都已经被执行完了,那么就该去看看timerQueue了,里面是还没到开始时间的任务。如果timerQueue里面有任务,那么就通过requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);这一句去调度它。具体的逻辑,我们将在下一部分介绍。
requestHostTimeout
在workLoop的结尾取出延时任务,和最初unstable_scheduleCallback处理延时任务时,都是调用了requestHostTimeout。而requestHostTimeout的逻辑也简单,其实就是setTimeout,去执行callback。
1 | |
所以关键还是看传入的callback是什么。以上两个场景中,传入的callback都是handleTimeout
handleTimeout
1 | |
首先,已经进入了handleTimeout方法,说明已经过了requestHostTimeout里面,等倒计时的阶段,所以把isHostTimeoutScheduled改为false。也就是说,isHostCallbackScheduled这个值,是在setTimeout之前设为true,等setTimeout的callback开始执行了就变为false的。
然后,调用advanceTimers方法,根据当前的时间,把过了开始时间的任务,从timerQueue移到taskQueue里。
如果下一个宏任务里还没有任务要被执行,那么就尝试要执行任务了。如果taskQueue里有任务,那么取出堆顶的,就可以立刻调度它,等下个宏任务就执行它。
如果所有的任务都没到开始时间,那么就再调用一遍requestHostTimeout,等到堆顶的任务到达开始时间。
advanceTimers
接下来我们看一下advanceTimers的逻辑。前面已经说过好多遍了,它的功能就是根据当前的时间,更新一下两个队列。因为taskQueue里面存的是已经过了开始时间的任务,而timerQueue存的是还没到开始时间的任务
在需要的时候执行advanceTimers,把由于时间推移,timerQueue中过了开始时间的任务,移动到taskQueue里面去。
1 | |
总结
可以看到,完整的scheduler调度流程,调用链路还是比较长的。我们将主要的流程画成一张流程图,就如下图所示。里面略过了详细的处理逻辑,只保留了大致调用路径,来帮助理清整体流程。而想要了解详细逻辑,还是要看上面分段的讲解。
