React 18 追踪:自动批处理与交错更新队列

5 分钟阅读
·

先看一个可以直接跑的例子。组件里有个按钮,点击后开一个 setTimeout,回调里连续调两次 setState:

function App() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);
  console.log('render', a, b);

  return (
    <button
      onClick={() => {
        setTimeout(() => {
          setA((v) => v + 1);
          setB((v) => v + 1);
        });
      }}>
      go
    </button>
  );
}

在 React 17 下点一次,console 打两行 render,两次 setState 各自触发一轮完整的渲染和提交。换 18 alpha 再跑,结果取决于挂载入口:createRoot 挂载时只打一行,两次更新合成一轮渲染;ReactDOM.render 挂载时还是两行,和 17 完全一致。Working Group 公布 Alpha 时 gaearon 那篇 Automatic batching for fewer renders in React 18 讨论帖里演示的就是这个对比,官方给这个现象起的名字叫 automatic batching。第 5 篇提过一句「批处理的范围变了」,这篇把它拆开看:差异在源码的哪几行成立,以及配套的交错更新队列解决什么问题。参考代码是 master 分支 d0ec283819,7 月 15 日的快照。

系列目录

日期 标题
06-08 从 Stack Reconciler 到 Fiber:追踪 React 18 开发,先看数据结构
06-10 React 18 追踪:Lane 模型(上),31 个二进制位取代 expirationTime
06-17 React 18 追踪:Lane 模型(下):调度决策与饥饿保护
06-24 React 18 追踪:Concurrent 工作循环与时间切片
07-01 React 18 追踪:createRoot 转正,ReactDOM.render 进入废弃警告
07-08 React 18 追踪:flushSync 统一同步刷新入口
07-15 React 18 追踪:自动批处理与交错更新队列(本篇)

分野在 scheduleUpdateOnFiber 的尾部

两种入口的行为差异落在同一个函数里。scheduleUpdateOnFiberpackages/react-reconciler/src/ReactFiberWorkLoop.new.js)在调完 ensureRootIsScheduled 之后有一段立即 flush 的分支:

if (
  lane === SyncLane &&
  executionContext === NoContext &&
  (fiber.mode & ConcurrentMode) === NoMode &&
  // Treat `act` as if it's inside `batchedUpdates`, even in legacy mode.
  !(__DEV__ && ReactCurrentActQueue.isBatchingLegacy)
) {
  resetRenderTimer();
  flushSyncCallbacksOnlyInLegacyMode();
}

四个条件合起来读:SyncLane、当前不在任何批处理上下文中、fiber 不在并发模式、不在 act 作用域。LegacyRoot 挂载的树 mode 是 NoMode,第三个条件成立,所以 legacy 下事件外的 setState 走到这里就当场把同步队列 flush 掉。setTimeout 回调里的第一次 setState 触发一轮渲染提交,第二次再触发一轮,17 打两行 render 的原因就在这里。v17.0.2 的 .old fork 里对应分支条件更少,SyncLane 加 NoContext 两条就够,效果相同。

ConcurrentRoot 的树整棵带 ConcurrentMode,第三个条件永远不成立,这个函数对并发树没有任何「当场渲染」的路径。渲染被整个推给 ensureRootIsScheduled:SyncLane 的更新走 scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root)) 进内部同步队列,再由 scheduleMicrotask(flushSyncCallbacks) 安排一个微任务统一 flush;其他 lane 走 Scheduler 的 scheduleCallback 按优先级排任务。setTimeout 回调里的更新不在任何事件上下文中,getCurrentEventPriority()ReactDOMHostConfig.js,读 window.event)返回 DefaultEventPriority,更新拿 DefaultLane,挂一个 Normal 优先级的 Scheduler 任务。同一轮里的第二次 setState 再进 ensureRootIsScheduled 时,走的是 existingCallbackPriority === newCallbackPriority 的复用分支,任务不重复挂。等宏任务跑完、Scheduler 回调执行时,两次更新都已经在队列里,一轮渲染一起处理。这就是开篇例子里 18 alpha 只打一行 render 的全部机制。

同步队列本身在 packages/react-reconciler/src/ReactFiberSyncTaskQueue.new.js,结构很简单:模块级数组 syncQueueperformSyncWorkOnRoot 的绑定回调,includesLegacySyncCallbacks 布尔位记录队列里有没有 legacy root 的回调。flushSyncCallbacksOnlyInLegacyMode 只在这个布尔位为真时才真正 flush,concurrent root 的同步回调只能等微任务里的 flushSyncCallbacks。两个 root 的回调混在同一个数组里,靠这个布尔位区分谁能提前跑,文件里的 TODO 注释自己也说后续应该把队列按 root 拆成两个。微任务一侧的 scheduleMicrotask 由宿主环境提供(ReactDOMHostConfig.js),优先用原生的 queueMicrotask,没有就退到 Promise.resolve().then,再不行退到 setTimeout,当时 supportsMicrotasks 恒为 true,不会走到 scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks) 那个兜底分支。

点击这类离散事件在 concurrent root 下走的是同一条微任务路径,只是入口多一层优先级设置。ReactDOMEventListener.js 里离散事件的 dispatch 走 dispatchDiscreteEvent,它用 setCurrentUpdatePriority(DiscreteEventPriority) 包住整个 dispatch,于是处理器里的 setState 经 requestUpdateLane 拿到 SyncLane,进 scheduleSyncCallback 排队,等当前宏任务结束后的微任务统一渲染。也就是说 concurrent root 下连「最高优先级的点击更新」也不在事件回调里同步渲染,这是和 17 的又一个可观察差异:17 里点击事件的渲染同步发生在 dispatch 结束前,18 alpha 的 createRoot 下推迟到事件后的微任务。

事件内的批处理是另一套老机制,当时还在。ReactDOMUpdateBatching.jsbatchedUpdates 包在事件 dispatch 的最外层(DOMPluginEventSystem.jsdispatchEventForPluginEventSystemsetBatchingImplementationReactDOM.js 里完成装配),它往 executionContext 上叠 BatchedContext,让事件处理器里所有 setState 都不满足上面那个 NoContext 条件,flush 推迟到最外层批处理退出。这套机制对 legacy root 仍是唯一的合批手段。这个文件还留着一个在 concurrent root 下也必须保留的动作:finishEventHandler 在每次事件处理结束后检查受控组件有没有 pending 更新,有的话调 flushSyncImpl(装配时注入的是 flushSyncWithoutWarningIfAlreadyRendering)把 DOM 恢复成受控值。这里要交代一句函数名的变化:上一篇结尾的回滚把 flushSyncImpl 改回了 flushDiscreteUpdatesImpl,而本篇快照里又是 flushSyncImpl,说明 flushSync 重构在 7 月 8 日到 15 日之间重新合入了,本篇按快照里的新名字讲。受控输入框的值必须由 React 同步校正,不能等微任务,否则用户已经敲进去的字符会被浏览器留在 DOM 里一瞬,注释里链着的 issue #1698 就是这个问题的原始记录。文件头有一段注释值得原文引一下:

Eventually, this API will go away when everything is batched by default. We’ll then have a similar API to opt-out of scheduled work and instead do synchronous work.

「everything is batched by default」说的就是 concurrent root 已经实现的状态,「opt-out 的反向 API」就是上一篇讲的 flushSync。注释写下的时候默认批处理还只在并发 fork 里,batchedUpdates 还得继续给 legacy 兜底。

这个差异是几个月拼出来的,当月又补了一块

「concurrent 全场景批处理、legacy 仅事件内批处理」在 7 月中这个时间点已经成立,但它是前几个月几个 commit 陆续拼出来的,核实一遍时间线。

第一块是同步更新的微任务化。4 月 21 日 a155860018「Fix: Don’t flush discrete at end of batchedUpdates (#21229)」之前,最外层 batchedUpdates 退出时会把 pending 的同步更新全部 flush,concurrent root 里离散事件的更新也跟着被提前冲掉。这个 commit 把结尾 flush 收窄成只对 legacy root 生效,message 里写得很直白:「Discrete sync updates can wait to flush in the microtask.」改完之后 concurrent root 的离散更新同样等微任务,一次点击里的多个 setState 在微任务里合成一轮。这也是为什么当时代码里 batchedUpdates 结尾和 scheduleUpdateOnFiber 尾部调的都是 flushSyncCallbacksOnlyInLegacyMode 而不是全量 flush。

第二块是交错更新队列,1 月 22 日 f15f8f64bb 引入,下一节展开。

第三块是当月的新事。7 月 12 日 a4ecd85e86「act: Batch updates, even in legacy roots (#21797)」给 act 作用域补了批处理:legacy root 下事件外的更新如果发生在 act 里,也当作在 batchedUpdates 里处理,上面代码块里那个 ReactCurrentActQueue.isBatchingLegacy 条件就是这个 commit 加的。它的 commit message 里有一句话可以直接当结论引用:「This is only necessary in legacy roots because in concurrent roots, updates are batched by default.」React 团队自己在 message 里把「concurrent 默认批处理」当成了既成事实来陈述。

所以开篇例子在 18 alpha 下的准确结论是:createRoot 下任何上下文的更新都批处理,ReactDOM.render 下只有事件处理器内(以及 7 月 12 日起的 act 作用域内)批处理,其余场景维持 17 的逐条同步 flush。

渲染进行中来了更新:交错更新队列

批处理解决的是「同一轮里的多个更新合成一次渲染」。并发模式还有另一个方向的更新时序问题:一次渲染还在进行中,新的更新到了。时间切片让出主线程后用户点了按钮,或者一次渲染跑了几毫秒期间有数据回流,都是这种场景。这些更新叫 interleaved update,交错更新。

拿第 3 篇的场景走一遍具体流程。一个 transition 渲染(TransitionLane,时间切片执行)进行到一半,主线程让出,用户在这时候点了按钮,处理器里的 setState 拿到 SyncLane。markUpdateLaneFromFiberToRoot 把 SyncLane 一路合并到 root.pendingLanes,isInterleavedUpdate 判定为真,更新挂进组件队列的 interleaved 链表,当前渲染里的组件读不到它。scheduleUpdateOnFiber 把 SyncLane 记进 workInProgressRootUpdatedLanes 之后调 ensureRootIsScheduledgetNextLanes 选出 SyncLane,和正在渲染的 TransitionLane 比较,SyncLane 优先级更高,当前任务被取消,改排同步队列加微任务。微任务里 renderRootSync 发现 lanes 变了,重建渲染栈,enqueueInterleavedUpdates 把点击更新并入 pending,新一轮渲染从 SyncLane 开始算。transition 那边的时间切片进度被丢掉,等 SyncLane 提交后重新调度。整条链里点击更新自始至终不会被那次 transition 渲染读到,一致性就是靠这个隔离保证的。

直接的解法是把更新照旧塞进组件队列的 pending 链表,但这有个一致性问题。一次渲染可能同时涉及多个组件的多个更新,如果渲染中途到来的更新当场进 pending,正在进行的这轮渲染可能读到其中一部分:组件 A 处理队列时新更新还没到,读的是旧 state;渲染继续走到组件 B 时新更新已入队,B 读到了新 state。同一次提交里不同组件基于不同版本的外部状态渲染,这种撕裂叫 tearing。f15f8f64bb 的 commit message 把约束写明了:「we cannot render some of those updates without rendering all of them」,要避免撕裂,这批更新只能整批处理或整批不处理。

这个 commit 之前的旧做法是给交错更新分配一条当前渲染 lanes 之外的 lane,让这轮渲染自然跳过它。message 里列了旧做法的代价:同一优先级的 lane 会用尽,用尽后只能打断当前渲染从头再来,反复发生会导致饥饿;而且工作循环和 Lane 模块里很多地方要单独为这种情况留分支,维护复杂,「has led to a number of tearing bugs」。新做法在数据结构上加了一层:组件的更新队列多一个 interleaved 字段,和 pending 一样是环形链表;渲染进行中到达的更新挂进 interleaved,不碰 pending。这就是 packages/react-reconciler/src/ReactFiberInterleavedUpdates.new.js 的全部内容,文件不到一百行。

交错更新的入队与转移

入队一侧的判定函数是 isInterleavedUpdateReactFiberWorkLoop.new.js):workInProgressRoot 非空、fiber 带 ConcurrentMode、且不是 render 阶段更新。第三个条件里挂着 deferRenderPhaseUpdateToNextBatch,这个 flag 6 月 2 日被 1d3558965f 默认关掉了,所以 render 阶段更新照旧并入当前渲染的 lanes,不按交错更新处理,和第 2 篇交代的一致。判定通过后,类组件走 enqueueUpdateReactUpdateQueue.new.js)的 interleaved 分支,hooks 走 dispatchActionReactFiberHooks.new.js)的同款分支,两边结构一样:interleaved 为 null 时先建一个自指的环,同时调 pushInterleavedQueue 把整个 queue 对象 push 进模块级数组 interleavedQueues,之后再来的更新直接插入环尾。

渲染一侧只记账不消费。processUpdateQueue(类组件)和 updateReducer(hooks)在处理完 pending 之后都有一段遍历 interleaved 链表的代码,只做一件事:把每个交错更新的 lane 合并进「本轮没处理完」的 lanes 集合(类组件里累加进 newLanes 交给 markSkippedUpdateLanes,hooks 里直接 markSkippedUpdateLanes(interleavedLane))。链表里的更新内容这轮一个字都不碰。这一步保证了本轮渲染提交时这些 lane 不会被清掉,继续留在 root 的 pendingLanes 里,下一轮调度一定还会选中它们。

转移到下一轮发生在 enqueueInterleavedUpdates,全仓库只有一个调用点,prepareFreshStack 里:

const lastInterleavedUpdate = queue.interleaved;
if (lastInterleavedUpdate !== null) {
  queue.interleaved = null;
  const firstInterleavedUpdate = lastInterleavedUpdate.next;
  const lastPendingUpdate = queue.pending;
  if (lastPendingUpdate !== null) {
    const firstPendingUpdate = lastPendingUpdate.next;
    lastPendingUpdate.next = (firstInterleavedUpdate: any);
    lastInterleavedUpdate.next = (firstPendingUpdate: any);
  }
  queue.pending = (lastInterleavedUpdate: any);
}

两个环各断一处、交叉接上,interleaved 链表整体拼到 pending 尾部,顺序保持先来后到。prepareFreshStack 的调用时机是新一轮渲染栈建立时:renderRootSyncrenderRootConcurrent 开头检测 workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes,不满足就丢掉旧栈重建。交错更新到达时,markRootUpdated 已经把它的 lane 挂进了 root.pendingLanes,scheduleUpdateOnFiber 同时把它记进 workInProgressRootUpdatedLanes。之后调度怎么走向分两种情况。getNextLanes 比较新旧 lanes 的最高优先级,新 lane 优先级更高(或者当前渲染已被挂起)时,返回新的 lanes,渲染栈重建,enqueueInterleavedUpdates 在重建时把攒下的交错更新并入 pending;新 lane 优先级同级或更低时,getNextLanes 返回当前的 wipLanes,渲染接着跑,交错更新等这轮提交后的下一轮渲染再转移处理。还有一种更激进的情况:当前渲染已经走到 RootSuspendedWithDelay,反正这轮渲染已经不会提交了,scheduleUpdateOnFiber 会直接 markRootSuspended 把当前 lanes 挂起,让调度立刻转向新更新,不再等这轮渲染收尾。

这套结构的收益对照旧做法列一下:交错更新不占 lane 名额,16 条 transition 车道不会被穿插更新消耗;判定和转移的全部逻辑收在一个文件里,工作循环只需要 workInProgressRootUpdatedLanes 一个掩码记账;撕裂从机制上消除,因为渲染中途任何组件都不可能读到这批更新。

边界

几条需要说清的限制。

批处理合并的是渲染次数,更新本身一个都不少。两次 setState 的 updater 按入队顺序在同一次渲染里依次执行,最终 state 和分开渲染时相同,只是中间态不再上屏。

默认更新当时仍不做时间切片。ReactFiber.new.jscreateHostRootFiberenableSyncDefaultUpdates 还是 true,ConcurrentUpdatesByDefaultMode 不会叠加,performConcurrentWorkOnRoot 里的 shouldTimeSlice 因此把 DefaultLane 这组排除在时间切片之外,setTimeout 里那轮合批渲染是一次性同步算完的。自动批处理先落地,全面并发调度还没放开,allowConcurrentByDefault 也还是 false。这两层的关系要分清:批处理改变的是渲染次数,时间切片改变的是渲染过程可不可中断,当时的代码里前者对默认更新已生效,后者没有。

legacy root 下行为没有变,事件外的多次 setState 想要合批仍然可以手动包 unstable_batchedUpdates。concurrent root 下想退出批处理、让某次更新立即渲染提交,用上一篇讲的 flushSync。

下一篇

渲染进行中收到更新的情况里,有一类还没展开:渲染被 Suspense 挂起之后到来的 ping 和重试。挂起的 lane 怎么从 suspendedLanes 转回 pingedLanes,重试更新怎么进队,下一篇顺着 ReactFiberThrow.new.js 看挂起与恢复的完整链路。


916 字 · 37 段落
xi ming

Written by xi ming You should follow him on Github