手写React useFetch:从竞态处理到SSR适配的完整指南
2026/9/23 7:14:53 网站建设 项目流程

说实话,干前端这么多年,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 useFetch<TData = unknown>( fetcher: (signal?: AbortSignal) => Promise<TData>, options?: UseFetchOptions ): UseFetchResult<TData> { // ... }

关键的是run方法的返回值也是Promise<TData>,这样业务代码可以拿到请求到的数据做后续判断,而不只是通过状态访问。我在实际项目里还会定义UseFetchResult接口而不是直接返回一个对象字面量,这样通过 IDE 自动提示就能看到所有字段,新接手项目的同事上手成本低很多。

5. 常见问题排查实录

5.1 现象:接口重复请求发出去两次甚至 N 次

排查思路:先从 useEffect 依赖数组查起。最常见的原因是依赖数组里放了对象或函数,每次渲染引用都变,导致 effect 重新执行。另一个隐藏很深的原因是 StrictMode,React 18 开发模式下会故意执行两次 effect 来暴露副作用问题。如果你在开发环境看到请求发两次,先确认是不是 StrictMode,生产环境不受影响。再一个原因就是组件没有被正确 memo,父组件一直重新渲染,子组件里的 useFetch 因为 fetcher 用的是 ref 保存所以不受影响,但如果你把 useFetch 直接写在了父组件里,那父组件每次渲染都会重新跑 effect。解决办法是把数据请求下沉到子组件,或者用 useCallback 把 fetcher 包装起来。

5.2 现象:页面切换回来之后,数据还是旧的

这个问题多半是 useFetch 没有和参数变化联动。比如同一个组件,切换不同的 id,useEffect 依赖里没有 id,导致组件复用了旧数据。解决方式很简单,把 id 放进 deps 里,并在请求开始前把旧数据清掉,或者在请求返回前给 data 置 null:

useEffect(() => { 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.get<API.User>(`/user/${id}`, { signal });

这样 useFetch 的 fetcher 参数完全由 api 层提供,业务代码非常干净。

6.2 关于 useFetch 和 React Query 的选型判断

最后说个人的真实体会。useFetch 适合中小型项目、请求逻辑不复杂的场景,或者作为理解原理的学习项目。如果你的项目需要完善的服务端状态管理,比如缓存失效、窗口聚焦重新请求、无限加载、乐观更新,直接使用 react-query 这类库能省非常多事,没必要在业务里硬造一个不成熟的轮子。

但无论选哪条路,useFetch 的这几十行代码背后涉及的思维训练都是有价值的:状态拆分、竞态控制、副作用清理、引用稳定性、类型推导。这些能力不是用库就能替代的,而是 React 开发的基本功。

我在实际使用中最深的体会是,useFetch 永远不可能替你解决所有数据层的问题,它能做的是把那些重复度极高的模板代码收敛掉,把真正容易出错的异步边界替你兜住。这个度,值得你自己拿捏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询