useSyncExternalStore
useSyncExternalStore 是 React 18 新增 的 Hook,专门用来把
React 外部的可变数据源
安全地同步到组件内部,并且天然支持并发渲染(Concurrent Features)
。一句话:只要状态不在 React 内部管理(localStorage、全局变量、Redux store、WebSocket 等),就用它来做订阅-渲染
桥梁。
签名三要素
ts
const subscribe = (callback: () => void) => {
// 订阅
callback()
return () => {
// 取消订阅
}
}
// 返回当前快照
const getSnapshot = () => {
return data
}
const snapshot = useSyncExternalStore(
subscribe, // (onStoreChange) => () => void 订阅+退订
getSnapshot, // () => Snapshot 读取当前快照
getServerSnapshot? // () => Snapshot SSR 时的初始值
);
- subscribe:组件
挂载
时 React 会调用它注册监听器;返回的函数会在卸载时执行做清理 - getSnapshot:每次渲染前 React 会调用它拿到
最新状态
,并用 Object.is 与上一次快照比较
,决定要不要重新渲染 - getServerSnapshot(可选):服务端渲染时给 React 一个
不会报错
的初始值,避免hydration
不匹配
对比
场景 | useState/useEffect 写法 | useSyncExternalStore 优势 |
---|---|---|
localStorage 主题切换 | 需判断 typeof window ,监听 storage 事件,同页修改无事件触发,SSR 报错 |
天生 SSR 安全,同页更新无压力 |
Redux/Zustand | 手写 useEffect 订阅、比较引用,并发模式下易出现 tearing(同一时刻不同组件读到不同值) |
官方保证 tear-free,并发安全 |
全局变量/WebSocket | 手动 setState 触发更新,易重复渲染或漏更新 | 精准订阅,按需渲染 |
案例
1. 订阅浏览器Api 实现自定义hook(useStorage)
需求:实现一个useStorage Hook,用于订阅 localStorage 数据。
作用:确保组件在 localStorage 数据发生变化时,自动更新同步。
实现思路:创建一个 useStorage Hook,能够存储数据到 localStorage,并在不同浏览器标签页之间同步这些状态。此 Hook 接收一个键值参数用于存储数据的键名,还可以接收一个默认值用于在无数据时的初始化。
在 hooks/useStorage.ts 中定义 useStorage Hook:
ts
import { useSyncExternalStore } from "react"
export default function useStorage(key: string, initialValue: any) {
// 订阅者
const subscribe = (callback: () => void) => {
// 订阅浏览器Api
window.addEventListener('storage', callback)
return () => {
// 取消订阅
window.removeEventListener('storage', callback)
}
}
// 获取当前数据源的快照
const getSnapshot = () => {
const item = localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
}
// useSyncExternalStore(订阅者, 当前数据源的快照)
const res = useSyncExternalStore(subscribe, getSnapshot)
// 更新缓存
const updateStorage = (value: any) => {
localStorage.setItem(key, JSON.stringify(value))
// 手动触发storage事件
window.dispatchEvent(new StorageEvent('storage'))
}
return [res, updateStorage]
}
在App.tsx中使用:
ts
import useStorage from "./hooks/useStorage"
function App() {
const [count, setCount] = useStorage('count', 0)
return (
<button onClick={() => setCount(count + 1)}> + </button>
Index: {count}
<button onClick={() => setCount(count - 1)}> - </button>
</>
)
}
export default App
效果展示:
2. 订阅history实现路由跳转
需求:实现一个简易的useHistory Hook,获取浏览器url信息 + 参数
useHistory.tsx
ts
import { useSyncExternalStore } from "react"
export const useHistory = () => {
const subscribe = (callback: () => void) => {
// 订阅浏览器Api
// vue 中路由三种模式, ssr使用、 两种web: history, hash
// history 监听url变化用popstate
// hash 监听url变化用hashchange
window.addEventListener('popstate', callback)
window.addEventListener('hashchange', callback)
return () => {
// 取消订阅
window.removeEventListener('popstate', callback)
window.removeEventListener('hashchange', callback)
}
// postate 只能监听浏览器前进后退,无法监听pushState和replaceState. 需要手动触发
}
// 获取快照
const getSnapshot = () => {
return window.location.href
}
const url = useSyncExternalStore(subscribe, getSnapshot)
const push = (url: string) => {
window.history.pushState({}, '', url)
window.dispatchEvent(new PopStateEvent('popstate'))
}
const replace = (url: string) => {
window.history.replaceState({}, '', url)
window.dispatchEvent(new PopStateEvent('popstate'))
}
return [url, push, replace] as const // 定义成元组类型
// 注意: [1, '3', false] => 这的值会被推断成联合类型 number | string | boolean. 元组值是元组类型,则不会被推断成联合类型
}
在App.tsx使用:
ts
import { useHistory } from "./hooks/useHistory"
function App() {
const [count, setCount] = useStorage('count', 0)
return (
<h1>url: {url}</h1>
<button onClick={() => push('/A')}>push</button>
<button onClick={() => replace('/B')}>push</button>
</>
)
}
export default App
实现效果:
总结
React 之外
的状态 → useSyncExternalStoreReact 之内
的状态 → useState / useReducer / useContext