当前位置: 首页 > news >正文

用自定义 Hook 封装数据请求:统一 loading、error 与竞态处理

用自定义 Hook 封装数据请求:统一 loading、error 与竞态处理

在 React 组件里手写fetch+useState+useEffect,第一次写起来很爽,写到第五个组件你就会发现:每个组件里都躺着一坨一模一样的loadingerrordata三件套,而且十有八九都藏着一个竞态 bug。这篇就把这坨样板抽成一个可复用的 Hook,顺手把竞态问题一次性解决掉。

先看朴素写法,它到底哪不对

一个典型的「拉用户详情」组件,大多数人第一版会这么写:

function UserProfile({ userId }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { setLoading(true); fetch(`/api/users/${userId}`) .then((res) => res.json()) .then((json) => { setData(json); setLoading(false); }) .catch((err) => { setError(err); setLoading(false); }); }, [userId]); if (loading) return <p>加载中…</p>; if (error) return <p>出错了:{error.message}</p>; return <div>{data?.name}</div>; }

这段代码有三个问题,前两个是「烦」,第三个是「错」:

  1. 样板重复:换个接口,这十几行几乎原样再抄一遍。
  2. error 不会重置:userId变了重新请求,如果第一次报过错,error还留着旧值,新请求成功了页面可能还在显示错误。
  3. 竞态(race condition):userId从 1 快速切到 2,两个请求同时在飞。如果 1 的响应比 2 晚回来,setData会被 1 的结果覆盖——你点的是用户 2,页面最后显示的是用户 1。这个 bug 在本地网速快时几乎复现不出来,一上线就有人报。

把它抽成一个 Hook

先把三件套和「请求成功/失败」的状态流转收进一个 Hook。这里用AbortController来处理竞态——组件卸载或依赖变化时,直接把上一个请求取消掉。

import { useState, useEffect } from "react"; function useFetch(url) { const [state, setState] = useState({ data: null, loading: true, error: null, }); useEffect(() => { // 每次 url 变化都新建一个控制器,旧请求会被 abort const controller = new AbortController(); // 重新进入请求态,顺手清掉上一轮的 error setState({ data: null, loading: true, error: null }); fetch(url, { signal: controller.signal }) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then((data) => setState({ data, loading: false, error: null })) .catch((error) => { // abort 触发的报错不是真错误,直接忽略,别写进 state if (error.name === "AbortError") return; setState({ data: null, loading: false, error }); }); // 清理函数:url 变化或卸载时取消在途请求 return () => controller.abort(); }, [url]); return state; }

组件侧一下清爽下来:

function UserProfile({ userId }) { const { data, loading, error } = useFetch(`/api/users/${userId}`); if (loading) return <p>加载中…</p>; if (error) return <p>出错了:{error.message}</p>; return <div>{data.name}</div>; }

竞态为什么就没了?因为userId一变,useEffect的清理函数先跑controller.abort(),把用户 1 的在途请求取消,它的.then就走进AbortError分支被忽略,永远不会setData。最终落地的一定是最后一次请求的结果。

坑一:把三个 state 合并成一个对象,不要拆成三个

你可能想把data / loading / error拆成三个useState。别这么干。拆开后你在 catch 里要连写setData(null); setLoading(false); setError(err),三次 setState 触发的中间渲染可能出现「loading 已 false 但 error 还没更新」的空窗态。合并成一个对象、一次setState落地,状态永远自洽。

坑二:URL 拼接对象参数导致死循环

useFetch依赖url这个字符串,字符串按值比较,稳。但如果你想扩展成useFetch(url, options),而options是组件里每次渲染新建的对象字面量:

// 反例:每次渲染 options 都是新对象,useEffect 每次都重跑,死循环 const { data } = useFetch("/api/list", { headers: { "X-Token": token } });

对象每次渲染引用都变,useEffect的依赖比较认为「变了」,于是无限请求。解决办法是把 options 用useMemo固定引用,或者干脆把它序列化后作为依赖:

function useFetch(url, options) { // 用序列化后的字符串做依赖,内容不变就不重跑 const optionsKey = JSON.stringify(options ?? {}); useEffect(() => { const controller = new AbortController(); setState({ data: null, loading: true, error: null }); fetch(url, { ...options, signal: controller.signal }) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then((data) => setState({ data, loading: false, error: null })) .catch((error) => { if (error.name === "AbortError") return; setState({ data: null, loading: false, error }); }); return () => controller.abort(); // 依赖用 optionsKey 而不是 options 本身,避开引用陷阱 // eslint-disable-next-line react-hooks/exhaustive-deps }, [url, optionsKey]); return state; }

坑三:需要「手动重新请求」怎么办

上面的 Hook 只在url变化时请求。但很多场景要一个「重试」按钮,或者提交表单后主动刷新列表。加一个refetch,用一个自增的 tick 触发useEffect:

import { useState, useEffect, useCallback } from "react"; function useFetch(url) { const [state, setState] = useState({ data: null, loading: true, error: null }); const [tick, setTick] = useState(0); // refetch 只是把 tick +1,间接触发下面的 effect,引用保持稳定 const refetch = useCallback(() => setTick((t) => t + 1), []); useEffect(() => { const controller = new AbortController(); setState({ data: null, loading: true, error: null }); fetch(url, { signal: controller.signal }) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then((data) => setState({ data, loading: false, error: null })) .catch((error) => { if (error.name === "AbortError") return; setState({ data: null, loading: false, error }); }); return () => controller.abort(); }, [url, tick]); // tick 变化即重新请求 return { ...state, refetch }; }

用起来:

function UserProfile({ userId }) { const { data, loading, error, refetch } = useFetch(`/api/users/${userId}`); if (loading) return <p>加载中…</p>; if (error) return ( <div> <p>出错了:{error.message}</p> <button onClick={refetch}>重试</button> </div> ); return <div>{data.name}</div>; }

什么时候别自己写,直接上库

这个 Hook 适合中小项目、或你想搞清楚原理时用。但如果你的应用有大量数据请求,还需要缓存、请求去重、后台自动刷新、分页,自己维护这些会越来越重——这时候直接上 TanStack Query(React Query)或 SWR,它们把缓存和竞态都处理好了,自己造轮子不划算。判断标准很简单:只是想去掉样板 + 修竞态,自定义 Hook 够了;开始需要缓存和跨组件共享数据,换库。

小结

  • 手写fetch + useState + useEffect的三件套样板,重复且容易藏竞态 bug,抽成useFetch一次解决。
  • data / loading / error合并成一个对象、一次setState落地,状态自洽,不会出现中间空窗态。
  • 竞态用AbortController治:依赖变化时清理函数abort()掉在途请求,AbortError分支直接忽略,最终落地的永远是最后一次请求。
  • 三个高频坑:对象型 options 用JSON.stringify做依赖避免死循环;要手动刷新用自增tick+useCallback稳定refetch引用。
  • 一句话记忆点:自定义 Hook 治样板和竞态,缓存和共享交给 React Query。
http://www.jsqmd.com/news/1193617/

相关文章:

  • 软件授权与机器码绑定:从离线验证到在线激活的演进之路
  • 2026 宿迁婚嫁黄金回收完整测评攻略|7 月大盘实时金价,6 家 24 小时上门回收婚嫁三金零隐形损耗扣费 - 不晚生活号
  • ShaderGraph叉积节点深度解析:从数学原理到高级应用实战
  • 如何5倍提升ComfyUI模型下载速度:终极加速配置指南
  • Spring Boot 3 + Vue 3 + MySQL 校园电子产品交易系统源码 前后端分离实战
  • 从恢复余数到SRT:Verilog除法器算法演进与实现对比
  • Spring Security OAuth漏洞CVE-2016-4977深度剖析:从SpEL注入到安全设计演进
  • C/C++逆向分析:多编译配置下main函数定位策略与实战
  • ImageSearch:你的私人图片管家,千万级本地图片秒级搜索终极指南
  • 混合基FFT优化:C++实现与性能提升实践
  • 电源纹波测量技巧与示波器配置指南
  • 什么样的适合做一人公司?这8种特质,你占了几个?
  • 2026 年现阶段,行唐靠谱的手机信号屏蔽器制造厂家哪家好,考场禁闭无声,这玩意儿让作弊者彻底失联的惊险瞬间 - 行业推荐【认证官】
  • 用 Ansible playbook 批量初始化服务器:从手忙脚乱到一条命令
  • RK3576开发板机器学习环境搭建与手写数字识别实战
  • 如何免费下载百度文库、道客巴巴等30+文档平台?kill-doc浏览器脚本终极解决方案
  • DS90UB662-Q1寄存器深度解析:构建稳定高速串行链路的实战指南
  • 如何利用zephyr-cn实现Zephyr容器化部署:新手友好的完整指南
  • 终极免费GTA5辅助工具:YimMenu完整使用与安全防护指南
  • 深度解密:5大实战技巧精通跨平台流媒体下载神器N_m3u8DL-RE
  • Windows 10官方ISO镜像下载全攻略:安全纯净的系统安装指南
  • 终极FGO自动战斗工具:Fate/Grand Automata专业配置指南
  • 如何永久备份微信聊天记录?WeChatExporter一键导出完整方案
  • 医院信息化-3 智慧医院“三位一体”评级实战指南:从标准解读到建设路径
  • VirtualLab通用探测器:光学仿真中的电磁场分析利器
  • Transformer推理优化终极指南:从500ms到10ms的完整实战方案
  • 【BGP协议实战解析】——从报文交互到路径选择的深度剖析
  • STM32L476 Nucleo开发板入门与低功耗应用实践
  • 如何快速配置Wand增强工具:完整功能扩展指南
  • PCB表面处理工艺:沉金与镀金技术对比与应用