9 月 20 日,8209de26 把 useMutableSource 的实现整个从 master 上删掉了, 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)」把实现填上,message 坦白说这版基本是从用户态实现复制粘贴的,「not ideal but is a good enough starting place」,并列出后续要做的事:把 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。实现回滚过一次,RFC 定的取舍方向没有变。
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 的坑,退回来改成 flag 后面逐步迁移。目前两个 API 都在,useMutableSource 的去留要看 Recoil 迁移的进度;useSyncExternalStore 这边,06f98c16 message 里列的后续(pre-commit 检查、Fizz、旧 SSR renderer、debug tools)到快照时点已经全部补上。这个系列追到现在,这是第一次完整看到一次 API 替换从提案、实现、删除到回滚加回的全过程,比看设计文档直观。

