
说实话干前端这么多年React 项目里我遇到最高频的重复劳动不是组件封装也不是路由配置而是数据请求这一块。每开一个新页面几乎都要写一遍 loading、error、data 的三件套再处理一下组件卸载之后的异步回调稍微不注意还会报错。后来我干脆把这段逻辑抽成了一个自定义 Hook也就是useFetch。这篇文章就把我实际在项目里反复打磨出来的版本拆开讲清楚包括实现思路、竞态处理、SSR 适配以及那些你在文档里翻不到但实战一定会踩的坑。1. useFetch 到底是什么为什么我不直接上现成库1.1 从手动请求的痛点说起很多同学刚开始写 React 的时候拿数据的方式都是 useEffect 里直接 fetch比如这样const [data, setData] useState(null); const [loading, setLoading] useState(true); const [error, setError] useState(null); useEffect(() { fetch(/api/user) .then((res) res.json()) .then((res) { setData(res); setLoading(false); }) .catch((err) { setError(err); setLoading(false); }); }, []);这段代码看着简单但你细想几个问题接口多了之后每个组件里都复制一遍这种逻辑改一个 loading 状态的处理方式可能要全局替换组件卸载之后请求才返回setState 会直接报 React 的 warning如果这个请求依赖的 id 变了老请求返回的结果可能比新请求晚最后覆盖成脏数据再加一个“手动刷新”需求你又要为了一个是否重新请求的变量去调整第二个参数。useFetch要解决的就是把这一整套状态管理和副作用逻辑收拢到一个 Hook 里让业务组件只关心“我要请求什么数据、拿到之后怎么展示”而不用关心“请求怎么发、loading 怎么管、卸载了怎么办”。1.2 第三方请求库那么多为什么还要自己写现在社区里像 swr、react-query、ahooks 的 useRequest 都做得非常成熟我也在正式项目里用过。它们提供了缓存、重试、焦点重新请求这些开箱即用的能力应对中大型项目确实省心。但自己动手写一个 useFetch 依然有不可替代的价值。一方面很多公司内部的老项目根本没法轻易引一个新的请求库特别是依赖 React 版本比较旧、构建链路过重的时候一个轻量的自定义 Hook 是侵入性最低的改造方案。另一方面面试的时候问 React Hooks十次有五次会聊到自定义 Hook 封装如果你能直接把 useFetch 的实现细节说出来包括竞态处理、AbortController 的用法、闭包陷阱的规避方式面试官基本会认定你是真的在项目里写过而不是只背了文档。抛开面试不谈自己封装一遍也能真正理解请求状态管理的本质之后再看 swr 的源码思路会清晰很多。2. 一步步手写 useFetch从能用版本到靠谱版本2.1 最小可用版本先让请求跑起来我们先不追求功能齐全只解决最核心的痛点把 loading、error、data 统一管理起来。这个版本的核心逻辑是让函数接收一个请求函数和依赖数组然后在 effect 中执行请求。import { useEffect, useState } from react; function useFetch(fetcher, deps []) { const [data, setData] useState(null); const [loading, setLoading] useState(true); const [error, setError] useState(null); useEffect(() { let active true; setLoading(true); fetcher() .then((res) { if (active) { setData(res); setError(null); } }) .catch((err) { if (active) { setError(err); setData(null); } }) .finally(() { if (active) { setLoading(false); } }); return () { active false; }; }, deps); return { data, loading, error }; }这里我特意用了一个active变量而不是直接判断组件是否卸载这个细节很关键。它的作用是在 effect 被清理的时候标记请求结果已失效之后即使 fetch 返回了也不会再去调用 setState。这个版本已经能覆盖大部分基础页面需求点击进入页面、拿到数据、展示来源非常直观。2.2 支持轮询和手动触发让 Hook 更灵活实际项目里只挂在 useEffect 里自动发一次请求是不够的我们经常需要手动刷新、轮询或者等某个操作之后再去请求数据。我给 Hook 增加一个run方法并把请求函数改成可选的参数封装这样既能自动请求也能手动控制import { useCallback, useEffect, useRef, useState } from react; function useFetch(fetcher, { immediate true, pollingInterval, deps [] } {}) { const [data, setData] useState(null); const [loading, setLoading] useState(false); const [error, setError] useState(null); const fetcherRef useRef(fetcher); const timerRef useRef(null); useEffect(() { fetcherRef.current fetcher; }, [fetcher]); const run useCallback(async () { setLoading(true); try { const res await fetcherRef.current(); setData(res); setError(null); } catch (err) { setError(err); setData(null); } finally { setLoading(false); } }, []); const clearTimer useCallback(() { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current null; } }, []); useEffect(() { if (immediate) { run(); } if (pollingInterval) { timerRef.current setInterval(run, pollingInterval); } return () { clearTimer(); }; }, [immediate, pollingInterval, deps]); return { data, loading, error, run, cancel: clearTimer }; }这个版本就已经接近我们日常项目里能用的水平了。run是通过 useCallback 固定引用即使父组件重新渲染也不会导致 effect 被反复触发。轮询场景下用setInterval驱动组件卸载时通过clearTimer清理定时器避免内存泄漏。2.3 引入 AbortController 解决竞态和请求取消上面的版本虽然能挡住组件卸载后的 setState但请求本身还在继续占用了网络和计算资源。更可怕的是请求竞态问题比如分页查询用户快速点击第 1 页、第 2 页、第 3 页三个请求发出去了。如果第 3 页的响应先到第 1 页的响应后到最终页面显示的是第 1 页的数据但当前页码却停在 3这就是经典的竞态 bug。解决方案是用 AbortController 把上一次没有完成的请求取消掉让结果自然丢失从源头避免覆盖function useFetch(fetcher, { immediate true, pollingInterval, deps [] } {}) { const [data, setData] useState(null); const [loading, setLoading] useState(false); const [error, setError] useState(null); const abortRef useRef(null); const fetcherRef useRef(fetcher); useEffect(() { fetcherRef.current fetcher; }, [fetcher]); const cancel useCallback(() { if (abortRef.current) { abortRef.current.abort(); abortRef.current null; } }, []); const run useCallback(async () { // 取消上次未完成的请求 cancel(); const controller new AbortController(); abortRef.current controller; setLoading(true); try { const res await fetcherRef.current(controller.signal); setData(res); setError(null); } catch (err) { // 忽略主动取消造成的错误 if (err.name ! AbortError) { setError(err); setData(null); } } finally { setLoading(false); abortRef.current null; } }, [cancel]); return { data, loading, error, run, cancel }; }需要注意的是fetcher必须把signal传给底层的fetch(url, { signal })这样才能真正中断请求。如果封装了 axios也可以通过axios.get(url, { signal })传入。这一版对竞态问题的处理效果非常明显我在实际测试中快速切换筛选条件页面上再也没出现“旧数据覆盖新数据”的情况。2.4 再往上走加缓存和依赖变化感知到了这步useFetch 已经能应付绝大多数页面场景了。如果还想更进一步可以给请求加上一层弱缓存避免同样的参数来回打接口。这里我用Map作为简单的缓存容器key 由 URL 或请求参数序列化得到const cacheMap new Map(); function useFetch(key, fetcher, { cacheTime 5 * 60 * 1000 } {}) { const [data, setData] useState(() cacheMap.get(key)?.data || null); useEffect(() { const cached cacheMap.get(key); if (cached Date.now() - cached.time cacheTime) { setData(cached.data); return; } run(); }, [key]); }不过这个版本的缓存策略相对简单粗暴没有处理并发请求合并。如果两个组件同时请求同一个 key会发两次请求。要合并并发需要把“进行中的请求 Promise”也放进缓存里这块复杂度就上来了。考虑到真正的项目如果对缓存要求很高直接上 react-query 会是更省心的选择。但自己写到这里你对 Hook 的掌控力是完全不一样的。3. 关键机制深度拆解为什么 useFetch 要这样设计3.1 为什么用 useRef 保存最新的 fetcher在 useFetch 的实现里我用了fetcherRef来保存每次渲染传入的请求函数。为什么不用 useEffect 的依赖去直接监听 fetcher因为请求函数在绝大多数情况下都是匿名的比如const { data } useFetch(() api.getUser(id), [id]);每次渲染这个箭头函数都是新引用。如果你把 fetcher 直接放进 useEffect 的依赖数组里会导致 effect 在每一次渲染后都重新执行请求被无限触发。用 ref 保存最新值就绕开了引用比较的问题只关注真正影响数据的依赖项比如 id。这背后的逻辑其实和 React 官网推荐的“把需要变动的值放进 ref而不是放进依赖”一脉相承。我的经验是凡是遇到“函数引用不稳定但又不想让 effect 重复执行”的情况第一反应就应该是 useRef。3.2 AbortSignal 的传递链路到底有多重要很多初学 useFetch 的人写了 AbortController但发现接口并没有被真正取消原因就是 signal 没有传到 fetch 层。AbortController 只是创建了一个控制信号你要么把它交给 fetch要么通过监听 abort 事件手动中断 axios 请求否则调abort()只是干瞪眼。// fetch 正确传法 const res await fetch(url, { signal: controller.signal }); // axios 正确传法 const res await axios.get(url, { signal: controller.signal });其实你在 catch 里看到AbortError并不能说明服务端不处理请求了它只代表浏览器端不再等待响应。在 React 组件场景下这已经足够防止没必要的 setState 和内存泄漏了。如果后端接口是比较重的查询任务建议后端也配合做中断检测这属于服务端优化范畴不在我们 Hook 讨论范围内。3.3 loading 状态为什么要拆出来而不是用 data null 判断有些简化的 useFetch 实现不返回 loading用data null来判断是否在加载。这在翻页、刷新场景下会出现问题数据已存在但正在刷新页面如果用 data null 判断就会闪 loading 状态视觉上很突兀。所以我把 loading 单独拎出来并且在请求开始时就置为 true请求结束时置为 false让业务组件自己决定“初始加载”和“刷新加载”分别渲染什么。这里我还喜欢加一个isFirstLoading的字段来区分是不是首次加载const isFirstLoading loading data null;这一个小小的状态区分确实能解决很多 UI 交互问题比如首次加载显示骨架屏后续刷新只在原数据上覆盖更新不让页面跳来跳去。4. 在真实项目中用 useFetch 的注意事项4.1 React 18 并发模式useFetch 会被影响吗随着 React 18 并发渲染的普及很多人开始担心自定义 Hook 里的状态更新是否会被打断。其实 useFetch 的设计天然就是兼容并发模式的。因为我们的状态更新发生在异步回调里React 会对这些更新进行批处理不会出现老版本那种“同步 setState 导致渲染多次”的问题。真正需要小心的是在事件处理里去调用run()的场景。React 18 里即使你在 Promise 回调里 setState也会有自动批处理。这意味着 loading 的多次变化会在一次渲染中合并。如果你依赖 loading 的中间态去做某些同步逻辑建议使用 useEffect 去监听变化不要把 have-to-have 逻辑直接写在 setLoading 后面。4.2 SSR 场景下如何做数据预获取热词里有人提到“react ssr 数据预获取方案”这其实是 useFetch 的另一个应用场景。在 SSR 中我们不希望客户端发一次请求、服务端又发一次请求而是希望在服务端就把数据准备好客户端直接用。这里提供一个简单的思路useFetch 的 fetcher 如果返回一个带有preload标记的数据源服务端可以通过renderToString之前的 Promise.all 把请求全部执行完然后把结果挂到全局变量或流式传输给客户端。客户端在useFetch初始化的时候优先取全局变量中对应的数据不走 fetch。// 服务端 const promises routes.map((route) preloadRouteData(route)); await Promise.all(promises); // 客户端 useFetch 初始化 const initialData window.__INITIAL_DATA__?.[key]; const [data, setData] useState(initialData || null);这个方案的好处是不用引入 redux-thunk 之类的额外架构缺点是需要自己维护服务端和客户端的数据对应关系。如果你的项目有 Next.js建议直接使用它的getServerSideProps或者 React 18 自带的use处理数据预取但用 useFetch 理解整个 SSR 数据流的基本原理是有帮助的。4.3 TypeScript 泛型让 useFetch 拥有完整类型推导用 TypeScript 写 useFetch一个缺失的泛型会导致所有页面都要写类型断言很痛苦。完整的泛型设计应该是这样function useFetchTData unknown( fetcher: (signal?: AbortSignal) PromiseTData, options?: UseFetchOptions ): UseFetchResultTData { // ... }关键的是run方法的返回值也是PromiseTData这样业务代码可以拿到请求到的数据做后续判断而不只是通过状态访问。我在实际项目里还会定义UseFetchResult接口而不是直接返回一个对象字面量这样通过 IDE 自动提示就能看到所有字段新接手项目的同事上手成本低很多。5. 常见问题排查实录5.1 现象接口重复请求发出去两次甚至 N 次排查思路先从 useEffect 依赖数组查起。最常见的原因是依赖数组里放了对象或函数每次渲染引用都变导致 effect 重新执行。另一个隐藏很深的原因是 StrictModeReact 18 开发模式下会故意执行两次 effect 来暴露副作用问题。如果你在开发环境看到请求发两次先确认是不是 StrictMode生产环境不受影响。再一个原因就是组件没有被正确 memo父组件一直重新渲染子组件里的 useFetch 因为 fetcher 用的是 ref 保存所以不受影响但如果你把 useFetch 直接写在了父组件里那父组件每次渲染都会重新跑 effect。解决办法是把数据请求下沉到子组件或者用 useCallback 把 fetcher 包装起来。5.2 现象页面切换回来之后数据还是旧的这个问题多半是 useFetch 没有和参数变化联动。比如同一个组件切换不同的 iduseEffect 依赖里没有 id导致组件复用了旧数据。解决方式很简单把 id 放进 deps 里并在请求开始前把旧数据清掉或者在请求返回前给 data 置 nulluseEffect(() { setData(null); run(); }, [id]);如果还想让体验更好可以在请求期间保留旧数据但需要额外加一个isFetching字段来告诉 UI“现在正在切换内容”。我看到很多人直接用 loading 判断导致切换的时候页面闪一下 loading交互感受很生硬。5.3 现象页面卡顿、内存占用持续上涨如果 useFetch 有轮询功能而且组件在页面里反复被销毁和重建一定要记得在清理函数里清除定时器和 abort。还有一点容易被忽略如果你在 fetch 的 finally 里做了 setState而组件已经卸载了这就等于白白触发了一次 React 的警告甚至老版本 React 里可能引起内存泄漏。我们的实现里通过 active 变量和 abort 双重保障基本可以杜绝这类问题。另外轮询场景下如果上一次请求还没结束、定时器又发起了新请求会导致请求堆积。稳妥的做法是在run开头判断loadingRef.current如果已经有一个请求在途就直接跳过这次执行const loadingRef useRef(false); const run useCallback(async () { if (loadingRef.current) return; loadingRef.current true; setLoading(true); // ... finally { loadingRef.current false; setLoading(false); } }, []);这个细节我建议所有做轮询的同学都加上实测下来请求堆积的问题立刻消失。6. 让 useFetch 更适合团队协作的几个建议6.1 约定请求函数的职责边界useFetch 虽然封装了状态和请求流程但业务组件还是需要写具体的 fetcher。如果团队里各写各的很可能出现一半人用 fetch 一半人用 axios处理错误的方式也千奇百怪。所以我在团队里会约定统一的 fetcher 封装把 api.ts 统一导出带类型的请求方法useFetch 只负责管理状态不负责具体的数据转换。// api.ts export const getUser (id: number, signal?: AbortSignal) request.getAPI.User(/user/${id}, { signal });这样 useFetch 的 fetcher 参数完全由 api 层提供业务代码非常干净。6.2 关于 useFetch 和 React Query 的选型判断最后说个人的真实体会。useFetch 适合中小型项目、请求逻辑不复杂的场景或者作为理解原理的学习项目。如果你的项目需要完善的服务端状态管理比如缓存失效、窗口聚焦重新请求、无限加载、乐观更新直接使用 react-query 这类库能省非常多事没必要在业务里硬造一个不成熟的轮子。但无论选哪条路useFetch 的这几十行代码背后涉及的思维训练都是有价值的状态拆分、竞态控制、副作用清理、引用稳定性、类型推导。这些能力不是用库就能替代的而是 React 开发的基本功。我在实际使用中最深的体会是useFetch 永远不可能替你解决所有数据层的问题它能做的是把那些重复度极高的模板代码收敛掉把真正容易出错的异步边界替你兜住。这个度值得你自己拿捏。