React 18 引入了 useSyncExternalStore,这是一个允许组件从 React 树之外的任何数据源(如浏览器 API、WebSockets 或第三方 store)读取数据的 Hook,且不会出现以往通过 useStateuseEffect 镜像数据时常见的视觉闪烁问题。如果你曾因为 UI 的一部分显示旧的窗口大小,而另一部分已经反映了新大小时看到过界面闪烁,那么这个 Hook 正是你一直在寻找的解决方案。

为什么外部状态需要一个新的 Hook

React 组件一直能够订阅超出其自身作用域的事物:window 对象、Redux store 或 WebSocket 连接。传统的方法是在 useEffect 中设置订阅,通过 setState 将传入的值推送到本地状态,并让 React 重新渲染。当 React 进行同步渲染时,这种方法运行良好;但 React 18 引入了并发渲染(concurrent rendering),在这种模式下,组件在浏览器实际绘制之前可能会被渲染多次。在这种模式下,“镜像到本地状态”的模式可能会导致 tearing(撕裂)——即在单个渲染周期内,UI 的不同部分读取了同一个外部值的不同快照。

useSyncExternalStore 的设计初衷就是为了防止 tearing。它向外部数据源请求一个 snapshot(当前值)和一个 subscribe 函数(如何接收变更通知)。React 会在组件挂载时调用 subscribe,并在卸载时自动取消订阅。每当 getSnapshot 返回的 snapshot 发生变化时,React 会调度一次渲染,确保整个组件树读取的是 同一个 snapshot,从而保证一致性。

两个必需的函数

函数 功能描述
subscribe 接收一个回调函数,每当外部值发生变化时必须调用该回调。它会返回一个 unsubscribe 函数,React 会在清理时调用它。
getSnapshot 从外部数据源返回当前值。它必须是 引用稳定的(referentially stable)——即在每次渲染时都返回相同的函数对象——以便 React 能够正确比较 snapshot。

浏览器 online 状态的一个最小化实现如下:

function useOnlineStatus() {
  return useSyncExternalStore(
    (onChange) => {
      window.addEventListener('online', onChange);
      window.addEventListener('offline', onChange);
      return () => {
        window.removeEventListener('online', onChange);
        window.removeEventListener('offline', onChange);
      };
    },
    () => navigator.onLine
  );
}

当任何组件调用 useOnlineStatus() 时,只有在 navigator.onLine 真正发生变化时,React 才会重新渲染该组件,并且每次渲染看到的都是相同的值。

防止 Hook 出错的规则

  1. 仅返回组件所需的最少量数据。
    如果外部 store 持有一个大型对象,但组件只关心其中一个字段,请仅返回该字段。返回过大的数据会导致不必要的重新渲染。

  2. getSnapshot 进行记忆化(Memoise)。
    如果你在每次渲染时都重新创建该函数,React 会将其视为一个新数据源,从而可能导致无限循环。请使用 useCallback 对其进行包装,或者将其定义在组件外部。

  3. 避免在每次调用时返回新的对象。
    返回一个新对象(例如 { count: store.getCount() })会在每次渲染时创建一个新的引用,使 React 认为 snapshot 已更改,从而触发无限渲染循环。请使用原始类型(primitives)、字符串、数字或经过记忆化的对象。

  4. 不要将其用于组件内部状态。
    对于 存在于组件内部的数据,useState 仍然是正确的工具。useSyncExternalStore 会带来本地状态不需要的额外开销。

何时使用它

  • 浏览器 API – 窗口大小、媒体查询、navigator.onLine、电池状态。
  • 框架无关的 store – Zustand、Redux、MobX 或任何暴露了订阅 API 的自定义 store。
  • 在 React 之外更新的可变数据源 – WebSocket 消息、IndexedDB 变更事件、Service Worker 通知。

如果你的数据源已经存在于 React 内部(例如父组件的状态),请继续使用 useState 或 context。

社区评价

早期使用者反馈,在并发渲染设置下调整窗口大小时,useSyncExternalStore 消除了他们之前看到的闪烁现象。一些库已经将其内部 Hook 切换到了此 API,承诺在不同 React 版本中提供更可预测的行为。代价是心智负担略有增加:开发者必须考虑这两个函数的稳定性,并避免在每次调用时返回新对象。

下一步关注

React 未来的版本可能会收紧围绕 useSyncExternalStore 的规范,并可能为常见的浏览器 API 添加内置辅助函数。请密切关注 React 官方博客,留意旧的模仿 useEffect 模式是否会被弃用。与此同时,该 Hook 已趋于稳定并属于公共 API 的一部分,因此你可以放心地重构现有的外部状态代码。

快速体验

一个展示了窗口大小同步、在线/离线检测以及简单 Zustand store 的在线演示位于 https://usesyncexternalstore.vercel.app/。包含完整注释的源代码可在 https://github.com/dev48v/usesyncexternalstore 获取。

核心要点: useSyncExternalStore 为 React 提供了一个可靠的桥梁,用于连接任何存在于其渲染周期之外的可变数据,从而防止撕裂 (tearing) 和不必要的重新渲染。将其应用于外部数据源,保持快照 (snapshot) 尽可能精简且稳定,并将一致性维护的繁重工作交给 React 来处理。