用自定义 Hook 封装数据请求:统一 loading、error 与竞态处理
用自定义 Hook 封装数据请求:统一 loading、error 与竞态处理
在 React 组件里手写fetch+useState+useEffect,第一次写起来很爽,写到第五个组件你就会发现:每个组件里都躺着一坨一模一样的loading、error、data三件套,而且十有八九都藏着一个竞态 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>; }这段代码有三个问题,前两个是「烦」,第三个是「错」:
- 样板重复:换个接口,这十几行几乎原样再抄一遍。
- error 不会重置:
userId变了重新请求,如果第一次报过错,error还留着旧值,新请求成功了页面可能还在显示错误。 - 竞态(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。
