【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 集成的场景,确保组件在并发渲染模式下仍能正确响应状态变化。并在不同浏览器标签页之间同步这些状态
相关推荐
小妖66627 分钟前
4个纯CSS自定义的简单而优雅的滚动条样式
前端·javascript·css
Yensean35 分钟前
Learning vtkjs之MultiSliceImageMapper
javascript·webgl
qq_589568102 小时前
Electron学习+打包
前端·javascript·electron
万叶学编程2 小时前
鸿蒙移动应用开发--ArkTS语法进阶实验
开发语言·javascript·ecmascript
小满zs2 小时前
React-router v7 第七章(导航)
javascript·react.js·ecmascript
源码方舟4 小时前
【HTML5】轮播图的实现方式一
前端·javascript·css·html·html5
源码方舟4 小时前
【HTML5】老式放映机原理-实现图片无缝滚动
前端·javascript·css·html·css3·html5
高木的小天才5 小时前
HarmonyOS ArkUI安全控件开发指南:粘贴、保存与位置控件的实现与隐私保护实践
安全·ui·华为·typescript·harmonyos
可怜的Tom被玩弄于股掌之中6 小时前
网络安全:sql注入练习靶场——sqli_labs安装保姆级教程
sql·安全·web安全·网络安全