上一篇介绍了 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 和 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 带来的自动批处理及其与同步刷新机制的关系。
