在 React 18 引入并发特性后,useSyncExternalStore 作为连接 React 与外部状态管理库(如 Redux、Zustand 或 Jotai)的标准 API 被广泛使用。然而,一些开发者在使用 startTransition 触发低优先级更新时,遇到了一个棘手的问题:当外部 store 在 transition 过程中发生突变(mutation),同一组件树的不同部分会渲染出不一致的状态,即所谓的“撕裂”(tearing)。本文将深入剖析这一现象背后的根本原因,并给出最佳实践建议。
问题重现:一场意外的“穿越”
假设有一个全局计数器 store,组件 A 和组件 B 分别通过 useSyncExternalStore 订阅同一个快照:
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
当用户触发一个标记为 startTransition 的事件时,组件 A 执行了耗时更新(如计算斐波那契数列),而在此期间,store 突然被另一个非 transition 的点击事件更新了值。此时可能出现:
- 组件 A 依然显示旧值(因为 transition 是低优先级,React 允许它在“暂停”后恢复,且恢复时使用了旧快照)。
- 组件 B 已经显示新值(因为非 transition 更新立即生效,迫使 B 重新渲染)。
这种在同一渲染帧内看到的状态不一致,就是典型的“撕裂”现象。
根因剖析:并发模式下的“时间分片”裂缝
要理解为什么 startTransition 容易引发撕裂,必须回到 React 并发渲染的底层机制:
-
优先级调度:
startTransition内的更新被标记为低优先级(Transition)。React 调度器允许高优先级更新(如点击输入框)中断正在进行的低优先级渲染工作。当中断发生时,React 会放弃当前已构建的 Fiber 树,重新开始渲染高优先级任务。 -
外部 store 的“快照”机制:
useSyncExternalStore的关键在于同步读取。React 会多次调用getSnapshot来判断状态是否变化,并且要求getSnapshot必须在每一次执行时返回同一份引用(除非 store 真的更新)。但在并发模式下,由于渲染可能被中断且恢复,getSnapshot的调用时机变得不可预测。 -
突变发生的时间窗口:典型的撕裂场景如下: - 低优先级 transition 开始渲染,
getSnapshot读取了旧值(比如 1 毫秒时的状态)。 - 渲染过程中高优先级事件导致 store 突变,getSnapshot下次返回新值(比如 2 毫秒时的状态)。 - React 恢复低优先级渲染,但此时 store 提供的快照不再稳定——同一个 fiber 节点可能在两次getSnapshot调用中获得不同值,导致 React 误判状态未变,从而跳过更新或产生混合快照。
为什么普通更新(非 transition)很少遇到?
在非并发场景(Legacy Mode)或高优先级同步更新中,React 的渲染是不可中断的。一旦开始渲染,所有 getSnapshot 调用都在同一帧内连续执行,store 在渲染期间几乎不可能被突变(因为 JavaScript 单线程,除非异步)。而 startTransition 故意让出执行权给其他更紧急的任务,为外部突变创造了侵入机会。
官方回应与解决之道
React 团队在 RFC 和文档中明确指出:外部 store 在 transition 期间发生突变时,useSyncExternalStore 无法保证一致性。这并不是实现缺陷,而是设计上的权衡——为了保持并发更新的高性能,React 选择了信任外部 store 的稳定性。
最佳实践:
-
使用不可变快照:确保
getSnapshot返回一个不会被后续突变影响的对象。例如在 Redux 中,每次 dispatch 后返回新的 state 引用(这通常已经满足),但要注意如果 store 内部缓存了可变数据(如 Map 或 Set),需要对其进行深拷贝或使用 immutable 库。 -
弱化过渡期的一致性要求:如果用户界面在 transition 期间短暂显示旧状态是可以接受的,那么实际撕裂影响不大。可以结合
useDeferredValue来展示旧值,或者用useLayoutEffect在过渡完成后强制同步。 -
避免在 transition 内直接修改共享状态:将状态变更拆分为高优先级(驱动 UI 即时反馈)和低优先级(数据同步)。例如,输入框的本地值用
useState管理,而服务器查询结果才放入外部 store 并触发 transition。 -
使用并发安全的状态库:像 Zustand 或 Jotai 已针对
useSyncExternalStore做了特别处理,它们会在每次getSnapshot时返回稳定的引用,并利用内部状态版本号检测是否被中途修改。若遇到不可控的外部突变,这些库可选择重新订阅或惰性求值。
结语
useSyncExternalStore 在并发模式下出现的“撕裂”问题,本质上是外部可变状态与React 异步调度之间的冲突。理解这一冲突的来历——不是 bug,而是并发优先哲学的副产品——能帮助我们更理性地选择状态管理策略。对于大多数 React 应用,遵循官方建议、保持对突变的时间窗口警惕,并在过渡中适度容忍短暂的不一致,才是平衡性能与正确性的务实之道。