问题场景
在 React 应用中,状态通常由 React 自身管理。但很多时候,我们需要使用 React 组件外部定义的状态,例如:
- 一个独立的、非 React 驱动的状态管理库(如 MobX、Redux 的外部 store)。
- 浏览器 API 返回的值,如
window.location、localStorage。 - 一个持久的 WebSocket 连接接收到的数据。
- 一个自定义的、不依赖 React 的事件发布系统。
在 React 18 之前,让组件订阅并响应这些外部状态的变更是一项挑战。开发者需要手动创建 useEffect 来订阅更新,并在组件卸载时取消订阅。这个过程容易出错,例如导致内存泄漏或在服务器端渲染(SSR)时产生不一致。
useSyncExternalStore 是 React 18 引入的一个专用 Hook,旨在彻底解决这个同步问题。它提供了一个安全、高效且并发模式兼容的方式来订阅外部数据源。
核心概念理解
useSyncExternalStore 的设计目标是确保你的组件在任何渲染模式(包括并发特性)下,都能安全地读取并同步外部状态。
它的签名如下:
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
subscribe函数:一个接收回调函数callback作为参数的函数。你的职责是订阅外部状态的变化,并在状态变化时调用callback。此函数必须返回一个用于取消订阅的函数。getSnapshot函数:一个返回外部状态当前值的函数。React 会调用它来获取状态的最新快照。这个值必须是不可变的(immutable),或者在状态未变化时返回相同的引用。getServerSnapshot函数(可选):仅在服务端渲染(SSR)时需要提供。它返回在服务器上渲染期间使用的状态快照。
核心工作原理:当组件渲染时,React 调用 getSnapshot 获取当前状态。当外部状态变化并触发 callback 时,React 会再次调用 getSnapshot,比较新旧快照。如果快照不同(使用 Object.is 比较),React 将安排组件重新渲染以同步最新状态。
分步实现指南
第一步:准备你的外部状态源
假设你有一个最简单的外部状态管理模块 externalStore.js。它使用发布-订阅模式来广播变更。
// externalStore.js
let state = { count: 0 };
let listeners = new Set();
// 获取当前状态
export function getState() {
return state;
}
// 订阅状态变化
export function subscribe(callback) {
listeners.add(callback);
// 返回一个取消订阅的函数
return () => listeners.delete(callback);
}
// 更新状态(模拟异步或用户操作)
export function increment() {
state = { count: state.count + 1 };
// 通知所有订阅者
listeners.forEach((listener) => listener());
}
第二步:在组件中使用 useSyncExternalStore
现在,创建一个 React 组件来同步并显示这个外部状态。
// CounterComponent.jsx
import { useSyncExternalStore } from 'react';
import { subscribe, getState } from './externalStore';
function CounterComponent() {
// 1. 调用 Hook,传入 `subscribe` 和 `getSnapshot` 函数
const count = useSyncExternalStore(
subscribe,
getState // 注意:这里直接传入 getState 函数引用
);
return (
<div>
<p>外部计数器: {count}</p>
{/* 触发外部状态更新 */}
<button onClick={() => increment()}>增加</button>
</div>
);
}
export default CounterComponent;
关键点:getState 函数被直接传递给 useSyncExternalStore 作为 getSnapshot 参数。只要外部状态未变,getState() 返回的对象引用就是相同的,这避免了不必要的重新渲染。
第三步:处理返回引用的变化(进阶)
如果外部状态本身是复杂的对象或数组,并且每次更新都返回一个新的引用(例如,使用 {...state, count: state.count + 1}),即使内容相同,Object.is 比较也会认为快照已变化,导致额外渲染。
为了优化,你需要确保在状态内容未变时返回同一个引用。一个常见模式是使用选择器(selector)来获取状态的一部分,并缓存结果。
// externalStore.js (优化后)
let state = { user: { name: 'Alice', age: 30 }, posts: [] };
// ... (listeners 定义同上)
// 提供一个带选择器的订阅函数
export function subscribeWithSelector(selector, callback) {
// 这里简化处理,实际可能需要更精细的监听逻辑
return subscribe(callback);
}
// 提供一个带选择器的获取快照函数
export function getSnapshotWithSelector(selector) {
// selector 是一个函数,例如 (state) => state.user
// 关键:如果 selector 的结果在引用上未变,则返回旧引用
// 这需要外部 store 本身保证(例如,使用 immutable 更新库如 Immer)
return selector(state);
}
在组件中,你可以这样使用:
const userName = useSyncExternalStore(
subscribe, // 订阅整个 store
() => getSnapshotWithSelector(state => state.user.name) // 只选择并缓存 `name`
);
这确保了只有当 state.user.name 的引用改变时,组件才会重新渲染。
处理常见挑战
场景一:订阅来自浏览器 API 的值
订阅 window.location.href 的变化是一个经典用例。你需要监听 popstate 事件。
// locationStore.js
export function subscribeLocation(callback) {
window.addEventListener('popstate', callback);
return () => window.removeEventListener('popstate', callback);
}
export function getLocationSnapshot() {
return window.location.href;
}
// 在组件中使用
function CurrentUrl() {
const url = useSyncExternalStore(subscribeLocation, getLocationSnapshot);
return <p>当前 URL: {url}</p>;
}
场景二:在服务端渲染(SSR)中使用
SSR 期间没有浏览器环境,因此 getSnapshot 可能会失败。你必须提供第三个参数 getServerSnapshot。
function useWindowWidth() {
const width = useSyncExternalStore(
subscribe,
// 客户端的快照函数
() => window.innerWidth,
// 服务端的快照函数:返回一个合理的默认值
() => 1024
);
return width;
}
场景三:迁移 useEffect + useState 的旧代码
旧模式(有问题):
function useWindowWidthOld() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return width;
}
问题:在并发模式下,useState 的更新可能被批处理,导致 UI 与真实的 window.innerWidth 暂时不一致。
新模式(使用 useSyncExternalStore):
function useWindowWidthNew() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
},
() => window.innerWidth,
() => 1024 // SSR 默认值
);
}
新模式保证了渲染值与外部状态源的同步,是处理此类需求的推荐方式。
与状态管理库的集成
像 Redux、MobX、Zustand 等库,通常已经或正在集成 useSyncExternalStore。
以 Redux 为例:React-Redux 库的 useSelector Hook 在底层已经替换为基于 useSyncExternalStore 的实现,以获得更好的并发兼容性。作为开发者,你通常不需要直接调用 useSyncExternalStore,而是使用库提供的更高级别的 Hook。
如果你在编写一个自定义的状态管理库,useSyncExternalStore 是将其与 React 安全集成的最佳方式。你需要:
- 暴露一个
subscribe函数,用于注册监听器。 - 暴露一个
getState或getSnapshot函数,返回当前状态。 - 推荐同时暴露一个
getServerSnapshot用于 SSR。
然后,你可以为你的库编写一个自定义 Hook 来封装这些逻辑,方便用户使用。
最终总结与注意事项
何时使用 useSyncExternalStore:当你的组件需要订阅并同步一个 React 组件树外部的、可变的数据源时,就应该使用它。
关键优势:
- 保证一致性:在并发渲染中确保 UI 与外部状态严格同步。
- 避免撕裂:防止组件在单次渲染中读取到不同版本的外部状态。
- 简化代码:替代了手动管理
useEffect订阅和取消订阅的繁琐逻辑。
注意事项:
getSnapshot返回的值必须在外部状态未变时保持引用稳定,否则会导致不必要的渲染。使用选择器进行缓存是常见优化手段。- 对于服务器端渲染,务必提供
getServerSnapshot函数。 - 该 Hook 专门用于外部存储。对于纯 React 状态,继续使用
useState或useReducer。

暂无评论,快来抢沙发吧!