React 源码探秘(六):调度器与时间切片,React 为什么不卡

先从「卡」说起

浏览器要保证界面流畅,每帧大约只有 16.6ms(60Hz),这期间要完成 JavaScript、样式计算、布局和绘制。任何一段 JS 如果一口气跑 50ms、100ms,用户的滚动、点击、输入就都会被晾在一边——这就是「卡顿」。

React 早期的同步渲染正是这种「长任务」:一次性把整棵 Fiber 树算完,中途绝不撒手,树一大主线程就被占死。时间切片(time slicing)的思路很朴素:既然一口气干不完,那就把活切成小片,每片最多干 5ms,干完就把主线程还给浏览器去渲染、去响应输入,剩下的活下次接着干。

Scheduler:把「什么时候干活」独立出来

React 从 17 起把调度逻辑抽成了独立的 scheduler 包(scheduler/src/Scheduler.js),它不依赖 React 自身,核心就三件事:

  • scheduleCallback(priorityLevel, callback):登记一个任务,排进队列
  • shouldYieldToHost():问「时间片用完没,该让位了吗」
  • cancelCallback(task):取消还没执行的任务

渲染这头(react-reconciler/src/ReactFiberWorkLoop.js)只负责「把渲染工作包成一个回调交给 Scheduler」,至于何时跑、按什么顺序跑、每次跑多久,全由 Scheduler 决定。scheduleCallback 内部会先算一个过期时间(expirationTime = now + timeout),然后把任务按过期时间塞进堆里:

JAVASCRIPT
// Scheduler.js(React 17/18 简化) function scheduleCallback(priorityLevel, callback, options) { const currentTime = getCurrentTime(); const timeout = timeoutByPriorityLevel[priorityLevel]; const newTask = { id: taskIdCounter++, callback, priorityLevel, startTime: currentTime + ((options && options.delay) || 0), expirationTime: currentTime + timeout, // 越早过期越靠前 }; if (newTask.startTime > currentTime) { push(timerQueue, newTask); // 还没到点,先搁 timer 队列 } else { push(taskQueue, newTask); // 立即可执行 requestHostCallback(flushWork); // 唤醒调度循环 } return newTask; }

taskQueue 是个最小堆(SchedulerMinHeap.js),过期时间越近的任务越靠前,所以「最急的活」总是先被取走。

时间切片为什么靠 MessageChannel + postMessage

「跑 5ms 就让位」要成立,得有一个可靠的唤醒机制:干完一小片后告诉浏览器「我还没干完,你有空再叫我」。Scheduler 选的是宏任务:

JAVASCRIPT
// SchedulerHostConfig(React 17/18) const channel = new MessageChannel(); channel.port1.onmessage = performWorkUntilDeadline; // 每次要唤醒调度循环,就 postMessage 一次 channel.port2.postMessage(null);

为什么是它而不是别的方案:

  • setTimeout:嵌套层级一深会被浏览器钳制到最少 4ms,还会被节流、降频,当「时间片定时器」不够快也不够准;
  • requestAnimationFrame:只在绘制新帧前回调,后台标签页直接停摆,而且它本来就该优先服务渲染;
  • requestIdleCallback:兼容性差、触发频率低,也没有任务优先级的概念。

postMessage 的回调在宏任务里执行,一帧内可以触发多次、不受钳制,是「把控制权切回浏览器事件循环」的最顺手工具(没有 MessageChannel 的旧环境才退回 setTimeout)。

shouldYield 与 5ms 帧预算

Scheduler 维护一条「死线」:每次被唤醒开工前先记下 deadline = now + yieldInterval,yieldInterval 默认 5ms——这就是给一帧预留的 JS 时间片预算,剩下的约 10ms 留给浏览器去渲染。真正干活的 workLoop 每执行完一个任务就检查一次:

JAVASCRIPT
function workLoop(hasTimeRemaining, initialTime) { let currentTime = initialTime; advanceTimers(currentTime); // 到点的 timer 任务挪进 taskQueue currentTask = peek(taskQueue); while (currentTask !== null) { if (currentTask.expirationTime > currentTime && shouldYieldToHost()) { break; // 没过期 && 时间片用完 → 让位 } const callback = currentTask.callback; if (typeof callback === 'function') { const didUserCallbackTimeout = currentTask.expirationTime <= currentTime; const continuationCallback = callback(didUserCallbackTimeout); if (typeof continuationCallback === 'function') { currentTask.callback = continuationCallback; // 没干完,回来续上 } } pop(taskQueue); currentTask = peek(taskQueue); } return currentTask !== null; // true → 还有活,下一个宏任务接着干 }

shouldYieldToHost 的实现就是拿当前时间跟死线比:getCurrentTime() >= deadline 就让位。注意那个关键条件——任务一旦过期(expirationTime <= currentTime),即使超时也要一口气干完,不再让位。低优先级任务被高优先级反复插队后,到点就会「强制补课」,这正是防止饿死(starvation)的兜底机制。

任务优先级:Immediate 到 Idle

Scheduler 的优先级本质是「能容忍延迟多久」,各档对应不同的 timeout:

优先级 timeout 含义
ImmediatePriority -1 立即执行(如同步更新)
UserBlockingPriority 250ms 点击、输入等用户交互
NormalPriority 5s 普通更新(并发渲染默认)
LowPriority 10s 不太急的更新
IdlePriority 约 2^31-1 ms 永远不急(预取等)

timeout 越小,过期越早,在最小堆里排得越靠前。同一个调度循环里,Scheduler 会先让 UserBlocking 的活干完,才轮到 Normal 的活。

Lane 模型:用位运算管理「一堆并发更新」

从 React 17 开始,fiber 内部的更新优先级换成了 lane 模型(ReactFiberLane.js),React 18 把它作为并发的基石。核心思路是把最多 31 条「车道」压进一个 31 位 bitmask:每个更新占用一条或几条 lane,lane 的数值越小优先级越高(SyncLane = 1 最高,transition 的 lane 靠后):

JAVASCRIPT
// ReactFiberWorkLoop.js export function getHighestPriorityLane(lanes) { return lanes & -lanes; // 取出最低位的 1,即最高优先级的那条 }

这套位运算的表达力很强:

  • 批量:一次更新可以同时占多条 lane,天然支持合并,不再需要手动「批量标记」;
  • 插队:正在渲染低优先级 lane 时来了 SyncLane,直接中断旧的、先处理新的;
  • 续算:被打断后剩下的 lane 原样存在 root.pendingLanes 里,下次调度接着算;
  • 防饿:过期与饥饿检测也是纯位运算,React 18 里专门记录「过期 lane」,触发上文说的强制补课。

一句话总结:expirationTime 时代用「时间点」比先后,lane 时代用「位掩码」表达更新集合——并发更新可以叠加、可以插队、可以精确描述「谁还没干完」。

workLoop 的可中断与恢复:为什么不会改坏界面

把两头接起来看 React 的渲染主流程(ReactFiberWorkLoop.js):

  1. 状态变了 → scheduleUpdateOnFiberensureRootIsScheduled:挑出最高优先级的 lane,把渲染工作包成 performConcurrentWorkOnRoot 交给 Scheduler;
  2. Scheduler 到点唤醒,进入 renderRootConcurrent,真正的循环长这样:
JAVASCRIPT
function workLoopConcurrent() { while (workInProgress !== null && !shouldYield()) { workInProgress = performUnitOfWork(workInProgress); } }

每算完一个 fiber 单元就问一次 shouldYield(),超时就把控制权还回去——渲染被中断了,但中断发生在「构建 Fiber 树」的阶段,安全性由三条保证:

  • 界面不会花:React 同时维护 current 与 workInProgress 两棵树(双缓冲),commit 之前 DOM 一动不动,用户永远看不到「改了一半」的页面;
  • 进度不会丢:workInProgress 指针记着干到哪了,已算完的 fiber 上存着 memoizedProps / memoizedState;下次唤醒从根重新往下走时,beginWork 发现 props/state 没变、也没有相关 lane 要处理,就直接 bailout——整棵没变化的子树 O(1) 跳过,而不是推倒重算;
  • commit 不可中断:render 一旦全部完成进入 commit 阶段,就同步一口气把 DOM 改完,保证一次提交的一致性。

所以「可中断」只发生在 render 阶段,「不可中断」的是 commit 阶段——这个边界,就是 React 并发安全的地基。

小结

  • 卡顿源于长任务霸占主线程;时间切片把渲染切成 5ms 的小片,让每一帧都有机会响应浏览器;
  • Scheduler(Scheduler.js)用 MessageChannel + postMessage 的宏任务实现「干一会、让一会」,用五档优先级 + 过期时间排序决定谁先干;
  • Lane 模型让「一堆并发更新」能用位运算优雅管理:批量、插队、续算都是 O(1);
  • 双缓冲 + 可中断的 workLoop(ReactFiberWorkLoop.js)保证界面永远一致、进度永不丢失。

有了这套地基,React 18 才敢默认开启并发渲染,也才有了 useTransition、useDeferredValue 这些「主动让出优先级」的 API——下一篇,我们来看并发模式本身。

【END】

本文链接:

版权声明:本博客所有文章除声明转载外,均采用 BY-NC-SA 3.0 许可协议。转载请注明来自 知己知事知理

阅读 0 | 发布于 2026-09-05
暂无评论