React useSyncExternalStore 实战:订阅浏览器在线状态、媒体查询与 localStorage 跨标签同步

React useSyncExternalStore 实战:订阅浏览器在线状态、媒体查询与 localStorage 跨标签同步

你大概写过这样的代码:想在组件里知道当前是不是断网了,于是在useEffect里加了window.addEventListener('online', ...),再用useState存一个isOnline。写多了你会发现三个烦人的问题:

  1. 首次渲染时isOnlineuseState里写死的初值,和真实的navigator.onLine可能对不上,页面会闪一下。
  2. 同一个「在线状态」逻辑在五个组件里各抄一遍,监听器加了五次。
  3. 上了 SSR(比如 Next.js),服务端没有navigator,水合(hydration)时客户端和服务端渲染结果不一致,控制台一片红色警告。

React 18 给了个专门解决这类问题的 Hook:useSyncExternalStore。它是为「订阅 React 之外的数据源」而生的——浏览器 API、第三方 store、localStorage,都算外部源。这篇我们把它彻底用明白。

先看朴素写法的坑

// 朴素写法:能跑,但有 hydration 问题和重复监听 function useOnlineStatusNaive() { const [online, setOnline] = useState(navigator.onLine); useEffect(() => { const up = () => setOnline(true); const down = () => setOnline(false); window.addEventListener('online', up); window.addEventListener('offline', down); return () => { window.removeEventListener('online', up); window.removeEventListener('offline', down); }; }, []); return online; }

问题出在useState(navigator.onLine):SSR 时navigator不存在直接抛错;就算兜住了,服务端和客户端初值也不一致。而且每个用这个 Hook 的组件都会独立注册一对监听器,不共享。

useSyncExternalStore 的三个参数

它的签名是:

const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
  • subscribe(callback):订阅外部源。数据变了就调callback通知 React 重读。返回一个取消订阅的函数。
  • getSnapshot():读当前值(快照)。React 用它判断值有没有变(用Object.is比较)。
  • getServerSnapshot():可选,SSR 时在服务端读值,专治水合不一致。

关键点:React 保证在渲染期间用getSnapshot读到的值是一致的,不会出现「读一半外部源变了」的撕裂(tearing)。这是普通useState + useEffect给不了的。

正确写法:在线状态 Hook

import { useSyncExternalStore } from 'react'; // subscribe 只关心「怎么订阅」,不关心当前值 function subscribe(callback) { window.addEventListener('online', callback); window.addEventListener('offline', callback); return () => { window.removeEventListener('online', callback); window.removeEventListener('offline', callback); }; } export function useOnlineStatus() { return useSyncExternalStore( subscribe, () => navigator.onLine, // 客户端读真实值 () => true // 服务端假定在线,避免水合闪烁 ); }

用起来就一行:

function StatusBar() { const online = useOnlineStatus(); return <div>{online ? '🟢 在线' : '🔴 已断网,内容可能未同步'}</div>; }

注意subscribe定义在组件外面。这很重要:如果把subscribe写成组件内每次渲染都新建的函数,React 会认为订阅目标变了,于是每次渲染都退订再重订,白白抖动。定义在外面保证它引用稳定。

进阶一:订阅媒体查询

同样的套路能订阅matchMedia,做响应式逻辑(注意不是用来替代 CSS 媒体查询,而是当你需要在 JS 里根据断点分支时):

export function useMediaQuery(query) { const mql = window.matchMedia(query); const subscribe = (callback) => { mql.addEventListener('change', callback); return () => mql.removeEventListener('change', callback); }; return useSyncExternalStore( subscribe, () => mql.matches, () => false // 服务端默认不匹配 ); } // 用法 function Nav() { const isMobile = useMediaQuery('(max-width: 768px)'); return isMobile ? <Drawer /> : <Sidebar />; }

这里有个坑:window.matchMedia(query)在每次渲染都执行了。如果query是固定字符串没大问题,但更严谨的做法是用useMemo缓存mql,并把subscribeuseCallback绑定,避免 query 变化时的重复创建。

进阶二:localStorage 跨标签页同步

这是useSyncExternalStore真正发光的场景。原生storage事件有个反直觉的行为:它只在「其他」标签页触发,当前标签页自己改localStorage不会收到通知。所以我们需要在写入时手动派发一个自定义事件,把当前标签页也串起来。

import { useSyncExternalStore, useCallback } from 'react'; export function useLocalStorage(key, initialValue) { const subscribe = useCallback((callback) => { // storage 事件:其他标签页改动 window.addEventListener('storage', callback); // 自定义事件:当前标签页自己改动 window.addEventListener('local-storage', callback); return () => { window.removeEventListener('storage', callback); window.removeEventListener('local-storage', callback); }; }, []); const getSnapshot = useCallback(() => { return localStorage.getItem(key) ?? JSON.stringify(initialValue); }, [key, initialValue]); const store = useSyncExternalStore( subscribe, getSnapshot, () => JSON.stringify(initialValue) // 服务端快照 ); const setValue = useCallback((value) => { localStorage.setItem(key, JSON.stringify(value)); // 手动派发,通知本标签页的订阅者 window.dispatchEvent(new Event('local-storage')); }, [key]); return [JSON.parse(store), setValue]; }

用法和useState几乎一样,但状态会在所有标签页之间自动同步:

function ThemeToggle() { const [theme, setTheme] = useLocalStorage('theme', 'light'); return ( <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}> 当前主题:{theme} </button> ); }

在两个标签页打开这个组件,任一个点按钮,另一个会立刻跟着变——这就是订阅外部源的威力。

一个容易踩的性能坑:getSnapshot 每次返回新对象

getSnapshot必须返回「稳定」的值。React 用Object.is比较前后两次快照,如果你每次都返回一个新对象,即便内容一样,React 也会认为变了,导致无限重渲染:

// ❌ 错误:每次返回新数组,React 认为一直在变 () => JSON.parse(localStorage.getItem('list') || '[]') // ✅ 正确:返回原始字符串这种可比较的原始值,解析放到组件里 () => localStorage.getItem('list') || '[]'

上面useLocalStorage的实现就是这么做的:快照返回字符串(原始值,可用Object.is稳定比较),JSON.parse放在返回时做。如果你确实要返回对象,得自己在外面缓存,只有内容真变了才换引用。

小结

  • useSyncExternalStore专门用来订阅 React 之外的数据源:浏览器 API、第三方 store、localStorage。
  • 三个参数各司其职:subscribe管订阅、getSnapshot读值、getServerSnapshot兜 SSR 水合。
  • subscribe要引用稳定(定义在组件外或用useCallback),否则会反复退订重订。
  • getSnapshot必须返回可用Object.is稳定比较的值,别每次返回新对象,否则无限重渲染。
  • localStorage 场景记得手动派发自定义事件,因为原生storage事件不通知当前标签页。

一句话记忆:凡是「React 管不着、但组件想跟着变」的外部状态,就交给 useSyncExternalStore,它帮你解决订阅、一致性快照和 SSR 水合三件事。