深入理解 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? // 可选:服务器端快照函数
);
参数解析
subscribe(callback)
:- 负责设置对外部数据存储的订阅
- 接收一个由 React 提供的回调函数作为参数
- 当外部存储数据变化时,必须调用此回调通知 React
- 必须返回一个取消订阅的清理函数
getSnapshot()
:- 返回组件需要的当前数据快照
- 必须是纯函数(无副作用)且同步执行
- 需要高效执行,因为 React 可能多次调用它
- 关键要求:如果底层数据未变化,必须返回与上次相同的引用(对象/数组)或原始值
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 集成。