React 18 追踪:flushSync 统一同步刷新入口

2 分钟阅读
·

上一篇看了 createRoot 转正。同一周,master 上还有一轮围绕同步刷新 API 的反复值得单独写一篇:7 月 1 日合入两个 commit,把 unbatchedUpdates 和 flushDiscreteUpdates 两个内部入口并进 flushSync;7 月 7 日又一个 commit 把这两个改动整体回滚,commit message 只有一行标题,没写原因。一次合入加一次回滚,恰好把「为什么要统一」和「统一有多难」两边都演示了一遍。写这篇的时候代码停在回滚后的状态,所以这篇文章两边都要讲:重构前的三个入口各自是什么语义,7 月 1 日的收敛方案长什么样,以及回滚恢复了什么。参考代码是 master 分支 cb8afda183(7 月 8 日),主要文件还是 packages/react-reconciler/src/ReactFiberWorkLoop.new.js,外加 ReactFiberSyncTaskQueue.new.js

系列目录

日期 标题
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 统一同步刷新入口(本篇)

重构前:三个同步刷新入口

先交代一个公共机制。ReactFiberWorkLoop.new.js 里有个模块级变量 executionContext,是一个位掩码,标记当前调用栈处在什么环境里。回滚后的快照里它有六个常量(NoContext 值为 0,实际标志位五个):

export const NoContext = /*             */ 0b00000;
const BatchedContext = /*               */ 0b00001;
const LegacyUnbatchedContext = /*       */ 0b00010;
const RenderContext = /*                */ 0b00100;
const CommitContext = /*                */ 0b01000;
export const RetryAfterError = /*       */ 0b10000;

所有批处理类 API 的本体都是对这个变量置位、执行回调、在 finally 里复位,差异只在置哪个位、退出时补什么动作。看清这一点,三个入口的区别就很好描述了。

batchedUpdates 置 BatchedContext,退出时如果 executionContext 已经回到 NoContext(说明自己是最外层批次),调 flushSyncCallbacksOnlyInLegacyMode 把 legacy 模式的同步更新刷掉。在这个上下文里,setState 只入队不渲染,这就是事件回调里多次 setState 只渲染一次的原因。同族的还有 deferredUpdates 和 discreteUpdates,分别把回调里的更新优先级压到 DefaultEventPriority、抬到 DiscreteEventPriority,它们管的是「更新进哪条 lane、什么时候一起算」,属于批处理这一侧。

这篇文章的主角是另一侧:刷新出口。批处理 API 决定更新怎么攒,刷新 API 决定攒下来的同步更新什么时候落地。重构前,这一侧有三个函数,语义互相重叠又各有偏差。

unbatchedUpdates 是内部 API,作用是反着来:清掉 BatchedContext,置上 LegacyUnbatchedContext。调用方只有一处,packages/react-dom/src/client/ReactDOMLegacy.js 里 legacy root 的初次挂载和 unmountComponentAtNode。配套的特判散在两个文件里。scheduleUpdateOnFiber 里有一个分支:SyncLane 更新碰上 LegacyUnbatchedContext 且当前不在渲染,直接 performSyncWorkOnRoot 同步跑完;commitRootImpl 里也有一个对应的早退分支,初次挂载的 commit 同步执行,但 layout 更新延后到批次结束。这套设计维持的是 Stack 时代的旧行为:ReactDOM.render 的初次挂载即使包在 batchedUpdates 里也要同步完成。注意它的语义边界:只同步刷新这个新挂载的 root,其他 root 上 pending 的同步工作不动。

flushDiscreteUpdates 也是内部 API,调用方是事件系统。packages/react-dom/src/events/ReactDOMUpdateBatching.js 的 finishEventHandler 在事件处理收尾时检查受控组件有没有 pending 更新,有的话先调它再恢复受控状态,注释里链着 facebook/react#1698 那个受控组件嵌套层级的老 issue。场景是这样的:受控 input 的 onChange 里 setState,React 可能 bailout 不去碰 DOM,但浏览器已经把输入框的值改了,事件结束后必须把 DOM 值恢复成受控值,恢复之前要先把 pending 的更新刷完,否则拿到的是旧状态。它的实现是 flushSyncCallbacks 之后再强制 flushPassiveEffects,注释说这样 effect 能在下一个连续事件之前触发。在 render 期间被调到不报错,最多 dev 环境告警一句就返回,因为它可能由嵌套的事件派发触发,比如生命周期里调 el.focus(),focus 事件在 render 中途同步派发,这时候没法同步刷新,只能放行。函数上方挂着一条 TODO,说等 act 和 batchedUpdates 能区分开之后,它应该变成公开 API。这条 TODO 挂了很久,它一直没转正。

flushSync 是三个里唯一的公开 API,在 18 alpha 的 ReactDOM 入口里以 flushSync 的名字导出,不带 unstable_ 前缀。快照里的实现核心就几行:

export function flushSync(fn, a) {
  const prevExecutionContext = executionContext;
  executionContext |= BatchedContext;
  const previousPriority = getCurrentUpdatePriority();
  try {
    setCurrentUpdatePriority(DiscreteEventPriority);
    if (fn) {
      return fn(a);
    }
  } finally {
    setCurrentUpdatePriority(previousPriority);
    executionContext = prevExecutionContext;
    // Flush the immediate callbacks that were scheduled during this batch.
    // Note that this will happen even if batchedUpdates is higher up
    // the stack.
    if ((executionContext & (RenderContext | CommitContext)) === NoContext) {
      flushSyncCallbacks();
    }
  }
}

置 BatchedContext,setCurrentUpdatePriority(DiscreteEventPriority),退出时只要不在 render 或 commit 阶段就立刻 flushSyncCallbacks,即使外层套着 batchedUpdates 也照样刷,finally 里的注释专门强调了这一点。在 render 或 commit 里调用会收到一条告警,提示把这个调用挪到 scheduler task 或微任务里。

三个函数往同一个队列倒更新,但入口语义各有一套:一个负责撤销批处理,一个顺带刷 passive effects,一个负责当场冲队列。ReactDOMUpdateBatching.js 文件顶部的注释写了收敛方向:等所有更新默认都批处理之后,batchedUpdates 这类 API 会消失,取而代之的是一个反向的出口,用来跳出调度、要求同步执行。flushSync 就是这个出口。

syncQueue:三个入口的共同终点

三个入口殊途同归,终点都是 ReactFiberSyncTaskQueue.new.js 里的 syncQueue。这个文件很小,结构一目了然:一个模块级数组 syncQueue,scheduleSyncCallback 负责把回调推进去,flushSyncCallbacks 负责把队列跑完。跑的时候逐个执行回调,回调返回续体就接着跑,和第 4 篇讲的 Scheduler 任务 continuation 是同一个模式。中途抛错会把剩余回调留在队列里,用 scheduleCallback 以 ImmediatePriority 挂到下一个 tick 继续刷,再向上抛。文件里另有一个 includesLegacySyncCallbacks 标记,legacy root 的同步回调入队时置上,flushSyncCallbacksOnlyInLegacyMode 靠它区分要不要动手。文件里还有一条 TODO 值得记:作者说队列里其实只有一种回调,就是 performSyncWorkOnRoot,所以更合理的结构大概是两个按 root 分的队列,legacy 一个 concurrent 一个,flushSyncCallbacksOnlyInLegacyMode 只刷 legacy 那个。这条 TODO 和这一周的重构是同一个方向的思考:legacy 和 concurrent 的同步工作现在混在一个队列里,靠一个布尔标记勉强区分。

平时谁来触发 flushSyncCallbacks?ensureRootIsScheduled。算出的 newCallbackPriority 是 SyncLane 时,它把 performSyncWorkOnRoot 推进 syncQueue,然后 scheduleMicrotask(flushSyncCallbacks),用微任务在事件循环当前回合收尾时刷掉。也就是说 SyncLane 更新的默认节奏是微任务,flushSync 做的事情只有一件:不等那个微任务,在 finally 里当场把队列跑完。所谓同步刷新,同步的是这个队列。

7 月 1 日:两个 commit 收敛成一个入口

7 月 1 日 Andrew Clark 连续合入两个 commit,前后只差一分钟,把上面三个入口砍到只剩一个。这两个 commit 和上一篇写的 ReactDOM.render 废弃警告在同一条线上:先把 legacy 入口标记废弃,再把为它服务的内部特判逐个拆掉。

32eefcb3「Replace flushDiscreteUpdates with flushSync (#21775)」处理事件系统那一支。做法是一次命名对调:原来的 flushSync(带告警的那个)改名 flushSyncWithWarningIfAlreadyRendering,给公开 API 用;原来的 flushDiscreteUpdates 改名 flushSync,不带告警,给事件系统内部用。commit message 里说得很坦白,这么绕是为了防止两个几乎相同的函数将来悄悄分叉;生产构建里带告警的版本会内联成不带告警的,行为完全一致。message 最后还补了一句,后来又把命名反转了一次,让带告警的那个保住 flushSync 这个短名字,「To make Seb happy」。除了改名,这个 commit 还顺手修了一处行为:新的 flushSync 不再强制 flushPassiveEffects,message 称那是一个过时的启发式,并补了一条测试「does not flush pending passive effects」,断言 passive effects 挂起时调 flushSync 不会把它们带出来。

ed6c091f「Replace unbatchedUpdates with flushSync (#21776)」处理 legacy 挂载那一支。unbatchedUpdates 整个删掉,LegacyUnbatchedContext 这个位也删掉,executionContext 从五个标志位缩回四个。scheduleUpdateOnFiber 里的 LegacyUnbatchedContext 分支、commitRootImpl 里的早退分支一起清掉,ReactDOMLegacy 的初次挂载和 unmount 改调 flushSync。这里有一个真实的行为差异,message 里写明了:unbatchedUpdates 只刷新新挂载的那个 root,flushSync 会刷新所有 root 上 pending 的同步工作。举个例子,batchedUpdates 里先给 root A 一个 setState,再 ReactDOM.render 挂载 root B:旧行为下 A 的更新继续等批次结束,B 同步挂载;新行为下 B 挂载的那一刻,A 的 pending 更新也被一起刷掉。团队的判断是差异足够小,不太可能有应用依赖这个区别,况且 legacy API 在 18 已经进了废弃流程。

重构后的形态确实干净:同步刷新只剩 flushSync 一个入口,公开和内部的区别收拢成「带不带告警」一个参数化的变体,executionContext 少一个位,scheduleUpdateOnFiber 和 commitRootImpl 各少一个特判分支。对调用方的影响也直接:事件系统和 legacy 挂载这两个内部场景不再各自维护私有入口,公开 API 和内部实现共享同一份代码,分叉的风险从源头上消除了。

7 月 7 日:9ccc25a0ea 整体回滚

六天之后,Brian Vaughn 提交 9ccc25a0ea「Reverting recent flushSync changes (#21816)」,把上面两个 commit 全部还原。flushDiscreteUpdates 和 unbatchedUpdates 回来了,LegacyUnbatchedContext 回来了,scheduleUpdateOnFiber 和 commitRootImpl 的两个特判分支回来了,32eefcb3 加的那条 passive effects 测试删掉了,ReactIncrementalScheduling-test 里被 ed6c091f 删掉的 59 行测试原样恢复。从 diff 的规模能看清回滚的彻底程度:ReactFiberWorkLoop 的 new 和 old 两个 fork 各改回 144 行,react-noop-renderer 重新导出 unbatchedUpdates 和 flushDiscreteUpdates,ReactDOMUpdateBatching.js 里被改名成 flushSyncImpl 的内部变量改回 flushDiscreteUpdatesImpl,setBatchingImplementation 的第三个参数重新接 flushDiscreteUpdates。两个 fork 的改动逐行一致,这也印证了第 1 篇说的,这类跨 fork 的改动必须两边同时动,回滚也一样。

回滚没有附带任何解释,message 只有标题一行。能看到的线索只有测试 diff 里的一处细节:ReactDOMFiber-test 重新接受了一种情况,用户代码没有调过 flushDiscreteUpdates,控制台却冒出「unstable_flushDiscreteUpdates: Cannot flush updates when React is already rendering」的告警,旁边挂着 TODO 说这个警告本来就不该触发。这恰好是 32eefcb3 想修掉的问题之一,事件系统内部的刷新不该以用户 API 的名义告警。重构的方向有正当性,但合入六天就被回滚,说明这两个 commit 里的某个行为变化在实际场景里踩到了东西,具体是什么,目前从仓库里看不出来。

到 7 月 8 日写稿时,代码就是回滚后的样子:三个入口并存,各带各的历史包袱。这场反复我照实记录,收敛方案本身大概率还会换个形式回来,继续盯。

flushSync 重构前后的同步刷新入口对比

flushSync 和 SyncLane 的关系

最后把 flushSync 接回前面几篇讲的 Lane 模型。flushSync 自己不渲染任何组件,它做两件事:进入回调前 setCurrentUpdatePriority(DiscreteEventPriority),退出后立刻 flushSyncCallbacks。

第一件事件决定 lane。packages/react-reconciler/src/ReactEventPriorities.new.js 里,EventPriority 是 Lane 的 opaque type,DiscreteEventPriority 就是 SyncLane 本身。第 2 篇讲过 requestUpdateLane 的分支顺序,其中一条是「更新来自 flushSync 这类 API 时,优先级已经由 setCurrentUpdatePriority 放进上下文变量,直接取」。flushSync 回调里的 setState 走的就是这条分支,拿到 SyncLane,位图上第 0 位,优先级最高。

第二件事决定时机。SyncLane 更新经 ensureRootIsScheduled 进 syncQueue,默认等微任务刷;flushSync 的 finally 里直接 flushSyncCallbacks,把队列当场跑完,渲染和提交都在当前调用栈里完成。所以 flushSync 的语义可以拆成两半:回调内的更新按 SyncLane 记优先级,回调结束时同步队列立即清空。在 legacy 模式下所有更新本来就拿 SyncLane,flushSync 带来的唯一差别是刷新时机;在 concurrent root 里,它还多了一个把更新钉到最高优先级车道的作用。

拿一个具体场景过一遍。concurrent root 里有个 transition 渲染正在时间切片里跑,此时调用 flushSync(() => setState(…)):这次更新拿 SyncLane,ensureRootIsScheduled 算出下一批是 SyncLane,把它推进 syncQueue 并挂上微任务;flushSync 的 finally 不等微任务,直接 flushSyncCallbacks 执行 performSyncWorkOnRoot。第 3 篇讲过 getNextLanes 的比较逻辑,SyncLane 的值小于任何 TransitionLane,进行中的 transition 渲染被判定为优先级更低,sync 渲染把它打断,跑完提交之后 transition 再从头调度。从 API 使用者的视角,这就是「立即看到这次更新的结果」;从调度器的视角,这是一次同步车道对 transition 车道的抢占。

回头看这一轮反复,方向本身是清楚的:三个语义重叠的入口收敛成一个公开 API,剩下的差异用「是否告警」表达。卡住的是兼容。unbatchedUpdates 背后是 Stack 时代初次挂载的同步行为,flushDiscreteUpdates 背后是受控组件和嵌套事件派发,每一处特判都对应着某个真实场景。React 18 废弃 legacy 入口就是在给这类清理铺路,只是从这一周的结果看,铺路还没铺完。批处理这一侧的变化也在推进,createRoot 带来的自动批处理和这套刷新机制直接相关,下一篇就看它。


906 字 · 32 段落
xi ming

Written by xi mingFollow onGitHub