【React】Hooks 解锁外部状态安全订阅 useSyncExternalStore 应用与最佳实践

一、背景

  1. useSyncExternalStore 是 React 18 引入的一个 Hook;
  2. 用于从外部存储(例如状态管理库、浏览器 API 等)获取状态并在组件中同步显示。这对于需要跟踪外部状态的应用非常有用。

二、场景

  1. 订阅外部 store 例如(redux,mobx,Zustand,jotai) vue的 vuex pinia
  2. 订阅浏览器API 例如(online,storage,location, history hash)等
  3. 抽离逻辑,编写自定义hooks
  4. 服务端渲染支持

三、用法

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)

  1. 我们实现一个useStorage Hook,用于订阅 localStorage 数据。这样做的好处是,我们可以确保组件在 localStorage 数据发生变化时,自动更新同步。
  2. 我们将创建一个 useStorage Hook,能够存储数据到 localStorage,并在不同浏览器标签页之间同步这些状态
  3. 此 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,实现基本的前进、后退操作以及程序化导航。

效果演示

  1. history:这是 useHistory 返回的当前路径值。每次 URL 变化时,useSyncExternalStore 会自动触发更新,使 history 始终保持最新路径。
  2. push 和 replace:
    1. 点击"跳转"按钮调用 push("/push"),会将 /push推入历史记录;
    2. 点击"替换"按钮调用 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;

六、注意事项

  1. 避免条件渲染 :不应基于 useSyncExternalStore 返回的状态值进行条件渲染(如动态加载懒加载组件),因为外部状态变化无法被标记为非阻塞更新,可能触发 Suspense 后备方案,导致用户体验不佳。

  2. 不可变快照getSnapshot 返回的快照必须是不可变的。若底层状态变化,需返回新的不可变快照。

  3. 清理订阅subscribe 函数需返回清理函数,确保组件卸载时取消订阅,防止内存泄漏。

  4. 如果 getSnapshot 返回值和上一次不同时,React 会重新渲染组件。如果总是返回一个不同的值,会进入到一个无限循环,并产生这个报错。

    bash 复制代码
    Uncaught (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

  1. 并发渲染安全性useEffect + useState 在并发模式下可能导致旧值问题,而 useSyncExternalStore 通过同步读取快照确保状态一致性。
  2. 适用场景useSyncExternalStore 更适合需要安全订阅外部状态源的场景,而 useEffect + useState 适用于简单的状态管理。

八、总结

  • useSyncExternalStore 是 React 18 为并发渲染设计的核心 Hook,通过安全订阅外部状态源,解决了状态与 UI 不一致的问题。
  • 它适用于需要与第三方状态管理库或浏览器 API 集成的场景,确保组件在并发渲染模式下仍能正确响应状态变化。并在不同浏览器标签页之间同步这些状态
相关推荐
暖木生晖5 小时前
flex-wrap子元素是否换行
javascript·css·css3·flex
gnip6 小时前
浏览器跨标签页通信方案详解
前端·javascript
gnip7 小时前
运行时模块批量导入
前端·javascript
逆风优雅7 小时前
vue实现模拟 ai 对话功能
前端·javascript·html
这是个栗子8 小时前
【问题解决】Vue调试工具Vue Devtools插件安装后不显示
前端·javascript·vue.js
姑苏洛言8 小时前
待办事项小程序开发
前端·javascript
Warren9810 小时前
公司项目用户密码加密方案推荐(兼顾安全、可靠与通用性)
java·开发语言·前端·javascript·vue.js·python·安全
1024小神12 小时前
vue3 + vite项目,如果在build的时候对代码加密混淆
前端·javascript
轻语呢喃12 小时前
useRef :掌握 DOM 访问与持久化状态的利器
前端·javascript·react.js
wwy_frontend13 小时前
useState 的 9个常见坑与最佳实践
前端·react.js