9 月 20 日,8209de26 从 master 删除 useMutableSource 实现,reconciler、hooks、root 的注册逻辑和测试共删除 3900 余行,commit message 为「This API was replaced by useSyncExternalStore」。第二天,82c8fa90 将其原样恢复,并在 message 中说明原因。在这次删除与恢复前后,useSyncExternalStore 的替换已推进三周:8 月 28 日创建包,9 月 1 日实现 shim,9 月 7 日合入 Fiber 实现。本文按 commit 顺序说明这一过程,以及 tearing 问题和放弃 useMutableSource 的原因。参考代码为 master 分支 1c58cfab95。
系列目录
| 日期 | 标题 |
|---|---|
| 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 追踪:自动批处理与交错更新队列 |
| 07-22 | React 18 追踪:Suspense 的挂起与恢复 |
| 08-05 | React 18 追踪:useTransition 与 useDeferredValue |
| 08-12 | React 18 追踪:useOpaqueIdentifier,SSR 一致的 id 怎么生成 |
| 08-19 | React 18 追踪:事件系统与 Context 传播 |
| 09-02 | React 18 追踪:Fizz,流式 SSR 引擎 |
| 09-09 | React 18 追踪:选择性注水,用户交互如何插队 hydration |
| 09-16 | React 18 追踪:useInsertionEffect 与 Server Components 雏形 |
| 09-23 | React 18 追踪:useSyncExternalStore 替换 useMutableSource 的三周(本篇) |
tearing 如何发生在外部 store 中
先讲清楚要解决的问题。React 自己的 state 不存在一致性问题:更新进队列,渲染开始时统一处理,一次渲染读到的 state 从开始到提交都是同一份,中途被打断、恢复,读的还是渲染开始时定下的 base state。
外部 store(Redux、Recoil、自己写的事件源)是另一回事。它是 React 管不到的可变对象,组件只能在 render 函数里现场读。同步渲染下这没有问题,读取到提交之间没有空隙。并发模式下渲染可以被打断:一个 transition 渲染的长列表,前半部分组件读完了 store 里的 count,用户点了按钮,渲染让出主线程,store 的 count 加一,等渲染恢复,后半部分组件读到的是加一之后的值。同一次提交里,两截列表基于 count 的两个不同版本渲染,屏幕上是拼起来的自相矛盾的界面。这就是 tearing。渲染可中断的时间窗口越长,踩中的概率越高,而并发模式的设计目标恰恰是让渲染长时间可中断,时间切片扩大了这种情况发生的窗口,因此外部 store 的接入方案需要在机制层面保证一致性。
useMutableSource 的机制与限制
useMutableSource 2020 年就进了 .new fork,是当时给出的答案。它的机制在 packages/react-reconciler/src/ReactFiberHooks.new.js 和 ReactMutableSource.new.js 里,快照时点这套代码还在,可以读。核心做法:
- store 必须用 createMutableSource 包一层,并且自带
_getVersion,每次修改版本号要变。渲染时记下版本,读取前发现版本对不上,说明渲染期间 store 变了。 - 读取的安全性用 lane 来判断。readFromUnsubscribedMutableSource 里检查 renderLanes 是否完整包含 root.mutableReadLanes(有待同步的 store 变更所在的 lane),包含才允许读,读完用 setWorkInProgressVersion 把版本记到源上,同一次渲染里后面的组件再读同一个源,发现版本变了就知道发生了 tearing。
- 检测到不安全读取的处理方式是抛错。源码里的注释写得很坦白:「Intentionally throw an error to force React to retry synchronously」,故意抛一个内部错误逼 React 放弃这次并发渲染,改成同步重试,同步重试会挡住插进来的变更,就能读到一致的值。注释也承认这个错误理论上不该被用户看到,但配套的兜底分支(多渲染器共享同一个源的场景)写着「can lead to tearing in the first renderer when it resumes, but there’s nothing we can do about that」,防 tearing 在这里是有缺口的。
- 订阅回调里除了 setSnapshot,还要 markRootMutableRead 记下「这条 lane 读过外部源」,再 markRootEntangled 把这些 lane 纠缠到一起,保证它们同一批渲染,读到同一版本。
- hydration 场景更麻烦,要在 createRoot 和 hydrateRoot 的选项里把用到的 mutable source 逐个注册(ReactDOMRoot.js 里的 mutableSources 选项),让 root 提前知道要跟踪哪些源的版本。
8 月 28 日 Andrew Clark 在 React 18 Working Group 发了替换 RFC(reactwg/react-18 discussions/86),里面列了放弃它的两个理由,对照代码都能对上。第一,selector 问题:getSnapshot 函数变化就要退订重订阅,库里用内联 selector 的话几乎每次渲染都重订阅,用户被迫把所有 selector 记忆化。第二,降级方向反了:tearing 发生时强制同步重试,即使更新包在 startTransition 里,也可能把已经可见的 UI 换回 Suspense fallback。RFC 原话的大意是这个取舍是倒的,用 fallback 换掉可见内容是体验上的明显回退。换句话说,useMutableSource 为了防 tearing 引入的版本号、纠缠、抛错重试这一整套,代价落到了不该落的地方。
三周内的提交顺序
8 月 28 日,46a0f050「Set up use-sync-external-store package (#22202)」。只有包骨架:package.json、README、入口文件和 src 里的占位实现、rollup 配置,全部新增加起来 81 行。message 写明这个包将来是内置 useSyncExternalStore API 的 shim,而内置 API 还没实现。RFC 是同一天发的,先公布方案再动工。
9 月 1 日,1314299c7f「Initial shim of useSyncExternalStore (#22211)」。用户态实现落地,message 里把设计说得很清楚:shim 模仿内置 API 的行为,向后兼容到任何支持 Hooks 的 React 版本;等内置 API 存在,包永远优先用内置的;库作者只管依赖 shim,用户拿到哪个实现由库保证是对的。message 还交代了测试策略:同一套用例通过 variant test flag 同时跑 shim 和内置实现两份,只有并发 root 适用的用例单独放一套。这句安排后来被认真执行了,9 月 7 日 Fiber 实现合入时改的就是这份共享测试。shim 源码开头的注释还补了一条限制:shim 不支持并发渲染,只有内置 API 支持。
9 月 7 日有两个 commit。77912d9a05「Wire up the native API for useSyncExternalStore (#22237)」把 useSyncExternalStore 挂到 reconciler 内部 dispatcher 的各张表(mount、update、rerender、无效上下文各一份),并从 react 包导出:stable 入口同时提供无前缀名和 unstable_useSyncExternalStore,experimental 入口只有带前缀的名称。ReactInternalTypes.js 的 HookType 和 Dispatcher 类型也登记了新 hook;Fizz、旧 SSR renderer、debug tools 的 dispatcher 都加入对应位置。此时 mount 和 update 函数体只有 throw new Error('Not yet implemented'),尚未实现功能。几个小时后,06f98c16「Implement useSyncExternalStore in Fiber (#22239)」补上实现。commit message 说明该版本主要复用用户态实现,并列出后续工作:将 tearing 检查从 layout 阶段提前到 pre-commit 阶段,补齐 Fizz、旧 SSR renderer 和 debug tools。同一天的 031abd24b6 加入开发期警告:getSnapshot 的返回值必须缓存,连续两次调用结果不同会导致死循环。
之后一周多在补边角。9 月 13 日 33226fadaa「Check for store mutations before commit (#22290)」把 tearing 检查从 layout 阶段挪到 commit 之前的 pre-commit 检查,这正是 06f98c16 message 里列的头号后续,后面要讲的 isRenderConsistentWithExternalStores 和 includesBlockingLane 都是这个 commit 引入的;同一天 fd5e01c2e0 给 extra 入口加了「selection 没变就复用旧选中值」的优化。9 月 20 日 86b3e2461d 实现了服务端渲染的支持,引入第三个参数 getServerSnapshot,Fizz 和旧 SSR renderer 一起补上,79b8fc6670 给 shim 补上 getServerSnapshot。
9 月 20 日,8209de26 删除 useMutableSource。ReactMutableSource.new/old.js 两个文件、hooks 里约 400 行、ReactDOMRoot 的 mutableSources 注册逻辑、2500 行测试,一次清空。message 一句话带过,看起来替换已经完成。
9 月 21 日,82c8fa90「Add back useMutableSource temporarily (#22396)」全部加了回来。message 解释了原因:Recoil 在一个 flag 后面用 useMutableSource,作者原以为 Recoil 没有在任何并发 root 里使用,行为不受影响,结果发现有几处确实在并发 root 里跑。直接删除会改变这些场景在并发 root 里的行为。message 最后说迁移到 useSyncExternalStore 预计不难,但为了降低风险会放在一个 flag 后面逐步推开,过渡期内先把旧 API 加回来。同一天 4da03c9fbd 给 shim 补了 React Native 版本。
到 9 月 23 日的快照,两个 API 并存,都在 react 包的导出列表里。这次恢复说明并发 root 中仍使用旧 API 的代码不能立即迁移。RFC 在三周内保持同一原则:store 触发的更新一律同步以保证一致性;React 自身 state 的 transition 继续使用时间切片,也不会因外部 store 变更而降级显示 fallback。实现曾恢复旧 API,但取舍没有改变。
Fiber 如何防止 tearing
看 06f98c16 落地、又经 33226fadaa 把一致性检查挪到 commit 前之后,快照时点仍在的实现,ReactFiberHooks.new.js 里的 mountSyncExternalStore 和 updateSyncExternalStore。思路和 useMutableSource 完全不同,没有任何版本号。
渲染时直接调 getSnapshot 读当前值。代码注释承认这违反 React 的常规规则,成立的前提是 store 更新永远是同步的,这句前提就是整个设计的支点,后面会看到它怎么被保证。hydration 是例外分支:getIsHydrating 为真时改读第三个参数 getServerSnapshot,没传就直接 invariant 报错退回客户端渲染;hydration 期间也不挂后面说的一致性检查,注释解释理由是服务端内容已经可见,读到陈旧值就用 passive effect 修补,犯不上整棵树重渲染。开发期还有两个兜底检查值得记住:mount 时把 getSnapshot 连调两次,两次结果不同就 console.error 警告「should be cached to avoid an infinite loop」,hydration 分支对 getServerSnapshot 也做同样的事,这就是 9 月 7 日 031abd24b6 加的那条警告的实现方式;订阅回调和 passive 阶段共用的 checkIfSnapshotChanged 里,getSnapshot 一旦抛错一律按「值变了」处理,宁可多渲染一次,也不拿可疑的旧值往下走。
防 tearing 靠提交前的一致性检查。渲染时,如果当前这批 lane 不是 blocking lane,就调 pushStoreConsistencyCheck:给 fiber 打上 StoreConsistency flag(ReactFiberFlags.js),把 {getSnapshot, 渲染时读到的值} 存进 updateQueue.stores。这里的 includesBlockingLane 值得看仔细,它覆盖的是 InputContinuous 和 Default 两组 lane(它们在 legacy 模式下不可中断),SyncLane 不在其中,所以 store 触发的同步重渲染照样会挂检查。渲染完成、commitRoot 之前,ReactFiberWorkLoop.new.js 里的 isRenderConsistentWithExternalStores 遍历整棵 finishedWork 树,用 subtreeFlags 快速跳过没有检查的子树,找到带 flag 的组件就重新调一次 getSnapshot,和渲染时读到的值对比。任何一个对不上,说明渲染期间 store 被并发事件改过,这次渲染结果整体作废,重新渲染。blocking lane 的渲染跳过检查,因为不可中断的渲染期间没有并发事件能插进来改 store。
订阅在 passive effect 里做(subscribeToStore),store 通知变化时先 checkIfSnapshotChanged 再 forceStoreRerender,而 forceStoreRerender 固定用 SyncLane 调度。这是关键的一笔:store 触发的更新永远是同步更新。同步更新不可中断,渲染从头到尾读到同一个 store 状态,tearing 在机制上被排除,不需要版本号,也不需要纠缠 lane。代价是 store 更新享受不到时间切片,这正是 RFC 说的取舍方向:一致性问题和 fallback 回退都消灭掉,成本是让这类更新走同步路径。对比第 3 篇讲的 lane 纠缠,useMutableSource 的 markRootEntangled 是把多个 lane 绑到一批渲染来凑一致性,新方案把这个问题从调度层拿掉了。
更新路径上还有两个省性能的细节。updateSyncExternalStore 里,getSnapshot 的结果和上次渲染的 memoizedState 用 ObjectIs 对比,没变就不调 markWorkInProgressReceivedUpdate,也不推 effect、不挂检查,这个组件可以像普通组件一样 bailout,store 没变就一分钱开销都不花。另一个是 updateStoreInstance:inst 上的 value 和 getSnapshot 在 passive 阶段才更新,更新前再检查一次快照,因为 render 到 passive 之间可能有事件或者别的组件的 layout effect 改了 store,漏掉这次检查就会拿旧值 bailout。这些边角正是「render 里直接读外部可变值」要付的利息。
shim 的工程细节
use-sync-external-store 包本身的写法也值得看。入口按 canUseDOM 分 client 和 server 两个文件,client 实现(useSyncExternalStoreClient.js)开头那段注释是难得的坦白:「This shim breaks many of the rules of React, and only works because of a very particular set of implementation details and assumptions」,后面跟着警告,别学这里的写法,这个 shim 存在的目的就是让其他库不用再写这种 hack。
实现上它用 useState 的 dispatch 当 forceUpdate 用(每次传一个新对象保证不相等),layout effect 里更新 inst 上的 value 和 getSnapshot 并做一次 tearing 检查,useEffect 里订阅。handleStoreChange 里还留了一条 TODO:没有跨渲染器的批处理 API,订阅回调里的多次 setState 合不合批取决于使用方有没有自己包 unstable_batchedUpdates。整套能跑通的前提还是那个:16 和 17 没有并发渲染,更新永远同步。注释里还点了另一个边界:18 的早期 alpha 有 startTransition 但没有内置 useSyncExternalStore,shim 在这种版本上工作不正常,开发期会打印警告让用户升级。还有一个容易看漏的细节:client shim 完全不读第三个参数 getServerSnapshot,注释解释 18 之前的版本在 render 里没办法判断自己是不是在 hydration,只能由使用方自己跟踪 hydration 状态,让 getSnapshot 直接返回正确的值;getServerSnapshot 只在 server 入口(useSyncExternalStoreServer.js)和 18 的内置实现里才真正被用到。
包里还有一个 extra 入口,导出 useSyncExternalStoreExtra,在 getSnapshot 外面包一层 selector 和可选的 isEqual,对应 RFC 里说的 selector 场景:selector 变化不再导致重订阅,只需要重新计算选中值,选中值没变就不触发重渲染。这正是 useMutableSource 时代内联 selector 每次渲染都重订阅那个问题的正面回应。
三周内完成了建包、shim、Fiber 实现和服务端支持。删除旧 API 时发现 Recoil 仍在并发 root 中使用它,因此改为在 flag 后逐步迁移。快照时两个 API 均保留,useMutableSource 的移除取决于 Recoil 的迁移进度;useSyncExternalStore 方面,06f98c16 message 列出的 pre-commit 检查、Fizz、旧 SSR renderer 和 debug tools 已全部补齐。该过程记录了 API 从提案、实现、删除到恢复旧实现的演进。
