React 是怎样调度更新的?Scheduler、时间切片和 Lane 优先级各做什么?
Scheduler 是 React 自带的任务调度包,按优先级给任务算出过期时间,放进小顶堆,在浏览器里用 MessageChannel 安排宏任务执行,每执行约 5 毫秒就检查一次要不要让出主线程,这就是时间切片。Lane 是 reconciler 里用二进制位表示的更新优先级,点击产生 SyncLane,Transition 产生 TransitionLane,多个更新用按位或合并,lanes & -lanes 取出最高优先级。同步和默认优先级的渲染不做时间切片。
简答
用户在搜索框里打字,同时下面一个大列表正在用新数据重新渲染。React 要让打字先响应、列表慢慢渲染,靠的是两层机制:reconciler 里的 Lane 决定“哪个更新更急”,
scheduler包决定“什么时候执行、执行多久让出主线程”。Scheduler 收到任务时,按优先级给它算一个过期时间,比如用户阻塞级是 250 毫秒,普通级是 5000 毫秒,再按过期时间放进一个小顶堆。在浏览器里,它用
MessageChannel发消息来安排一个宏任务,在这个宏任务里连续执行任务,执行超过约 5 毫秒就停下来,把主线程还给浏览器去处理输入和绘制,再发一条消息接着做。这就是时间切片。源码注释说选MessageChannel是因为setTimeout有 4 毫秒的嵌套限制。Lane 是一个 31 位的二进制数,每一位代表一种优先级,位越低越急。点击这类离散事件里的更新是
SyncLane,startTransition里的更新是TransitionLane。根节点上待处理的更新用按位或合并,lanes & -lanes取出最低的那一位,就是下一次要处理的最高优先级。渲染某个 Lane 时,React 再把它映射成 Scheduler 的优先级交给 Scheduler。要注意,不是所有渲染都被切片。源码里同步、连续输入和默认优先级的 Lane 被归为阻塞型,走不检查让出的同步工作循环;只有 Transition 这类非阻塞更新才在每个 fiber 之间检查
shouldYield。另外,这些都是 React 内部实现,常量和函数名会随版本变化,下文以 2026 年 10 月 facebook/react 主分支源码为准。
为什么要自己写调度器
渲染一个大组件树,如果一口气做完,主线程会被占住几十甚至几百毫秒,期间用户的输入没有反应。Fiber 把渲染拆成一个个小单元,做完一个单元就可以停下,原理见《React 的虚拟 DOM 和 diff 是怎样工作的?Fiber 解决了什么问题?》。
可是“什么时候停、停了以后什么时候继续”需要有人管,这就是 scheduler 包的工作。浏览器自带的 requestIdleCallback 只在空闲时触发,什么时候有空闲说不准,React 没有用它,见《requestIdleCallback 适合做什么?为什么不能用它保证任务一定执行?》。
Scheduler 怎样排队
Scheduler 对外的入口是 unstable_scheduleCallback(priorityLevel, callback)。优先级定义在 SchedulerPriorities.js 里,从 1 到 5 依次是 ImmediatePriority、UserBlockingPriority、NormalPriority、LowPriority、IdlePriority。
每个优先级对应一个超时时间,加上当前时间就是任务的过期时间 expirationTime。当前源码的取值如下:
| 优先级 | 超时 | 来源 |
|---|---|---|
| ImmediatePriority | -1,立即过期 | Scheduler.js |
| UserBlockingPriority | 250 毫秒 | userBlockingPriorityTimeout |
| NormalPriority | 5000 毫秒 | normalPriorityTimeout |
| LowPriority | 10000 毫秒 | lowPriorityTimeout |
| IdlePriority | 1073741823,永不过期 | maxSigned31BitInt |
任务对象的 sortIndex 设为 expirationTime,放进 taskQueue。taskQueue 是 SchedulerMinHeap.js 实现的小顶堆,比较的是 sortIndex,相同时比较自增的 id。所以过期时间最早的任务总在堆顶,优先级高的任务虽然后来,算出来的过期时间更早,也会排到前面。
带 delay 参数的任务还没到开始时间,会先放进另一个堆 timerQueue,按开始时间排序,到时间后再挪进 taskQueue。
时间切片是怎样实现的
真正干活的是 performWorkUntilDeadline。它记下开始时间,然后调用 flushWork,里面的 workLoop 不断从堆顶取任务执行。每取一个任务前,如果这个任务还没过期,并且 shouldYieldToHost() 返回 true,就停下来。
shouldYieldToHost 的判断很简单:从这次开始到现在,过去的时间小于 frameInterval 就继续,否则让出。frameInterval 的默认值来自 frameYieldMs,当前是 5 毫秒。
停下以后还有任务,就再安排一次 performWorkUntilDeadline。安排的方式按环境选择:
- 有
setImmediate的环境(Node.js 和旧版 IE)用setImmediate,源码注释说它不会像MessageChannel那样阻止 Node.js 进程退出,执行时机也更早。 - 浏览器和 Worker 里用
MessageChannel:port1.onmessage设为performWorkUntilDeadline,每次通过port2.postMessage(null)触发。注释写的原因是setTimeout有 4 毫秒的限制。 - 都没有时才退回
setTimeout(performWorkUntilDeadline, 0)。
postMessage 触发的是一个宏任务,所以每次让出以后,浏览器能先处理排在前面的输入事件和渲染,再回来接着执行。这里的 4 毫秒限制指的是 HTML 规范对嵌套定时器的规定,详见《怎样用 setTimeout 实现 setInterval?两者有什么区别?》。
还有一点:任务的回调如果返回一个函数,Scheduler 会把它当作同一个任务的后续,留在堆里下次接着执行。React 正是用这种方式把一次没做完的渲染接上。
下面用普通 JavaScript 模拟这个过程。每个单位工作耗时 1 毫秒,切片长度 5 毫秒。“渲染列表”先进队列,需要 8 个单位;“响应输入”后进队列,只需 2 个单位,但优先级更高:
const timeouts = { UserBlocking: 250, Normal: 5000, Low: 10000 };
let now = 0;
let taskId = 0;
const taskQueue = [];
const done = [];
function scheduleCallback(priority, name, units) {
const expirationTime = now + timeouts[priority];
taskQueue.push({ id: taskId++, name, units, expirationTime });
taskQueue.sort((a, b) => a.expirationTime - b.expirationTime || a.id - b.id);
}
function performWorkUntilDeadline() {
const startTime = now;
while (taskQueue.length > 0) {
if (now - startTime >= 5) {
return '让出主线程';
}
const task = taskQueue[0];
now += 1;
task.units -= 1;
if (task.units === 0) {
taskQueue.shift();
done.push(task.name + '@' + now);
}
}
return '队列清空';
}
scheduleCallback('Normal', '渲染列表', 8);
scheduleCallback('UserBlocking', '响应输入', 2);
console.log(performWorkUntilDeadline()); // 让出主线程
console.log(done.join(', ')); // 响应输入@2
console.log(performWorkUntilDeadline()); // 队列清空
console.log(done.join(', ')); // 响应输入@2, 渲染列表@10
“响应输入”虽然后加入,过期时间是 250,比“渲染列表”的 5000 早,排到了堆顶,所以先完成。接着“渲染列表”做了 3 个单位,到第 5 毫秒,切片用完,让出主线程。下一次宏任务再把剩下的 5 个单位做完。
这个模拟用数组排序代替小顶堆,用一个数字代替真实时间,只为说明顺序。真实的 Scheduler 只在两个任务之间检查让出;一次渲染内部的让出是由 React 的工作循环在每个 fiber 之间调用 shouldYield 实现的,下面会讲到。
Lane 用二进制位表示优先级
Scheduler 只认 5 个优先级,React 内部需要更细的区分:同一时间可能有多个不同来源的更新在等待,有的还需要分组、合并。所以 reconciler 用 Lane 表示更新的优先级。
ReactFiberLane.js 里 TotalLanes 是 31,每个 Lane 是一个只有一位为 1 的二进制数,越靠右越优先。几个常见的 Lane:
| Lane | 二进制 | 来源 |
|---|---|---|
| SyncLane | 0b10 | 点击、输入等离散事件里的更新 |
| InputContinuousLane | 0b1000 | 滚动、mousemove 等连续事件里的更新 |
| DefaultLane | 0b100000 | 不在事件中触发的普通更新 |
| TransitionLane1 到 14 | 第 8 位到第 21 位 | startTransition 里的更新 |
| IdleLane | 第 29 位 | 空闲优先级 |
事件和 Lane 的对应关系在 ReactEventPriorities.js 里:DiscreteEventPriority 就是 SyncLane,ContinuousEventPriority 就是 InputContinuousLane,DefaultEventPriority 就是 DefaultLane。ReactDOMEventListener.js 把 click、input 等事件归为离散事件,scroll、mousemove 等归为连续事件。Transition 有 14 条 Lane,新的 Transition 会依次分到下一条,用完再回到第一条。
用位表示的好处是集合运算都是位运算。多个更新合在一起是按位或,mergeLanes(a, b) 就是 a | b;去掉某个 Lane 是 removeLanes(set, subset),写作 set & ~subset;取最高优先级是 getHighestPriorityLane(lanes),写作 lanes & -lanes,利用补码只留下最低位的 1。下面照源码里的数值算一遍:
const SyncLane = 0b0000010;
const InputContinuousLane = 0b0001000;
const DefaultLane = 0b0100000;
function mergeLanes(a, b) {
return a | b;
}
function getHighestPriorityLane(lanes) {
return lanes & -lanes;
}
function removeLanes(set, subset) {
return set & ~subset;
}
let pendingLanes = 0;
pendingLanes = mergeLanes(pendingLanes, DefaultLane);
pendingLanes = mergeLanes(pendingLanes, SyncLane);
pendingLanes = mergeLanes(pendingLanes, InputContinuousLane);
console.log(pendingLanes.toString(2)); // 101010
console.log(getHighestPriorityLane(pendingLanes) === SyncLane ? 'SyncLane' : '其他'); // SyncLane
pendingLanes = removeLanes(pendingLanes, SyncLane);
console.log(getHighestPriorityLane(pendingLanes) === InputContinuousLane ? 'InputContinuousLane' : '其他'); // InputContinuousLane
console.log((pendingLanes & DefaultLane) !== 0 ? '还有 DefaultLane' : '没有'); // 还有 DefaultLane
三个更新合并以后,一个数字就记下了三种待处理的优先级。处理完 SyncLane 把它去掉,剩下的最高优先级就是 InputContinuousLane,DefaultLane 还在等。
Lane 怎样交给 Scheduler
更新发生时,requestUpdateLane 决定它的 Lane:在 Transition 里就分一条 TransitionLane,否则按当前事件优先级取 Lane。Lane 合并进根节点的 pendingLanes 后,ReactFiberRootScheduler.js 取出下一批要处理的 Lane。
如果里面有 SyncLane,不交给 Scheduler,源码注释说同步工作总会在微任务结束时刷新。其他情况先把 Lane 换算成事件优先级,再映射为 Scheduler 优先级:离散和连续事件对应 UserBlockingPriority,默认对应 NormalPriority,空闲对应 IdlePriority,然后调用 scheduleCallback。
哪些渲染会被切片
拿到要渲染的 Lane 后,ReactFiberWorkLoop.js 先算 shouldTimeSlice。includesBlockingLane 把 SyncLane、InputContinuousLane、DefaultLane 等归为阻塞型,包含阻塞型 Lane 或者已过期的 Lane 时,走 workLoopSync,一个 fiber 接一个做完,不检查让出。
否则走并发循环,当前默认是 workLoopConcurrentByScheduler:只要还有 fiber,并且 shouldYield() 返回 false,就继续处理下一个 fiber。shouldYield 就是 Scheduler 的 shouldYieldToHost。所以真正被切片、能被打断的,主要是 Transition、Retry、Idle 这类非阻塞更新。这也解释了为什么要用 useTransition 才能让大列表渲染不卡输入,见《useTransition 和 useDeferredValue 能优化哪些卡顿?》。
低优先级更新也不会无限等下去。markStarvedLanesAsExpired 会给每个待处理的 Lane 记一个过期时间,同步和连续输入类是 250 毫秒,Transition 是 5000 毫秒(Retry Lane 的过期由 enableRetryLaneExpiration 开关控制,当前默认关闭),过期后加入 expiredLanes,下次渲染就按同步方式做完,避免一直被高优先级更新插队而“饿死”。
常见追问
为什么用 MessageChannel,不用 setTimeout 或 requestAnimationFrame?
源码注释给的理由是
setTimeout有 4 毫秒的限制:HTML 规范规定定时器嵌套超过 5 层后,延迟至少 4 毫秒,时间切片每 5 毫秒就要让出一次,这个损耗太大。MessageChannel的消息是宏任务,没有这个限制,又能让浏览器在两次之间处理输入和绘制。在 Node.js 里 Scheduler 优先用setImmediate。
Scheduler 的优先级和 Lane 是什么关系?
两者分属两个包。Lane 在 reconciler 里,用 31 个二进制位区分更新,能表示“一组待处理的更新”。Scheduler 是一个通用的任务调度器,只有 5 个优先级。React 调度一次渲染时,先取最高优先级的 Lane,换算成事件优先级,再映射成 Scheduler 优先级。
SyncLane的渲染不经过 Scheduler,在微任务里同步完成。
时间切片会让渲染变快吗?
不会,总工作量没有减少,还多了让出和恢复的开销。它的作用是不让一次长渲染长时间占住主线程,让输入事件能插进来,高优先级更新可以打断低优先级的渲染先完成。