【翻译】useSyncExternalStore:揭秘其实用性,助力React开发实践

65 阅读12分钟

原文链接:www.epicreact.dev/use-sync-ex…

作者:Kent C. Dodds

React 的 useSyncExternalStore 属于那种大多数开发者日常用不到,但需要时却不可或缺的钩子。它对于将 React 组件与外部状态管理系统或 React 无法控制的浏览器 API 集成至关重要。遗憾的是,它也常被误解。让我们厘清困惑,通过实际案例进行演示,并解决最常见的错误。

为什么存在 useSyncExternalStore ?"撕裂"问题

React 内置的状态管理(useStateuseReducer)和 Context API 非常适合管理应用程序内部的数据。但当组件需要显示 React 控制范围之的数据源时该怎么办?请考虑以下情况:

  • 浏览器 APInavigator.onLine(在线状态)、document.visibilityState(页面可见性)、window.matchMedia(媒体查询)。
  • 第三方状态管理库:旧版 Redux 或 MobX,或未针对 React 并发特性设计的自定义存储库。(注:Redux、Zustand 和 Jotai 等现代库通常在内部使用 useSyncExternalStore 实现 React 绑定)。
  • 全局 JavaScript 变量或自定义事件系统:任何 React 未管理的可变数据源。

useSyncExternalStore 之前,开发者通常通过 useEffectuseState 订阅这些外部数据源并更新本地组件状态。虽然这种方法在简单场景下可行,但可能与React的并发渲染机制产生冲突。

并行渲染使 React 能同时处理多个 UI 更新任务,并可根据需要暂停、恢复或放弃渲染工作。这显著提升了用户感知到的性能表现。然而,当 React 正处于渲染组件树的过程中,若外部存储发生变更,不同组件可能读取到不同版本的外部数据。这种不一致现象称为"撕裂"——此时 UI 会因显示冲突信息而出现明显的"撕裂"效果。

useSyncExternalStore 通过提供由 React 管理的同步订阅外部存储的方式解决了这个问题。它确保在渲染过程中,所有组件都能看到相同且一致的数据快照,即使在并发更新期间也是如此,从而避免了画面撕裂现象。

API详解

const synchronizedState = useSyncExternalStore(
  subscribe,
  getSnapshot,
  getServerSnapshot? // Optional
);

让我们仔细看看这些论点:

  1. subscribe(callback) :
  • 此函数负责设置对外部数据存储的订阅。
  • 它接受一个参数:由 React 提供的 callbacl 函数。
  • 每当外部存储中的数据发生变化时,您的 subscribe 函数必须调用此 callbacl。这会通知 React 存储已发生变更,可能需要重新渲染。
  • 该函数必须返回一个unsubscribe订阅函数。当组件卸载时,或在渲染间隔内subscribe函数本身发生变更时,React 将调用此清理函数。
  1. getSnapshot() :
  • 该函数的职责是返回存储中当前数据的快照,以满足组件的需求。
  • 它必须是纯函数(无副作用)且同步执行(立即返回值)。React可能多次调用该函数,即使存储未发生变化,因此它需要快速执行。
  • 关键点在于:若底层数据未变更,getSnapshot 应通过引用返回相同值(若为对象或数组),或返回与上次调用时相同的原始值。这使React能通过Object.is比较优化重渲染。更多细节详见"常见错误"章节。
  1. getServerSnapshot?() (可选) :
  • 此函数仅用于服务器端渲染(SSR)和客户端数据注入。
  • 它应返回数据在服务器端初始呈现时的快照。
  • 若外部存储仅限客户端使用(例如依赖服务器端不存在的浏览器API),可在此处提供默认值或占位符。
  • 若未提供值且在SSR场景中使用,组件通常会在客户端暂停直至数据注入完成;若服务器渲染的HTML与初始客户端渲染不匹配,React可能抛出错误。

示例:追踪在线状态(浏览器 API)

让我们创建一个名为 useOnlineStatus 的自定义钩子,用于追踪用户浏览器是否处于在线状态。

import { useSyncExternalStore } from 'react'

// 1. Define getSnapshot outside the component:
// It reads the current state from the external source.
function getOnlineStatusSnapshot() {
	return navigator.onLine
}

// 2. Define subscribe outside the component:
// It sets up listeners and calls the React-provided callback on change.
function subscribeToOnlineStatus(callback) {
	window.addEventListener('online', callback)
	window.addEventListener('offline', callback)
	// Return the cleanup function
	return () => {
		window.removeEventListener('online', callback)
		window.removeEventListener('offline', callback)
	}
}

// 3. Create the custom hook
export function useOnlineStatus() {
	// useSyncExternalStore ensures synchronous reads and tearing prevention
	const isOnline = useSyncExternalStore(
		subscribeToOnlineStatus,
		getOnlineStatusSnapshot,
		// For a robust SSR scenario, you should provide getServerSnapshot:
		() => true, // Assuming client is 'online' or a sensible default
	)
	return isOnline
}

// Usage in a component:
function StatusBar() {
	const isOnline = useOnlineStatus()
	return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>
}

此示例演示了核心模式:稳定的 subscribegetSnapshot 函数与外部源(navigator.onLine及其事件)进行交互。

常见错误、陷阱及解决之道

人们向我提出了许多关于 useSyncExternalStore 的问题。以下是一些最常见的问题。

1. "为什么不直接使用 useEffect + useState呢?"

虽然 useEffectuseState 可以订阅外部存储,但这种模式在并发渲染中容易发生撕裂。React 可能暂停组件渲染时,外部存储发生更新,导致 React 恢复渲染时使用过期数据,或引发 UI 不一致。useSyncExternalStore 专为与 React 渲染生命周期协同设计,确保渲染过程中读取操作保持同步与一致。若需同步 React 外部状态,useSyncExternalStore组件是正确且稳健的解决方案

2. "我的 subscribegetSnapshot 函数在每次渲染时都会被重新创建!"

若在组件或自定义钩子中未启用备忘录机制而直接内联定义 subscribegetSnapshot,它们将在每次渲染时生成新函数。

// ❌ Bad: subscribe and getSnapshot are re-created every render
function MyComponentUsesStore() {
	// These functions get new identities on each render of MyComponentUsesStore
	function subscribe(callback) {
		/* ... */
	}
	function getSnapshot() {
		/* ... */
	}
	const value = useSyncExternalStore(subscribe, getSnapshot)
	// ...
}

useSyncExternalStore 接收到新的 subscribe 订阅函数实例时,它会重新订阅存储(先调用旧的取消订阅函数,再调用新的订阅函数)。这种操作效率低下,若处理不当可能导致错误或内存泄漏。

解决方法:

  • 在组件外部定义它们:如 useOnlineStatus 示例所示。这是最简单且通常最佳的方式。
  • 使用 useCallback 进行备忘:如果 subscribegetSnapshot 函数依赖于属性或状态(例如订阅特定文档的 ID),请用 useCallback 包裹它们。
// ✅ Good: subscribe and getSnapshot are stable
function subscribeToStore(callback) {
	/* ... */
}
function getStoreSnapshot() {
	/* ... */
}

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

// ✅ Also Good (if dependent on props, e.g., storeId):
import { useCallback, useSyncExternalStore } from 'react'

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

	const getSnapshot = useCallback(() => {
		return externalStoreAPI.getSnapshot(storeId)
	}, [storeId])

	const value = useSyncExternalStore(subscribe, getSnapshot)
	// ...
}

3. "为什么getSnapshot被调用这么多次?"

React 在渲染过程中可能多次调用 getSnapshot,即使未发生重新渲染也可能调用(例如用于验证一致性)。这是预期行为。因此 getSnapshot 必须满足

  • 快速:避免耗时计算。
  • 纯粹:无副作用。不得修改作用域外的任何内容。
  • 一致性:若快照相关的底层存储数据未变更,getSnapshot 必须返回完全相同的值(对象/数组采用 Object.is 实现引用相等性)。

React 通过 getSnapshot 的返回值判断是否需要重新渲染。若该方法在数据未变更时频繁返回新对象/数组实例,将导致不必要的重新渲染(参见下文错误 #8)。

4. "我遇到一个错误:useSyncExternalStore 不是一个函数。"

这几乎总是意味着您正在使用低于 React 18 的版本。useSyncExternalStore 是在 React 18 中引入的。解决方案:将您的 reactreact-dom 包升级至至少 v18.0.0 版本。

5. "如何在服务器端渲染(SSR)中正确使用此功能?"

如果您的外部存储能在服务器端提供有意义的值,则必须提供第三个参数 getServerSnapshot。否则,控制台将显示以下错误:

Missing getServerSnapshot, which is required for server-rendered content. Will
revert to client rendering.

以下是使用 getServerSnapshot 的示例:

const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)

getServerSnapshot 方法应返回存储库在服务器端渲染时的初始状态。例如,若同步对象为 localStorage(该存储机制在服务器端不存在),则 getServerSnapshot 可能返回 null 或默认值。需注意这可能导致内容闪现错误。因此你需要设计巧妙的方案来处理此问题(更优方案是使用 Cookie 或服务器端状态管理方案)。

若省略 getServerSnapshot 且组件在服务器端渲染,React 期望初始客户端渲染(水合后)与服务器渲染的 HTML 匹配。若客户端调用 getSnapshot() 返回的内容与服务器隐式渲染结果不一致(或服务器无法渲染),则会触发水合不匹配错误。若使用该钩子的组件在服务器端挂起(例如因无法获取值),则在水合过程中客户端也会保持挂起状态,直至 subscribe 函数被调用且客户端快照可用。

6. "我能用这个搭配Next.js、Remix或其他服务器端渲染框架吗?"

是的,绝对如此!在这种环境中安全使用外部存储库,这个钩子至关重要。

  • 如果商店支持服务器端:提供 getServerSnapshot 方法。
  • 若存储仅限浏览器端(如 window.matchMedia):
    • getServerSnapshot 应返回合理默认值(例如媒体查询返回 falsenavigator.onLine 返回 true)。
    • 客户端的 getSnapshot 将在 hydration 时提供实际浏览器值。React 将确保平滑过渡。
    • 或者,如果不存在合理的服务器默认值,您可以在客户端条件渲染该组件,或者在未提供 getServerSnapshot 时将其包裹在 中,并期望它在加载过程中保持悬挂状态。
    • window.matchMedia 示例:
// In your hook
const getServerSnapshot = () => false // Or whatever default makes sense
// ...
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)

7. "何时应该使用它来替代 Redux、Zustand、Jotai 或 React Context?"

这是常见的混淆点。

  • React 上下文:用于在组件树中共享 React 状态,无需进行属性钻取。它适用于由 React 管理状态的情况。
  • Redux、Zustand、Jotai 等:这些库的现代版本通常在内部使用 useSyncExternalStore 来连接其外部存储逻辑与 React 的并发渲染。作为这些库的终端用户,你通常使用它们提供的钩子(useSelectoruseStore),不会直接调用 useSyncExternalStore

应用开发者需直接使用 useSyncExternalStore 的场景:

  1. 集成非 React 感知型外部存储:例如第三方原生 JS 库、全局变量、Web Worker 或任何在 React 外部管理状态且无专属 React 绑定机制的系统。
  2. 直接订阅浏览器 API:如 navigator.onLinedocument.visibilityStatewindow.matchMedia等。
  3. 构建自定义状态管理库:若您正在开发新的状态管理方案,useSyncExternalStore 是使其兼容 React 并发功能的核心组件。

因此,它并非与 Zustand 等库的"二选一"关系;相反,useSyncExternalStore 通常是这些库的实现细节,或是在特定外部数据源接口需求下您可选用的工具。

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

React 使用 Object.is() 来比较前一个快照与 getSnapshot 返回的当前快照。如果每次调用 getSnapshot 都会返回新的对象或数组引用,React 就会认为状态发生了变化,从而导致重新渲染——即使底层数据完全相同也是如此。

// External store (example)
const myExternalStore = {
	_data: { user: { name: 'Alex', preferences: { theme: 'dark' } } },
	listeners: [],
	getData() {
		return this._data
	},
	subscribe(listener) {
		/* ... */ return () => {
			/* ... */
		}
	},
	// ... methods to update _data and notify listeners
}

// ❌ Bad: getSnapshot always returns a new object
function getPreferencesSnapshot_Bad() {
	// Even if preferences haven't changed, this is a new object instance every time.
	return { ...myExternalStore.getData().user.preferences }
}

// ✅ Good: Return the same object reference if data hasn't changed.
// This requires your store or your snapshot logic to be a bit smarter.

// Option 1: If the store itself manages immutable data for selections.
function getPreferencesSnapshot_Good_Immutable() {
	// Assumes myExternalStore.getData().user.preferences *is* the immutable object,
	// replaced only when it actually changes.
	return myExternalStore.getData().user.preferences
}

// Option 2: Manually cache the derived snapshot.
let lastKnownPreferences = myExternalStore.getData().user.preferences
let cachedPreferencesSnapshot = { ...lastKnownPreferences }

function getPreferencesSnapshot_Good_Cached() {
	const currentPreferences = myExternalStore.getData().user.preferences
	// Shallow comparison; for deep objects, you might need a deep comparison
	// or ensure the store replaces `currentPreferences` by reference on any nested change.
	if (currentPreferences !== lastKnownPreferences) {
		cachedPreferencesSnapshot = { ...currentPreferences }
		lastKnownPreferences = currentPreferences
	}
	return cachedPreferencesSnapshot
}

关键在于引用稳定性:如果数据未发生变化,getSnapshot 必须返回与之前完全相同的对象实例。如果是基本类型(string、number、boolean),这通常不成问题,除非你正在不必要地重新计算它。

额外福利:可复用的 useMediaQuery 钩子

以下是构建可复用媒体查询钩子的方法,通过在查询可能变化时使用useCallback确保subscribegetSnapshot的稳定性。此内容源自Epic React高级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 或更高版本?
  • 稳定函数:您的 subscribegetSnapshot 函数是否稳定(定义在组件外部或通过 useCallback 进行备忘)?
  • getSnapshot 纯净性与性能getSnapshot 是否快速、纯净,且在底层数据未改变时返回相同的值引用(Object.istrue)?
  • subscribe 正确性subscribe 是否仅在存储实际变更时正确调用 React 提供的 callback函数?是否返回正确的 unsubscribe 函数?
  • SSR:若使用服务器端渲染,是否提供了 getServerSnapshot 函数?该函数返回的值是否与客户端初始可见状态一致,或为安全默认值?
  • 仅限外部状态:您是否确定此状态确实独立于 React?React 的状态和上下文有其专属机制。

总结

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

祝您同步愉快(且安全)!欢迎参加 Epic React 工作坊《高级 React API》,深入学习更多进阶模式与应用场景!