一、背景
- useSyncExternalStore 是 React 18 引入的一个 Hook;
- 用于从外部存储(例如状态管理库、浏览器 API 等)获取状态并在组件中同步显示。这对于需要跟踪外部状态的应用非常有用。
二、场景
- 订阅外部 store 例如(redux,mobx,Zustand,jotai) vue的 vuex pinia
- 订阅浏览器API 例如(online,storage,location, history hash)等
- 抽离逻辑,编写自定义hooks
- 服务端渲染支持
三、用法
tsx
const state = useSyncExternalStore(
subscribe: (onStoreChangeCallback: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot?: () => Snapshot
);
- subscribe:订阅函数,接收一个回调函数
onStoreChange
,当外部状态变化时调用该回调。需返回一个清理函数,用于取消订阅。 - getSnapshot:获取当前数据源的快照(当前状态)。
- getServerSnapshot(可选):服务端渲染时使用的快照函数,确保客户端与服务端状态一致。
返回值:该 res 的当前快照,可以在你的渲染逻辑中使用
tsx
const subscribe = (callback: () => void) => {
// 订阅
callback()
return () => {
// 取消订阅
}
}
const getSnapshot = () => {
return data
}
const res = useSyncExternalStore(subscribe, getSnapshot)
四、订阅浏览器 Api
实现自定义hook
(useStorage
)
- 我们实现一个useStorage Hook,用于订阅 localStorage 数据。这样做的好处是,我们可以确保组件在 localStorage 数据发生变化时,自动更新同步。
- 我们将创建一个 useStorage Hook,能够存储数据到 localStorage,并在不同浏览器标签页之间同步这些状态。
- 此 Hook 接收一个键值参数用于存储数据的键名,还可以接收一个默认值用于在无数据时的初始化。
在 hooks/useStorage.ts 中定义 useStorage Hook:
tsx
import { useSyncExternalStore } from "react";
/**
* 自定义 Hook,用于在 React 组件中同步 localStorage 状态
* @param key - localStorage 存储的键名
* @param initValue - 初始值,当 localStorage 中不存在对应键值时使用
* @returns [存储的值, 更新函数] - 返回一个数组,包含当前值和更新函数
*/
export const useStroage = (key: string, initValue: any) => {
/**
* 订阅者: 订阅 storage 变化
* @param callback - storage 变化时的回调函数
* @returns 取消订阅的函数
*/
const subscribe = (callback: () => void) => {
// 订阅浏览器的 storage 事件
window.addEventListener('storage', callback);
return () => {
// 取消订阅
window.removeEventListener('storage', callback);
};
};
/**
* 获取当前 localStorage 中存储的值
* @returns 当前存储的值或初始值
*/
const getSnapshot = () => {
const storedValue = localStorage.getItem(key);
return storedValue ? JSON.parse(storedValue) : initValue;
};
// 使用 React 的 useSyncExternalStore 同步外部状态
const value = useSyncExternalStore(subscribe, getSnapshot);
/**
* 更新 localStorage 中的值
* @param value - 要存储的新值
*/
const updateStorage = (value: any) => {
// 将值存储到 localStorage
localStorage.setItem(key, JSON.stringify(value));
// 触发 storage 事件,通知其他订阅者
window.dispatchEvent(new StorageEvent('storage', { key }));
};
return [value, updateStorage];
};
测试使用 自定义 hooks
tsx
import { useStroage } from './hooks/useStrage'
function App() {
// 使用 useStroage hook 管理计数状态,初始值为1
// count: 当前计数值
// setCount: 更新计数的函数
const [count, setCount] = useStroage('count', 1);
return (
<>
{/* 显示当前计数值 */}
<div>{count}</div>
{/* 增加按钮 - 点击时计数值加1 */}
<button onClick={() => setCount(count + 1)}>Add</button>
{/* 减少按钮 - 点击时计数值减1 */}
<button onClick={() => setCount(count - 1)}>Sub</button>
</>
);
};
export default App;
五、获取浏览器url信息 + 参数
实现一个简易的useHistory Hook,获取浏览器url信息 + 参数
让我们在组件中使用这个 useHistory Hook,实现基本的前进、后退操作以及程序化导航。
效果演示
- history:这是 useHistory 返回的当前路径值。每次 URL 变化时,useSyncExternalStore 会自动触发更新,使 history 始终保持最新路径。
- push 和 replace:
- 点击"跳转"按钮调用 push("/push"),会将 /push推入历史记录;
- 点击"替换"按钮调用 replace("/replace"),则会将当前路径替换为 /replace。
tsx
import { useSyncExternalStore } from "react";
/**
* 自定义 Hook,用于在 React 组件中同步和管理浏览器历史记录状态
* @returns [当前URL, push方法, replace方法] - 返回一个元组,包含当前URL和两个导航方法
*/
export const useHistory = () => {
/**
* 订阅浏览器历史记录变化
* @param callback - 历史记录变化时的回调函数
* @returns 取消订阅的函数
*/
const subscribe = (callback: () => void) => {
// 监听 popstate 事件 - 用于捕获浏览器前进/后退按钮的操作,
// history 底层监听的是 popstate 事件
window.addEventListener('popstate', callback);
// 监听 hashchange 事件 - 用于捕获 URL hash 部分的变化
// hash 底层监听的是 hashchange 事件
window.addEventListener('hashchange', callback);
// 返回清理函数
return () => {
window.removeEventListener('popstate', callback);
window.removeEventListener('hashchange', callback);
};
};
/**
* 获取当前浏览器 URL
* @returns 当前完整的 URL 字符串
* 如果 getSnapshot 返回值和上一次不同时,React 会重新渲染组件。
* 如果总是返回一个不同的值,会进入到一个无限循环,并产生这个报错。
* - 比如数组对象这中引用类型。getSnapshot 返回值和上一次不同时,React 会重新渲染组件。
* - 解决方式需要我们手动去比对更新。
*/
const getSnapshot = () => {
return window.location.href;
};
// 使用 React 的 useSyncExternalStore 同步 URL 状态
const url = useSyncExternalStore(subscribe, getSnapshot);
/**
* 向历史记录栈中推入新的记录
* @param url - 目标 URL
*/
const push = (url: string) => {
window.history.pushState(null, '', url);
// 手动触发 popstate 事件,因为 pushState 不会自动触发
window.dispatchEvent(new PopStateEvent('popstate'));
};
/**
* 替换当前的历史记录
* @param url - 目标 URL
*/
const replace = (url: string) => {
window.history.replaceState(null, '', url);
// 手动触发 popstate 事件,因为 replaceState 不会自动触发
window.dispatchEvent(new PopStateEvent('popstate'));
};
return [url, push, replace] as const;
};
使用
tsx
import { useHistory } from './hooks/useHistory'
/**
* App 组件 - 演示 useHistory 自定义 Hook 的使用
* @returns React 组件
*/
function App() {
// 使用 useHistory hook 获取当前 URL 和导航方法
const [url, push, replace] = useHistory();
return (
<>
{/* 显示当前 URL */}
<div>{url}</div>
{/* 使用 push 方法导航到 /push 路径 */}
<button onClick={() => push('/push')}>/push</button>
{/* 使用 replace 方法替换当前路径为 /replace */}
<button onClick={() => replace('/replace')}>/replace</button>
</>
);
};
export default App;
六、注意事项
-
避免条件渲染 :不应基于
useSyncExternalStore
返回的状态值进行条件渲染(如动态加载懒加载组件),因为外部状态变化无法被标记为非阻塞更新,可能触发 Suspense 后备方案,导致用户体验不佳。 -
不可变快照 :
getSnapshot
返回的快照必须是不可变的。若底层状态变化,需返回新的不可变快照。 -
清理订阅 :
subscribe
函数需返回清理函数,确保组件卸载时取消订阅,防止内存泄漏。 -
如果
getSnapshot
返回值和上一次不同时,React 会重新渲染组件。如果总是返回一个不同的值,会进入到一个无限循环,并产生这个报错。bashUncaught (in promise) Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.
七、对比 useEffect
+ useState
- 并发渲染安全性 :
useEffect
+useState
在并发模式下可能导致旧值问题,而useSyncExternalStore
通过同步读取快照确保状态一致性。 - 适用场景 :
useSyncExternalStore
更适合需要安全订阅外部状态源的场景,而useEffect
+useState
适用于简单的状态管理。
八、总结
useSyncExternalStore
是 React 18 为并发渲染设计的核心 Hook,通过安全订阅外部状态源,解决了状态与 UI 不一致的问题。- 它适用于需要与第三方状态管理库或浏览器 API 集成的场景,确保组件在并发渲染模式下仍能正确响应状态变化。并在不同浏览器标签页之间同步这些状态。