深入 React 调度器:任务调度器的选择取舍
一帧中的任务
通常对于浏览器这一宿主环境来说,一帧中的任务会包含 事件循环 tick
(执行一个宏任务,然后清空所有已注册的微任务)、浏览器渲染周期 api 以及 浏览器渲染任务,整体执行流程如下:
- 执行一个宏任务 (Task): 从宏任务队列中选择一个最旧的、可运行的宏任务并执行它。这些宏任务通常包含
setTimeout
、setInterval
、MessageChannel
、I/O 操作
、UI 交互事件
等回调函数。 - 执行所有微任务 (Microtask Checkpoint): 执行当前宏任务执行完毕后存在的所有微任务。如果微任务执行过程中又添加了新的微任务,则继续执行,直到微任务队列为空。这些微任务通常包含
Promise.resolve().then(fn)
、queueMicrotask(fn)
、MutationObserver
等回调函数。 - 更新渲染 (Update the Rendering - 按需):
- 执行所有
requestAnimationFrame (rAF)
回调。 - 执行样式计算、布局、合成和绘制等渲染步骤 (如果浏览器确定需要渲染)。
- 执行所有
- 执行
requestIdleCallback
回调 (按需且有空闲时间): 如果当前帧有空闲时间,并且requestIdleCallback
队列中有回调,则执行它们。
例子说明
const channel = new MessageChannel();
channel.port1.onmessage = ({ data: { message } }) => {
console.log('MessageChannel Callback ', message);
};
const callback = () => {
requestIdleCallback(() => {
console.log('requestIdleCallback');
Promise.resolve().then(res => {
console.log('requestIdleCallback - Promise');
});
channel.port2.postMessage({ message: 'MessageChannel - 3' });
});
requestAnimationFrame(() => {
channel.port2.postMessage({ message: 'MessageChannel - 2' });
console.log('requestAnimationFrame');
Promise.resolve().then(res => {
console.log('requestAnimationFrame - Promise');
});
});
Promise.resolve().then(res => {
console.log('Promise');
channel.port2.postMessage({ message: 'MessageChannel - 1' });
});
};
callback();
上述例子的 输出结果 为:
Promise
requestAnimationFrame
requestAnimationFrame - Promise
MessageChannel Callback MessageChannel - 1
MessageChannel Callback MessageChannel - 2
requestIdleCallback
requestIdleCallback - Promise
MessageChannel Callback MessageChannel - 3
解释:
初始状态: 所有任务队列均为空。
Tick 1: 初始脚本执行
- 宏任务: 主线程开始执行全局脚本,这是第一个宏任务。
callback()
函数被调用。Promise.resolve().then(...)
注册一个 微任务 A。requestIdleCallback(...)
注册一个 Idle 宏任务 到 Idle 任务队列。requestAnimationFrame(...)
注册一个 rAF 宏任务 到 动画帧回调队列。
- 宏任务结束: 脚本同步代码执行完毕。
- 微任务阶段: 清空微任务队列。
- 执行 微任务 A 的回调,控制台输出
Promise
日志(✅)。 - 执行
channel.port2.postMessage({ message: 'MessageChannel - 1' })
,注册一个 宏任务 M1 到 标准任务队列。
- 执行 微任务 A 的回调,控制台输出
Tick 1
执行结束- 当前队列状态:
- rAF 队列:
[rAF 回调]
- 任务队列:
[M1]
- Idle 队列:
[Idle 回调]
- rAF 队列:
- 当前队列状态:
- 宏任务: 主线程开始执行全局脚本,这是第一个宏任务。
Tick 2: 动画帧回调
- 宏任务: 事件循环进入下一轮。根据优先级,浏览器准备渲染更新,因此从 rAF 队列 中取出 rAF 宏任务 执行。
channel.port2.postMessage({ message: 'MessageChannel - 2' })
被调用,注册一个宏任务 M2 到标准任务队列。- 控制台输出
requestAnimationFrame
日志(✅)。 Promise.resolve().then(...)
注册一个微任务 B。
- 宏任务结束: rAF 回调执行完毕。
- 微任务阶段: 清空微任务队列,执行 微任务 B 的回调,控制台输出
requestAnimationFrame - Promise
日志(✅)。 - Tick 2 结束
- 当前队列状态:
- 任务队列:
[M1, M2]
- Idle 队列:
[Idle 回调]
- 任务队列:
- 当前队列状态:
- 宏任务: 事件循环进入下一轮。根据优先级,浏览器准备渲染更新,因此从 rAF 队列 中取出 rAF 宏任务 执行。
Tick 3: 第一个 MessageChannel 消息
- 宏任务: 事件循环从标准任务队列中按先进先出原则取出 宏任务 M1 执行。
channel.port1.onmessage
回调被触发,控制台输出MessageChannel Callback MessageChannel - 1
日志(✅)。
- 宏任务结束: M1 回调执行完毕。
- 微任务阶段: 微任务队列为空,跳过。
- Tick 3 结束
- 当前队列状态:
- 任务队列:
[M2]
- Idle 队列:
[Idle 回调]
- 任务队列:
- 当前队列状态:
- 宏任务: 事件循环从标准任务队列中按先进先出原则取出 宏任务 M1 执行。
Tick 4: 第二个 MessageChannel 消息
- 宏任务: 事件循环继续从标准任务队列中取出 宏任务 M2 执行。
channel.port1.onmessage
回调被触发。- 控制台输出
MessageChannel Callback MessageChannel - 2
日志(✅)。
- 宏任务结束: M2 回调执行完毕。
- 微任务阶段: 微任务队列为空,跳过。
- Tick 4 结束
- 当前队列状态:
- 任务队列:
[]
- Idle 队列:
[Idle 回调]
- 任务队列:
- 当前队列状态:
- 宏任务: 事件循环继续从标准任务队列中取出 宏任务 M2 执行。
Tick 5: 空闲状态回调 (requestIdleCallback)
- 宏任务: 事件循环发现高优先级队列已空,且浏览器处于空闲状态。因此,从 Idle 任务队列 中取出 Idle 宏任务 执行。
- 控制台输出
requestIdleCallback
日志(✅)。 - 执行
Promise.resolve().then(...)
注册一个微任务 C。 - 执行
channel.port2.postMessage({ message: 'MessageChannel - 3' })
,注册一个宏任务 M3 到标准任务队列。
- 控制台输出
- 宏任务结束: Idle 回调执行完毕。
- 微任务阶段: 清空微任务队列。
- 执行 微任务 C 的回调,控制台输出
requestIdleCallback - Promise
日志(✅)。
- 执行 微任务 C 的回调,控制台输出
- Tick 5 结束
- 当前队列状态:
- 任务队列:
[M3]
- Idle 队列:
[]
- 任务队列:
- 当前队列状态:
- 宏任务: 事件循环发现高优先级队列已空,且浏览器处于空闲状态。因此,从 Idle 任务队列 中取出 Idle 宏任务 执行。
Tick 6: 第三个 MessageChannel 消息
- 宏任务: 事件循环从标准任务队列中取出宏任务 M3 执行。
channel.port1.onmessage
回调被触发。- 控制台输出
MessageChannel Callback MessageChannel - 3
日志(✅)。
- 宏任务结束: M3 回调执行完毕。
- 微任务阶段: 微任务队列为空,跳过。
- Tick 6 结束
- 当前队列状态: 所有不同种类的任务队列均为已清空,任务执行完成。
- 宏任务: 事件循环从标准任务队列中取出宏任务 M3 执行。
流程总结
Tick | 宏任务类型 | 控制台输出 |
---|---|---|
1 | 初始脚本 | (无) |
2 | requestAnimationFrame | requestAnimationFrame requestAnimationFrame - Promise |
3 | MessageChannel (第一个) | MessageChannel Callback MessageChannel - 1 |
4 | MessageChannel (第二个) | MessageChannel Callback MessageChannel - 2 |
5 | requestIdleCallback | requestIdleCallback requestIdleCallback - Promise |
6 | MessageChannel (第三个) | MessageChannel Callback MessageChannel - 3 |
接下来将 requestAnimationFrame
和 requestIdleCallback
统称为 环境渲染任务(即浏览器渲染周期 api 调度的任务)。那么对于浏览器宿主环境的任务类型来说,可以划分为 宏任务、微任务、环境渲染任务 三类任务类型,后续的内容就对这三类任务做具体的可行性分析。
可行性分析
首先需要先确定一下是要使用宏任务,还是微任务。
微任务的可行性分析
react
需要具备并发执行的特性,并发就意味着单个事件循环 tick
中只能执行部分任务,剩余的任务分配给后续多个事件循环 tick
中执行。
在单个事件循环 tick
中,是会清空所有的微任务队列。若使用调度采用微任务,那么就需要做到最基本的一件事情是,如何在这一个事件循环 tick
中执行受限微任务,在后续的事件循环 tick
中再执行剩余的受限微任务。
直接在一个事件循环 tick
中推送多个微任务是不切实际的,这些微任务都会被执行掉,这是同步调度而非并发调度。并发的意义在于可以控制一帧任务中控制要执行哪些任务。
那么是否宿主环境中存在能检测事件循环 tick
开始的时机,在这个时机中提前注册要执行的微任务似乎也能满足要求。但不幸的是,宿主环境中并没有提供观测事件循环 tick
开始时机的钩子,这对于 react
来说就无法通过微任务得知下一个事件循环 tick 的时机。
既然没有的话,那么就需要微任务依附在环境 api
,跟随环境 api
的执行时机而触发调度,接下来分析一下环境 api
(requestAnimationFrame
、requestIdleCallback
)。
浏览器渲染任务的可行性分析 (rAF / rIC)
测试用例
let startTime = null;
let exCount = 0;
const handler = function (info) {
return function (count) {
console.log(
`VI-LOG_${info}_${count} -- Time Overhead: `,
new Date().getTime() - startTime
);
};
};
const requestIdleCallbackOnMessage = handler('requestIdleCallback');
const requestAnimationFrameOnMessage = handler('requestAnimationFrame');
const channel = new MessageChannel();
channel.port1.onmessage = function wrap({ data }) {
const { count } = data;
channelOnMessage(count);
};
const triggerMessageChannel = count => {
channel.port2.postMessage({ count });
};
const callback = exCount => {
startTime = new Date().getTime();
requestIdleCallback(requestIdleCallbackOnMessage.bind(null, exCount));
requestAnimationFrame(requestAnimationFrameOnMessage.bind(null, exCount));
};
let setInternalTime = new Date().getTime();
setInterval(() => {
console.log(
`------------------------------------ VI-LOG_Test_Start: ${++exCount} ------------------------------------`
);
console.log(
`VI-LOG_setInterval_${exCount} -- Time Overhead: `,
new Date().getTime() - setInternalTime
);
callback(exCount);
setInternalTime = new Date().getTime();
}, 2000);
requestAnimationFrame
的执行行为:- 前台: 回调函数会在浏览器下一次重绘之前执行。这通常与显示器的刷新率同步(例如
60Hz
对应约16.7ms
一帧)。日志中,requestAnimationFrame
在setInterval
回调之后几毫秒内执行,符合预期。 - 后台: 当标签页不是激活状态时,浏览器会暂停
requestAnimationFrame
的回调执行。所有通过requestAnimationFrame
调度的回调会被加入队列,直到标签页恢复激活状态。 - 重新聚焦: 一旦标签页重新激活,所有在后台期间累积的
requestAnimationFrame
回调会在短时间内集中执行完毕,然后再开始处理新的requestAnimationFrame
请求。 - 可行性分析
- 后台 tab 暂停:当
tab
进入后台时,requestAnimationFrame
的回调会完全暂停执行。若react
的核心工作循环完全依赖requestAnimationFrame
,那么在tab
后台时,所有react
更新(包括 状态更新、副作用处理 等)都会停止。这对于某些需要后台保持一定活性的应用(如 消息通知、数据预取后更新状态 等场景)是不可接受的。 - 用途:
requestAnimationFrame
调度完成后会执行浏览器渲染工作,这确实适合react
的commit
阶段的同步任务。但react
并不仅有与渲染相关的任务,还存在大量副作用的任务,对于这些副作用的任务的处理放置在requestAnimationFrame
中并不合理。
- 后台 tab 暂停:当
- 前台: 回调函数会在浏览器下一次重绘之前执行。这通常与显示器的刷新率同步(例如
requestIdleCallback
的执行行为:前台: 回调函数会在浏览器主线程处于空闲时期时被调用。这允许执行一些低优先级的后台任务而不会影响用户体验(如动画流畅性)。
后台:
requestIdleCallback
的行为与requestAnimationFrame
不同。即使在后台,如果浏览器判断有空闲资源,requestIdleCallback
的回调仍然可能被执行。不过,后台requestIdleCallback
的执行频率和可用的timeRemaining()
可能会受到限制。重新聚焦: 类似于
requestAnimationFrame
,如果在后台期间有requestIdleCallback
回调被调度但因某些原因(如没有足够的空闲时间或浏览器策略)未能立即执行,它们也可能在Tab
重新激活后得到执行。可行性分析
后台 tab 暂停:
requestIdleCallback
在后台tab
中仍有机会执行,尽管频率和可靠性可能降低。这比requestAnimationFrame
的完全暂停要好,requestIdleCallback
执行时机是不稳定的,只有在浏览器空闲时才会执行,可以视作低优先级任务,容易被浏览器其他任务占用主线程而导致饥饿问题。这对于需要及时响应的用户交互(如输入框反馈)或高优先级更新是不可接受的。利用空闲时间:
requestIdleCallback
允许在浏览器主线程空闲时执行任务,这其实是符合react
的并发可中断渲染。同时还提供deadline.timeRemaining()
机制,有利于协助react
判断当前帧有多少剩余时间可用于执行工作。兼容性问题:
safari v18.5
版本默认是关闭requestIdleCallback
特性,IE
、Opera Mini
完全不支持。用户采用率仅78.95%
。Hack 行为:
requestIdleCallback
可以通过配置timeout
参数来使低优先任务超时而推送到宿主环境的宏任务队列。与此同时,经测试发现当配置timeout > 0
时(timeout = 0
时,后台任务会堆积后聚焦时一次性执行),后台执行触发的时机也相对稳定。因此若场景中无需考虑
requestIdleCallback
的兼容性和跨平台问题,通过requestIdleCallback(fn, { timeout: 1 })
也是可以在一定程度上完成调度任务。但这是一种
hack
行为,与requestIdleCallback
的设计初衷相悖。requestIdleCallback
的设计初衷是将任务视为低优先级任务调度,当宿主环境空闲时或任务饥饿时进行调度,而requestIdleCallback(fn, { timeout: 1 })
的意义则视为尽快将任务推送到宿主环境的宏任务队列中,等待宿主环境调度。react
希望能够 快速响应并执行其内部最高优先级任务,虽然能通过hack
行为来完成调度,但timeout: 1
推送宏任务的积极性并没有setTimeout(fn, 0)
和new MessageChannel()
高。与此同时hack
方式与requestIdleCallback
的初衷相悖,再接着requestIdleCallback
又存在兼容性和跨平台性问题,因此选择采纳requestIdleCallback
并不是一个很好的选择。
综上分析可知,requestAnimationFrame
和 requestIdleCallback
并不适合作为 react
通用场景下的任务调度器。讨论到此,宿主环境任务 以及 微任务 均不适合。
那么接下来就对宏任务进行分析吧。对于 react
来说是需要一个稳定且高效的调度机制,宏任务本身就适合作为调度机制。每一次事件循环 tick
中,浏览器都会从任务队列中取一个宏任务执行,这个过程通常情况下是稳定且可控的。
那么接下来就对宏任务进行分析吧。
宏任务的可行性分析
测试用例
let startTime = null;
let exCount = 0;
const handler = function (info) {
return function (count) {
console.log(
`VI-LOG_${info}_${count} -- Time Overhead: `,
new Date().getTime() - startTime
);
};
};
const channelOnMessage = handler('MessageChannel');
const setTimeoutPreOnMessage = handler('setTimeout pre');
const setTimeoutPostOnMessage = handler('setTimeout post');
const channel = new MessageChannel();
channel.port1.onmessage = function wrap({ data }) {
const { count } = data;
channelOnMessage(count);
};
const triggerMessageChannel = count => {
channel.port2.postMessage({ count });
};
const callback = exCount => {
startTime = new Date().getTime();
setTimeout(setTimeoutPreOnMessage.bind(null, exCount), 0);
triggerMessageChannel(exCount);
setTimeout(setTimeoutPostOnMessage.bind(null, exCount), 0);
};
let setInternalTime = new Date().getTime();
setInterval(() => {
console.log(
`------------------------------------ VI-LOG_Test_Start: ${++exCount} ------------------------------------`
);
console.log(
`VI-LOG_setInterval_${exCount} -- Time Overhead: `,
new Date().getTime() - setInternalTime
);
callback(exCount);
setInternalTime = new Date().getTime();
}, 2000);
根据图表可分析出,在后台需要持续进行精确、低延迟任务的场景中,setTimeout
和 setInterval
是不可靠的,会受到浏览器节流策略的严重影响。而 MessageChannel
作为一种宏任务,无论标签页是在前台还是后台,MessageChannel
的时间开销始终保持在 0-2ms
之间,表现是三者中最稳定、最高效的。因此 MessageChannel
是实现可靠、高性能任务调度的理想选择。
注意
浏览器在标签页退后台后会对 setTimeout(fn, 0)
进行显著的节流(throttling
),导致其延迟通常增加到约 1s
左右,当处于后台的时间达到 12.4min
左右,会将延迟时间提升到 1min
左右。相比之下,MessageChannel
的消息传递即便在标签页后台状态下,其回调执行相对稳定,虽然在非激活 tab
场景下偶尔会存在回调延迟问题(超过 1s
),但基本稳定在 10ms
以下。标签页重新激活后,setTimeout
的响应会恢复。
那么接下来分析一下 react
是如何选择调度机制的。
react 的选择策略
react
在 scheduler
包中也是采用宏任务作为任务调度,代码如下:
// Capture local references to native APIs, in case a polyfill overrides them.
const localSetTimeout =
typeof setTimeout === 'function' ? setTimeout : null;
const localSetImmediate =
typeof setImmediate !== 'undefined' ? setImmediate : null; // IE and Node.js + jsdom
const performWorkUntilDeadline = () => {
if (typeof localSetImmediate === 'function') {
// Node.js and old IE.
// There's a few reasons for why we prefer setImmediate.
//
// Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
// (Even though this is a DOM fork of the Scheduler, you could get here
// with a mix of Node.js 15+, which has a MessageChannel, and jsdom.)
// https://github.com/facebook/react/issues/20756
//
// But also, it runs earlier which is the semantic we want.
// If other browsers ever implement it, it's better to use it.
// Although both of these would be inferior to native scheduling.
schedulePerformWorkUntilDeadline = () => {
localSetImmediate(performWorkUntilDeadline);
};
} else if (typeof MessageChannel !== 'undefined') {
// 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);
};
} else {
// We should only fallback here in non-browser environments.
schedulePerformWorkUntilDeadline = () => {
// $FlowFixMe[not-a-function] nullable value
localSetTimeout(performWorkUntilDeadline, 0);
};
}
};
从代码注释和代码逻辑可以看出,react
选择的优先级顺序是:setImmediate
> MessageChannel
> setTimeout
。
在 node.js
宿主环境中,首选 setImmediate
的原因在于 更符合语义 且 执行更早。setImmediate
会在当前事件循环的 I/O
事件之后立即执行,比 setTimeout(..., 0)
更早,更符合 立即执行下一个宏任务 的需求。MessageChannel
虽然在 node.js v15+
中可用,但它会阻止进程在没有其他任务时自动退出,而 setImmediate
不会。这对于服务端渲染 (ssr
) 场景至关重要。
在浏览器宿主环境中,MessageChannel
比 setTimeout
拥有更优选择的原因在于规避 setTimeout
的 4ms
延迟。浏览器为了节能和防止嵌套的 setTimeout
造成性能问题,会对 setTimeout(..., 0)
设置一个最小延迟(通常是 4ms
,现代浏览器对其做了优化)。而 MessageChannel
是一个宏任务(Macrotask
),可以实现几乎 零延迟 推送宏任务到宿主的任务队列中,等待宿主环境调度。
在通用的宿主环境中,即使 setTimeout
存在一定延迟,但这是所有环境中最通用的异步 api
。