useSyncExternalStore 如何解决并发渲染下的“状态撕裂”问题

142 阅读5分钟

本文系统梳理什么是“撕裂”(tearing)、为什么在并发渲染更容易出现、一个会产生撕裂的错误用法示例、如何用 useSyncExternalStore 正确对接外部可变状态源,以及在实际工程中的取舍与最佳实践。

一、什么是“撕裂”(Tearing)

  • 定义:在一次渲染提交中,多个依赖同一外部可变数据源的组件读到了不同版本的快照,导致同一屏 UI 展示互相矛盾的状态。

  • 直观现象:

    • 同一页面里两个组件,一个显示旧值,一个显示新值。
    • 界面看到的是 A,事件回调/副作用里用到的却是 B。
  • 前提:组件在渲染时直接读取“React 之外的可变数据源”(例如全局单例对象、第三方 store、浏览器环境状态等),而不是 React 的 state/props/context。

二、为什么并发渲染更容易发生撕裂

React 18 的并发渲染允许渲染过程被打断、暂停、恢复或丢弃。当渲染尚未提交时,外部数据源可能发生变化,如果组件在渲染阶段直接读外部源:

  • 组件 A 在较早的时间片读取“旧快照”;
  • 渲染尚未提交,外部源更新为“新快照”;
  • 组件 B 在稍后的时间片读取“新快照”;
  • 一次提交落地后,A 与 B 同屏呈现不同版本,出现撕裂。

时间线示意:

  • t0:外部 store 的 count=0,React 开始一次渲染。
  • t1:组件 FastPane 渲染,读取到 count=0。
  • t2:未提交时,外部 store 更新为 count=1。
  • t3:组件 SlowPane 渲染,读取到 count=1。
  • t4:React 提交这次渲染,FastPane 显示 0、SlowPane 显示 1。

三、一个会产生撕裂的“错误用法”示例

下面的自建全局 store 与天真的订阅 hook,在并发渲染下存在撕裂风险(示例中的忙等仅用于模拟慢组件,不要在生产中使用)。

// 简易外部 store(树外,可变)
type State = { count: number }
let state: State = { count: 0 }
const listeners = new Set<() => void>()
const store = {
  getSnapshot: () => state,
  set(partial: Partial<State>) {
    state = { ...state, ...partial }
    listeners.forEach(l => l())
  },
  subscribe(l: () => void) {
    listeners.add(l)
    return () => listeners.delete(l)
  }
}

// 天真 hook:并发场景不安全
function useStoreNaive() {
  const [snap, setSnap] = React.useState(store.getSnapshot())
  React.useEffect(() => store.subscribe(() => setSnap(store.getSnapshot())), [])
  return snap
}

function FastPane() {
  const { count } = useStoreNaive()
  return <div>Fast: {count}</div>
}

function SlowPane() {
  const { count } = useStoreNaive()
  // 模拟耗时渲染(仅演示)
  const start = performance.now()
  while (performance.now() - start < 100) {}
  return <div>Slow: {count}</div>
}

function App() {
  React.useEffect(() => {
    const id = setInterval(
      () => store.set({ count: store.getSnapshot().count + 1 }),
      200
    )
    return () => clearInterval(id)
  }, [])
  return (
    <>
      <FastPane />
      <SlowPane />
    </>
  )
}

在并发渲染与中途外部更新的组合下,FastPane 和 SlowPane 可能读到不同快照并一起提交,发生撕裂。

四、useSyncExternalStore 如何解决撕裂

核心思想:让 React 成为“快照的协调者”。你提供订阅与读取快照的方法,React 在渲染时统一读取快照;若渲染中途快照变化,React 会在提交前中止并重试,确保一次提交内所有消费者看到的是同一版本。

  • 第一个参数 subscribe:告诉 React 如何订阅外部源的变更。
  • 第二个参数 getSnapshot:告诉 React 当前快照是什么,必须是同步、可比较的值。
  • 第三个参数 getServerSnapshot:SSR 备用快照获取(CSR 可与第二个相同)。

正确写法:

function useStoreSafe() {
  return React.useSyncExternalStore(
    store.subscribe,     // 订阅外部源
    store.getSnapshot,   // 客户端快照
    store.getSnapshot    // SSR 快照(示例沿用)
  )
}

function FastPane() {
  const { count } = useStoreSafe()
  return <div>Fast: {count}</div>
}

function SlowPane() {
  const { count } = useStoreSafe()
  const start = performance.now()
  while (performance.now() - start < 100) {}
  return <div>Slow: {count}</div>
}

这样即使渲染过程中外部 store 更新,React 也会检测到快照变化并重新渲染,避免“同一轮提交中的不一致”。

五、工程实践与注意事项

  • 优先级选择

    • 如果状态可以放进 React(state/props/context),优先这么做,React 自带一致性保证,天然无撕裂。
    • 必须读“外部可变源”时,使用 useSyncExternalStore,或选用已适配它的状态库(如 React-Redux v8+、Zustand、Jotai)。
  • 选择器与细粒度订阅

    • useSyncExternalStore 不自带 selector 参数。若你只关心状态的一部分,可封装一个 useStoreSelector:

      • 在 getSnapshot 中返回“被选择的那一片段”,确保该片段在未变化时引用稳定(React 按 Object.is 比较)。
      • 避免每次 getSnapshot 都创建新对象,否则每次都会触发重渲染。
    • 简例(要点是 getSnapshot 返回可比较的片段,必要时做结构性缓存):

      function useStoreSelector<T>(selector: (s: State) => T) {
        const getSnap = React.useCallback(() => selector(store.getSnapshot()), [selector])
        return React.useSyncExternalStore(store.subscribe, getSnap, getSnap)
      }
      // 用法:只订阅 count
      const count = useStoreSelector(s => s.count)
      
  • 订阅契约

    • subscribe 必须在“变更完成并可观察到”后通知监听器,避免在渲染中途异步穿插导致时序问题。
    • getSnapshot 应该是纯函数、同步返回当前快照,不做副作用。
  • SSR/Hydration

    • 提供稳定的 getServerSnapshot,确保服务端与客户端的初始快照一致,避免水合不匹配。
  • 性能

    • 外部 store 内部可做浅比较/结构共享,确保未变化的切片保持引用不变,减少不必要的重渲染。

六、哪些场景需要(或不需要)useSyncExternalStore

  • 需要:

    • 自建或第三方的“树外”可变 store(全局单例状态、实时数据源)。
    • 依赖浏览器环境状态并希望在并发/过渡/Suspense/SSR 中保持一致(如媒体查询、网络状态、可见性等)。
  • 不需要:

    • 完全使用 React 的 state/props/context 传递数据。
    • 来自已适配 React 18 的成熟状态库(React-Redux v8、Zustand、Jotai 等)——它们内部已用 uSES。

七、与 Context/Hooks 的关系与取舍

  • Context 适合“树内作用域化”的配置(主题、语言、尺寸等),配合“拆分多个 Context + useMemo”即可避免大多数无关更新。

  • useSyncExternalStore 解决的是“树外可变源 + 并发渲染”的一致性问题,两者面向的问题域不同。

  • 在一个项目中,常见组合是:

    • UI 配置/主题用 Context;
    • 业务状态用外部 store(Redux/Zustand)+ uSES;
    • 组件内部局部状态用 useState/useReducer。

八、一句话总结

“撕裂”是指同一次提交中,不同组件读取到了同一外部状态的不同版本。React 18 引入 useSyncExternalStore,用“统一订阅 + 快照协调”的方式确保并发渲染下的快照一致性,从根本上避免这一类不一致。工程上优先把状态放进 React;当必须读取树外可变源时,使用 useSyncExternalStore 或选用已适配它的状态库,就是正确的做法。