xDocxDoc
AI
前端
后端
iOS
Android
Flutter
AI
前端
后端
iOS
Android
Flutter
  • 深入理解 useSyncExternalStore:React 并发渲染下的外部状态同步

深入理解 useSyncExternalStore:React 开发的外部状态同步

在 React 的并发特性背景下,useSyncExternalStore 作为一个关键但常被误解的钩子,为开发者提供了一种安全、高效的方式来集成外部状态源。本文将深入探讨其工作原理、实际应用及常见陷阱,帮助你掌握这一强大工具。

为什么需要 useSyncExternalStore?

状态撕裂(Tearing)问题

React 的内置状态管理(如 useState 和 useReducer)和 Context API 非常适合管理组件内部状态。但当组件需要依赖外部数据源时,如:

  • 🌐 浏览器 API:navigator.onLine(网络状态)、document.visibilityState(页面可见性)、window.matchMedia(媒体查询)
  • 📦 第三方状态库:旧版 Redux 或 MobX,或未针对 React 并发特性设计的自定义存储
  • 🔗 全局 JavaScript 变量或自定义事件系统

在 useSyncExternalStore 出现之前,开发者通常使用 useEffect 和 useState 组合来订阅这些外部源并更新组件状态。虽然这在简单场景下有效,但在并发渲染中可能导致状态撕裂:即 React 在渲染过程中暂停时,外部状态发生变化,导致同一渲染周期内不同组件看到不一致的数据版本,UI 显示冲突信息。

useSyncExternalStore 通过提供 React 管理的同步订阅机制,确保在渲染过程中所有组件都能看到一致的数据快照,从而避免撕裂。

🔧 API 详解

const synchronizedState = useSyncExternalStore(
  subscribe,      // 订阅函数
  getSnapshot,    // 获取快照函数
  getServerSnapshot? // 可选:服务器端快照函数
);

参数解析

  1. subscribe(callback):

    • 负责设置对外部数据存储的订阅
    • 接收一个由 React 提供的回调函数作为参数
    • 当外部存储数据变化时,必须调用此回调通知 React
    • 必须返回一个取消订阅的清理函数
  2. getSnapshot():

    • 返回组件需要的当前数据快照
    • 必须是纯函数(无副作用)且同步执行
    • 需要高效执行,因为 React 可能多次调用它
    • 关键要求:如果底层数据未变化,必须返回与上次相同的引用(对象/数组)或原始值
  3. getServerSnapshot?() (可选):

    • 仅用于服务器端渲染(SSR)和客户端注水(hydration)
    • 返回服务器端应显示的数据初始快照
    • 对于仅客户端的存储(如依赖浏览器API),应提供合理的默认值

💻 实战示例:在线状态检测

下面是一个检测用户网络状态的自定义钩子实现:

import { useSyncExternalStore } from 'react'

// 1. 在组件外定义 getSnapshot:从外部源读取当前状态
function getOnlineStatusSnapshot() {
  return navigator.onLine
}

// 2. 在组件外定义 subscribe:设置监听器并在变化时调用React提供的回调
function subscribeToOnlineStatus(callback) {
  window.addEventListener('online', callback)
  window.addEventListener('offline', callback)
  
  // 返回清理函数
  return () => {
    window.removeEventListener('online', callback)
    window.removeEventListener('offline', callback)
  }
}

// 3. 创建自定义钩子
export function useOnlineStatus() {
  // useSyncExternalStore 确保同步读取和防止撕裂
  const isOnline = useSyncExternalStore(
    subscribeToOnlineStatus,
    getOnlineStatusSnapshot,
    // 对于健壮的SSR场景,应提供getServerSnapshot:
    () => true // 假设客户端为"在线"或合理的默认值
  )
  
  return isOnline
}

// 在组件中的使用:
function StatusBar() {
  const isOnline = useOnlineStatus()
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>
}

此示例展示了核心模式:稳定的 subscribe 和 getSnapshot 函数与外部源(navigator.onLine 及其事件)交互。

🚫 常见陷阱与解决方案

1. 为什么不用 useEffect + useState?

虽然 useEffect 和 useState 可以订阅外部存储,但这种模式在并发渲染中容易导致撕裂。React 可能暂停组件渲染,外部存储可能更新,然后 React 使用过时数据恢复渲染,导致UI不一致。

useSyncExternalStore 专门设计用于与 React 渲染生命周期集成,确保在渲染过程中读取是同步和一致的。

2. subscribe 或 getSnapshot 函数每次渲染都重新创建

如果在组件或自定义钩子内联定义这些函数而没有记忆化,它们将在每次渲染时创建新函数:

// ❌ 错误:subscribe 和 getSnapshot 每次渲染都重新创建
function MyComponentUsesStore() {
  // 这些函数在每次 MyComponentUsesStore 渲染时都有新标识
  function subscribe(callback) { /* ... */ }
  function getSnapshot() { /* ... */ }
  
  const value = useSyncExternalStore(subscribe, getSnapshot)
}

当 useSyncExternalStore 接收到新的 subscribe 函数实例时,它将重新订阅存储(先调用旧的取消订阅函数,然后调用新的订阅)。这是低效的,如果处理不当可能导致错误或内存泄漏。

解决方案:

  • 在组件外定义它们(如 useOnlineStatus 示例所示)
  • 使用 useCallback 记忆化它们(如果函数依赖props或state)
// ✅ 正确:subscribe 和 getSnapshot 是稳定的
function subscribeToStore(callback) { /* ... */ }
function getStoreSnapshot() { /* ... */ }

function MyComponentUsesStore() {
  const value = useSyncExternalStore(subscribeToStore, getStoreSnapshot)
}

// ✅ 也正确(如果依赖props,如storeId):
import { useCallback, useSyncExternalStore } from 'react'

function MyComponentUsesStore({ storeId }) {
  const subscribe = useCallback(
    (callback) => {
      return externalStoreAPI.subscribe(storeId, callback)
    },
    [storeId]
  )
  
  const getSnapshot = useCallback(
    () => externalStoreAPI.getSnapshot(storeId),
    [storeId]
  )
  
  const value = useSyncExternalStore(subscribe, getSnapshot)
}

3. getSnapshot 被调用太多次

React 可能在一个渲染过程中多次调用 getSnapshot,甚至在没有重新渲染的情况下(例如验证一致性)。这是预期行为。

因此,getSnapshot 必须:

  • 快速:避免昂贵计算
  • 纯净:无副作用,不修改其范围外的任何内容
  • 一致:如果相关底层存储数据未更改,必须返回完全相同的值(对象/数组的引用相等性)

React 使用 getSnapshot 的返回值来确定是否需要重新渲染。如果即使数据未更改也频繁返回新的对象/数组实例,将导致不必要的重新渲染。

4. "useSyncExternalStore is not a function" 错误

这通常意味着你使用的是 React 18 之前的版本。useSyncExternalStore 是在 React 18 中引入的。

解决方案:将 react 和 react-dom 包升级到至少 v18.0.0。

5. 服务器端渲染(SSR)的正确使用

如果你的外部存储可以在服务器上提供有意义的值,必须提供第三个参数 getServerSnapshot。如果不提供,将在控制台中看到错误:"Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering."

const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)

getServerSnapshot 应返回服务器上应呈现的存储初始状态。例如,如果你正在与 localStorage(服务器上不存在)同步,getServerSnapshot 可能返回 null 或默认值。

如果省略 getServerSnapshot 并且组件在服务器上呈现,React 期望初始客户端呈现(水合后)与服务器呈现的 HTML 匹配。如果客户端的 getSnapshot() 返回与服务器上隐式呈现的内容不同的内容,将得到水合不匹配错误。

6. 与 Next.js、Remix 或其他 SSR 框架一起使用

绝对可以!这个钩子对于在此类环境中安全使用外部存储至关重要。

  • 如果存储与服务器兼容:提供 getServerSnapshot
  • 如果存储仅限浏览器(例如 window.matchMedia):getServerSnapshot 应返回合理的默认值(例如,媒体查询为 false,或 navigator.onLine 为 true)

客户端的 getSnapshot 将在水合时提供实际的浏览器值,React 将确保平滑过渡。

// 在你的钩子中
const getServerSnapshot = () => false // 或任何有意义的默认值
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)

或者,如果不存在合理的服务器默认值,可以有条件地仅在客户端呈现组件,或者如果未提供 getServerSnapshot 并期望它在水合期间暂停,则将其包装在 <Suspense> 中。

7. 何时使用它而不是 Redux、Zustand、Jotai 或 React Context?

这是一个常见的困惑点。

  • React Context:用于需要在组件树中共享而不需要属性钻取的 React 状态。它适用于由 React 管理的状态。
  • Redux、Zustand、Jotai 等:这些库的现代版本通常在内部使用 useSyncExternalStore 来将其外部存储逻辑与 React 的并发渲染桥接。作为这些库的最终用户,你通常使用它们提供的钩子(useSelector、useStore)而不直接调用 useSyncExternalStore。

作为应用程序开发者,你会在以下情况下直接使用 useSyncExternalStore:

  • 与非 React 感知的外部存储集成:这可能是第三方纯 JavaScript 库、全局变量、Web Worker 或任何在 React 外部管理状态并且没有自己的 React 绑定的系统。
  • 直接订阅浏览器 API:如 navigator.onLine、document.visibilityState、window.matchMedia 等。
  • 构建自己的状态管理库:如果你正在编写新的状态管理解决方案,useSyncExternalStore 是你用来使其与 React 并发功能兼容的原始工具。

因此,它不是与像 Zustand 这样的库的"非此即彼";相反,useSyncExternalStore 通常是它们的实现细节,或者当这些库不适合与特定外部数据源接口时,它是你的工具。

8. 如何避免由于 getSnapshot 导致的无限循环或不必要的重新渲染?

React 使用 Object.is() 比较先前的快照与 getSnapshot 返回的当前快照。如果 getSnapshot 每次调用都返回新的对象或数组引用,React 会认为状态已更改,导致重新渲染,即使底层数据相同。

// 外部存储(示例)
const myExternalStore = {
  _data: {
    user: {
      name: 'Alex',
      preferences: {
        theme: 'dark'
      }
    }
  },
  listeners: [],
  getData() {
    return this._data
  },
  subscribe(listener) {
    /* ... */
  },
  // ... 更新 _data 和通知监听器的方法
}

// ❌ 错误:getSnapshot 总是返回新对象
function getPreferencesSnapshot_Bad() {
  // 即使首选项未更改,这也是每次的新对象实例。
  return { ...myExternalStore.getData().user.preferences }
}

// ✅ 正确:如果数据未更改,返回相同的对象引用。
// 这需要你的存储或快照逻辑更智能一些。

// 选项1:如果存储本身管理不可变数据以供选择。
function getPreferencesSnapshot_Good_Immutable() {
  // 假设 myExternalStore.getData().user.preferences *是*不可变对象,
  // 仅在实际更改时替换。
  return myExternalStore.getData().user.preferences
}

// 选项2:手动缓存派生快照。
let lastKnownPreferences = myExternalStore.getData().user.preferences
let cachedPreferencesSnapshot = { ...lastKnownPreferences }

function getPreferencesSnapshot_Good_Cached() {
  const currentPreferences = myExternalStore.getData().user.preferences
  
  // 浅比较;对于深层对象,可能需要深层比较
  // 或确保存储在任何嵌套更改时通过引用替换 `currentPreferences`。
  if (currentPreferences !== lastKnownPreferences) {
    cachedPreferencesSnapshot = { ...currentPreferences }
    lastKnownPreferences = currentPreferences
  }
  
  return cachedPreferencesSnapshot
}

关键是引用稳定性:如果数据未更改,getSnapshot 必须返回与之前完全相同的对象实例。如果是原始值(字符串、数字、布尔值),除非不必要地重新计算,否则这不是问题。

🎁 附加内容:可复用的 useMediaQuery 钩子

以下是构建可复用媒体查询钩子的方法,确保如果查询可以更改,使用 useCallback 使 subscribe 和 getSnapshot 稳定。这来自 Epic React workshop 关于高级 React API 的内容。

import { Suspense, useSyncExternalStore } from 'react'
import * as ReactDOM from 'react-dom/client'

export function makeMediaQueryStore(mediaQuery: string) {
  function getSnapshot() {
    return window.matchMedia(mediaQuery).matches
  }

  function subscribe(callback: () => void) {
    const mediaQueryList = window.matchMedia(mediaQuery)
    mediaQueryList.addEventListener('change', callback)
    return () => {
      mediaQueryList.removeEventListener('change', callback)
    }
  }

  return function useMediaQuery() {
    return useSyncExternalStore(subscribe, getSnapshot)
  }
}

const useNarrowMediaQuery = makeMediaQueryStore('(max-width: 600px)')

function NarrowScreenNotifier() {
  const isNarrow = useNarrowMediaQuery()
  return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen'
}

function App() {
  return (
    <div>
      <div>This is your narrow screen state:</div>
      <Suspense fallback="...loading...">
        <NarrowScreenNotifier />
      </Suspense>
    </div>
  )
}

const root = ReactDOM.hydrateRoot(rootEl, <App />, {
  onRecoverableError(error) {
    if (String(error).includes('Missing getServerSnapshot')) return
    console.error(error)
  }
})

注意:在 useMediaQuery 示例中,如果你不在并发模式下或对此特定功能的状态撕裂不关心,对于仅客户端场景,使用 useEffect 和 useState 可能看起来更简单。然而,useSyncExternalStore 是最健壮的处理方式,尤其是在 SSR 和并发功能方面。

注意,即使我们假设服务器渲染(使用 hydrateRoot),我们也没有提供 getServerSnapshot,因为我们无法在服务器上检查媒体查询。因此,我们添加了一个 onRecoverableError 处理程序来避免不必要的错误日志记录。

🔧 故障排除清单

调试 useSyncExternalStore 时:

  • React 版本:你是否在使用 React 18 或更新版本?
  • 稳定函数:你的 subscribe 和 getSnapshot 函数是否稳定(在组件外定义或使用 useCallback 记忆化)?
  • getSnapshot 纯净性和性能:getSnapshot 是否快速、纯净,并且如果底层数据未更改,是否返回相同的值引用(Object.is 为 true)?
  • subscribe 正确性:subscribe 是否仅在存储实际更改时正确调用 React 提供的回调?它是否返回适当的取消订阅函数?
  • SSR:如果使用 SSR,你是否提供了 getServerSnapshot 函数?它返回的值是否与客户端最初可能看到的值一致或安全的默认值?
  • 外部状态:你是否确定你正在将其用于真正外部的 React 状态?React 状态和上下文有自己的机制。

总结

useSyncExternalStore 是一个专门但强大的钩子,用于在并发时代安全地将 React 组件连接到外部数据源。通过理解其目的(防止撕裂和确保一致读取)并遵循这些最佳实践,你可以自信地将 React 与任何外部状态管理系统或浏览器 API 集成。

最后更新: 2025/9/26 10:15