通俗易懂的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
2
3
4
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects(true);
return null;
});

执行这个方法,意思就是说,我有个工作要做,你看看什么时候有空,你安排个时间做一下。

scheduleCallback对应着Scheduler npm包里面,unstable_scheduleCallback方法。

我们分段来看一下这个方法的逻辑

调度准备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
function unstable_scheduleCallback(
priorityLevel: PriorityLevel,
callback: Callback,
options?: {delay: number},
): Task {
var currentTime = getCurrentTime();

var startTime;
if (typeof options === 'object' && options !== null) {
var delay = options.delay;
if (typeof delay === 'number' && delay > 0) {
startTime = currentTime + delay;
} else {
startTime = currentTime;
}
} else {
startTime = currentTime;
}

var timeout;
switch (priorityLevel) {
case ImmediatePriority:
// Times out immediately
timeout = -1;
break;
case UserBlockingPriority:
// Eventually times out
timeout = userBlockingPriorityTimeout;
break;
case IdlePriority:
// Never times out
timeout = maxSigned31BitInt;
break;
case LowPriority:
// Eventually times out
timeout = lowPriorityTimeout;
break;
case NormalPriority:
default:
// Eventually times out
timeout = normalPriorityTimeout;
break;
}

var expirationTime = startTime + timeout;

var newTask: Task = {
id: taskIdCounter++,
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex: -1,
};
// 后略
}

方法的入参,分别是priorityLevel,表示callback的紧急程度,粗略的理解就是紧急的任务会被优先调度。这个优先级和前面render和commit提到的lanes不完全相同,而是有着一定的对应关系。对应逻辑如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
switch (lanesToEventPriority(nextLanes)) {
// Scheduler does have an "ImmediatePriority", but now that we use
// microtasks for sync work we no longer use that. Any sync work that
// reaches this path is meant to be time sliced.
case DiscreteEventPriority:
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingSchedulerPriority;
break;
case DefaultEventPriority:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
case IdleEventPriority:
schedulerPriorityLevel = IdleSchedulerPriority;
break;
default:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
}

export function lanesToEventPriority(lanes: Lanes): EventPriority {
const lane = getHighestPriorityLane(lanes);
if (!isHigherEventPriority(DiscreteEventPriority, lane)) {
return DiscreteEventPriority;
}
if (!isHigherEventPriority(ContinuousEventPriority, lane)) {
return ContinuousEventPriority;
}
if (includesNonIdleWork(lane)) {
return DefaultEventPriority;
}
return IdleEventPriority;
}

第二个入参是callback,就是要被安排执行的任务。第三个参数是可选参数,可以传入delay字段,表示延时,具体作用我们马上就能见到。

如果没有传入delay,那么startTime就是开始调度的时间。如果传入了大于0的delay,则startTime是当前时加伤delay

接下来,要将传入的priorityLevel,转换成对应的timeouttimeout会和startTime相加,得到expirationTime作为过期时限。调度器的设计思想是,如果任务过期了,那么任务会被更优先的执行。

startTimeexperationTime是两个关键的概念,在后面还会多次出现。它们分别表示这个任务开始的时间,和过期的时间。

我们可以看到,ImmediatePriority优先级的,timeout是-1,确保这个任务已经过期,从而被优先执行。而如果是用户交互级别的优先级,则过期时间设为userBlockingPriorityTimeout,值是250ms,以此类推,不逐个介绍。

接下来,当执行这个调度的时候,会生成一个Task对象,用于存放被调度的任务。具体每个字段的命名都是自解释的,我们直接看后半部分。

安排调度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function unstable_scheduleCallback(
priorityLevel: PriorityLevel,
callback: Callback,
options?: {delay: number},
): Task {
// 前略
if (startTime > currentTime) {
// This is a delayed task.
newTask.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);
// Schedule a host callback, if needed. If we're already performing work,
// wait until the next time we yield.
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback();
}
}

return newTask;
}

安排调度,分为了两个情况处理。

非延时任务

我们先看else里的逻辑,即新任务不是延时任务。那么Task对象上的sortIndex会被赋值为experationTime,然后推到taskQueue里。

taskQueue从数据结构上来说,是一个以sortIndex来排序的最小堆。如果不了解最小堆的概念,需要自行学习。从含义上来说,它以最小堆的形式,以过期时间为依据,存放着所有超过了startTime的任务,并且堆顶的元素是过期时间最早的。

推入最小堆后,如果当前不是正在调度中,则会将isHostCallbackScheduled设为true,并调用requestHostCallback方法,取一个任务去执行。

延时任务

如果是延时任务,那么Task对象上的sortIndex会赋值为startTime,然后推到timerQueue里。timerQueuetaskQueue类似,也是个最小堆。只不过它的排序依据是开始时间,而不是过期时间。含义上来讲,它存放的是所有没到开始时间的任务。

taskQueue里面的任务,都是优先于timerQueue的,因为timerQueue里面的任务都还没到开始时间。

如果此时,taskQueue空了,并且timerQueue的堆顶是刚创建的这个任务,那么说明当前最优先的任务就是刚创建的这个任务了。如果不是它的话,说明其他任务开始时间比他更早,已经被调度过了,我们不需要对刚插入的任务做处理。下面看一下newTask === peek(timerQueue)的处理。

如果isHostTimeoutScheduled为true,表示已经有延时任务在等计时器的倒计时。那么应该取消这个计时器,转而去调度刚创建的任务。它才是最高优先级。如果isHostTimeoutScheduled为false,则置为true。

然后调用requestHostTimeout(handleTimeout, startTime - currentTime);去安排在一定的时机去执行刚创建的任务

requestHostCallback

我们优先看非延时任务的调度。如果isMessageLoopRunning是false(初始值就是false),那么会将它设成true,并且调用schedulePerformWorkUntilDeadline方法

1
2
3
4
5
6
function requestHostCallback() {
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}

schedulePerformWorkUntilDeadline方法,react区分了多种运行环境。通常在浏览器里,schedulePerformWorkUntilDeadline的实现是借助于MessageChannel

1
2
3
4
5
6
7
8
// DOM and Worker environments.
// We prefer MessageChannel because of the 4ms setTimeout clamping.
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};

MessageChannel有两个port,分别是port1和port2。一个port可以设置一个监听方法,另一个port可以发送消息。当一个端口发送消息的时候,就会触发另一个端口的监听方法。

所以上面代码的意思就是,schedulePerformWorkUntilDeadline方法会发送消息,从而来触发另一个端口的onmessage,也就是performWorkUntilDeadline方法的执行。

为什么要绕这么一圈呢,其实MessageChannel触发onmessage的时候,是会在下一个宏任务里执行。所以下面两段代码,大致是等价的

MessageChannel实现

1
2
3
4
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};

setTimeout实现

1
2
3
schedulePerformWorkUntilDeadline = () => {
setTimeout(performWorkUntilDeadline, 0);
};

而实际上,setTimeout实现就是react在不支持MessageChannel环境的一种降级实现。只不过浏览器环境里,setTimeout有个缺陷就是,多层嵌套的setTimeout,即使delay是0ms,也会强制间隔4ms。详见这里

那其实我们也就看出来了,调度器取出一个任务,决定要执行它的时候,也需要隔一个宏任务。所以以前几篇中,我们多次提到,调度器会安排在下一个宏任务里去执行

而实际执行任务的方法,就是performWorkUntilDeadline

performWorkUntilDeadline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const performWorkUntilDeadline = () => {
if (enableRequestPaint) {
needsPaint = false;
}
if (isMessageLoopRunning) {
const currentTime = getCurrentTime();
// Keep track of the start time so we can measure how long the main thread
// has been blocked.
startTime = currentTime;

// If a scheduler task throws, exit the current browser task so the
// error can be observed.
//
// Intentionally not using a try-catch, since that makes some debugging
// techniques harder. Instead, if `flushWork` errors, then `hasMoreWork` will
// remain true, and we'll continue the work loop.
let hasMoreWork = true;
try {
hasMoreWork = flushWork(currentTime);
} finally {
if (hasMoreWork) {
// If there's more work, schedule the next message event at the end
// of the preceding one.
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
}
}
}
};

enableRequestPaint被设置为true。将needsPaint设为false,表明当前正在执行调度任务中,需要占据主线程,不会让浏览器接管主线程去渲染页面。

如果isMessageLoopRunning还是true,则将startTime更新为此刻的时间,然后调用flushWork去执行任务。hasMoreWork用于区分是否还有剩余的任务没执行完。注释里写了不catch是怕debug太麻烦。因此只是try了之后,在finally块里处理后续的逻辑。当抛出异常时,react会按有剩余任务去处理。

如果有剩余任务没完成,则再调用一遍schedulePerformWorkUntilDeadline,也就是再下一个宏任务,去执行剩余的任务。

如果所有任务都执行完了,则将isMessageLoopRunning改为false,结束这一次任务的执行。

下面,我们需要看下flushWork的逻辑

flushWork

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function flushWork(initialTime: number) {
// We'll need a host callback the next time work is scheduled.
isHostCallbackScheduled = false;
if (isHostTimeoutScheduled) {
// We scheduled a timeout but it's no longer needed. Cancel it.
isHostTimeoutScheduled = false;
cancelHostTimeout();
}

isPerformingWork = true;
const previousPriorityLevel = currentPriorityLevel;
try {
// No catch in prod code path.
return workLoop(initialTime);
} finally {
currentTask = null;
currentPriorityLevel = previousPriorityLevel;
isPerformingWork = false;
}
}

此时相当于已经开始进入了任务的执行阶段了,已经实际开始执行了,所以将isHostCallbackScheduled设为false。也就是说,isHostCallbackScheduled在决定执行任务,到下一个宏任务开始执行它的这个期间,是true。

然后如果此时还有个计时器在倒计时,准备调度一个延时任务,那么也取消这个计时器,因为当前有立即执行的任务了。

并且将isPerformingWork设成true,提醒一下,isPerformingWork实际被使用到,是在unstable_scheduleCallback最后,判断是否要安排调度的时候。

接下来,要不停的循环,取出任务并执行了。真正复杂的逻辑在workLoop里面

workLoop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
function workLoop(initialTime: number) {
let currentTime = initialTime;
advanceTimers(currentTime);
currentTask = peek(taskQueue);
while (currentTask !== null) {
if (!enableAlwaysYieldScheduler) {
if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
// This currentTask hasn't expired, and we've reached the deadline.
break;
}
}
// $FlowFixMe[incompatible-use] found when upgrading Flow
const callback = currentTask.callback;
if (typeof callback === 'function') {
// $FlowFixMe[incompatible-use] found when upgrading Flow
currentTask.callback = null;
// $FlowFixMe[incompatible-use] found when upgrading Flow
currentPriorityLevel = currentTask.priorityLevel;
// $FlowFixMe[incompatible-use] found when upgrading Flow
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// If a continuation is returned, immediately yield to the main thread
// regardless of how much time is left in the current time slice.
// $FlowFixMe[incompatible-use] found when upgrading Flow
currentTask.callback = continuationCallback;
advanceTimers(currentTime);
return true;
} else {
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
advanceTimers(currentTime);
}
} else {
pop(taskQueue);
}
currentTask = peek(taskQueue);
if (enableAlwaysYieldScheduler) {
if (currentTask === null || currentTask.expirationTime > currentTime) {
// This currentTask hasn't expired we yield to the browser task.
break;
}
}
}
// Return whether there's additional work
if (currentTask !== null) {
return true;
} else {
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
return false;
}
}

调用workLoop时传入的initialTime,其实就是前面调用performWorkUntilDeadline时候的currentTime

avdancedTimers方法我们后面会展开讲,它的作用就是更新一下timerQueuetaskQueue,把已经过期的延时任务从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
2
3
4
5
6
7
8
9
function requestHostTimeout(
callback: (currentTime: number) => void,
ms: number,
) {
// $FlowFixMe[not-a-function] nullable value
taskTimeoutID = localSetTimeout(() => {
callback(getCurrentTime());
}, ms);
}

所以关键还是看传入的callback是什么。以上两个场景中,传入的callback都是handleTimeout

handleTimeout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function handleTimeout(currentTime: number) {
isHostTimeoutScheduled = false;
advanceTimers(currentTime);

if (!isHostCallbackScheduled) {
if (peek(taskQueue) !== null) {
isHostCallbackScheduled = true;
requestHostCallback();
} else {
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
}
}
}

首先,已经进入了handleTimeout方法,说明已经过了requestHostTimeout里面,等倒计时的阶段,所以把isHostTimeoutScheduled改为false。也就是说,isHostCallbackScheduled这个值,是在setTimeout之前设为true,等setTimeout的callback开始执行了就变为false的。

然后,调用advanceTimers方法,根据当前的时间,把过了开始时间的任务,从timerQueue移到taskQueue里。

如果下一个宏任务里还没有任务要被执行,那么就尝试要执行任务了。如果taskQueue里有任务,那么取出堆顶的,就可以立刻调度它,等下个宏任务就执行它。

如果所有的任务都没到开始时间,那么就再调用一遍requestHostTimeout,等到堆顶的任务到达开始时间。

advanceTimers

接下来我们看一下advanceTimers的逻辑。前面已经说过好多遍了,它的功能就是根据当前的时间,更新一下两个队列。因为taskQueue里面存的是已经过了开始时间的任务,而timerQueue存的是还没到开始时间的任务

在需要的时候执行advanceTimers,把由于时间推移,timerQueue中过了开始时间的任务,移动到taskQueue里面去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function advanceTimers(currentTime: number) {
// Check for tasks that are no longer delayed and add them to the queue.
let timer = peek(timerQueue);
while (timer !== null) {
if (timer.callback === null) {
// Timer was cancelled.
pop(timerQueue);
} else if (timer.startTime <= currentTime) {
// Timer fired. Transfer to the task queue.
pop(timerQueue);
timer.sortIndex = timer.expirationTime;
push(taskQueue, timer);
} else {
// Remaining timers are pending.
return;
}
timer = peek(timerQueue);
}
}

总结

可以看到,完整的scheduler调度流程,调用链路还是比较长的。我们将主要的流程画成一张流程图,就如下图所示。里面略过了详细的处理逻辑,只保留了大致调用路径,来帮助理清整体流程。而想要了解详细逻辑,还是要看上面分段的讲解。


通俗易懂的React原理(十):Scheduler调度器
https://miku03090831.github.io/2025/10/05/通俗易懂的React原理(十):Scheduler调度器/
作者
qh_meng
发布于
2025年10月5日
许可协议