useSyncExternalStore 让组件能够读取存在于 React 之外的数据,而不会冒着并发渲染可能引入视觉闪烁(visual glitches)的风险。它取代了以往通过 useEffect 将外部值拉取到本地状态的旧模式,而那种模式可能会导致“撕裂”(tearing)——即 UI 的不同部分在同一次渲染期间显示不同的值。
为什么旧模式在并发环境下会失效
在 React 18 之前,通用的做法是:
const [value, setValue] = useState(initial);
useEffect(() => {
const unsubscribe = externalSource.subscribe(v => setValue(v));
return unsubscribe;
}, []);
Effect 在组件渲染后运行。当 React 的并发渲染器决定暂停、中止或重新执行渲染时,effect 的触发时机可能与读取状态的 UI 不同步。结果就是不匹配:一个组件可能读取了旧值,另一个读取了新值,屏幕会短暂显示不一致的数据。这种“撕裂”在纯同步世界中是无害的,但在 React 并行渲染多个 UI 版本时,它会变成一个可见的 Bug。
how useSyncExternalStore 如何解决问题
该 Hook 需要两个回调函数:
- subscribe – 注册监听器并返回一个用于移除监听器的函数。
- getSnapshot – 返回外部数据源的当前值。
React 在组件挂载时调用 subscribe,并在卸载时销毁它。在每次渲染期间,它都会调用 getSnapshot,并保证所有使用相同外部 store 的组件都读取到完全相同的快照。因为读取发生在渲染阶段,渲染器可以协调整个树的更新,从而消除撕裂。
像 Zustand 和 Redux 这样的库已经使用此 Hook 封装了它们的 store,这就是为什么它们能开箱即用地支持 React 18 的并发特性。
防止 Hook 行为异常的规则
- 仅返回组件所需的内容 – 返回一个大型对象会迫使 React 在每次更新时比较深层结构。窄快照(narrow snapshot)可以保持变更检测的高效。
- 保持引用一致性 – 如果外部值没有改变,getSnapshot 必须返回相同的对象引用。如果每次调用都返回一个新对象,会触发无限渲染循环,因为 React 在每次渲染时都会检测到变化。
- 仅限于外部数据源 – 本地组件状态应属于
useState或useReducer。将外部 store Hook 用于纯内部值会增加不必要的间接性。
何时使用 useSyncExternalStore
- 封装浏览器 API(窗口大小、网络在线状态、媒体查询)。
- 连接不了解 React 的纯 JavaScript store。
- 用全局只读数据源取代属性钻取(prop-drilling),让多个组件进行订阅。
反方观点
对于简单且不频繁变化的数据,useState + useEffect 模式仍然有效,尤其是在应用程序未启用并发特性时。这是一种较低层的方法,一些开发者认为它更容易理解,因为订阅逻辑显式地存在于 effect 中。然而,代价是开启并发渲染后面临撕裂的风险。对于已经使用 React 18 新特性的项目,useSyncExternalStore 带来的额外安全性超过了其增加的少量样板代码。
后续注意事项
- 验证 store 的 getSnapshot 返回的是稳定引用;在开发过程中通过
console.log快速检查引用,可以发现意外的对象重建。 - 保持订阅函数轻量化 —— 重型工作应该在外部数据源内部完成,而不是在传递给 React 的回调函数中。
- 在 React 的并发模式下测试组件(例如,使用
act配合ReactDOM.createRoot),以确认不会出现撕裂。
一个实时演示展示了该 Hook 的实际应用:同步存储在 React 之外的一个简单计数器,并在不产生视觉闪烁的情况下更新多个组件。演示地址为 https://usesyncexternalstore.vercel.app/。源代码托管在 GitHub 上,更详细的文章解释了实现细节。
总结: 在 React 可以乱序渲染 UI 部分内容的时代,useSyncExternalStore 是外部数据与组件渲染之间可靠的桥梁。它保持了视觉一致性,可扩展至全局 store,并且已经内置在流行的状态管理库中。只要需要读取非 React 状态,就请采用它,并保持快照小巧且稳定,以充分发挥其优势。
