
我在实际开发里见过太多人把 useEffect 里的 fetch 写了一遍又一遍一个 loading 状态、一个 data 状态、一个 error 状态偶尔还漏掉取消请求的处理。所以当项目里需要频繁请求接口时我第一反应就是封装一个 useFetch。这个自定义 Hook 能把数据请求的逻辑统一收拢让组件代码变得极其干净同时也避免了重复逻辑带来的隐患。今天这篇文章就围绕 useFetch 从零到进阶讲透结合我在项目里踩过的坑和面试中被追问过的细节一次性把这块内容盘明白。如果你想搞清楚 React 自定义 Hook 到底怎么设计、数据请求怎么封装才不算过度设计、以及面试中被问到这类题目怎么回答才显得有深度这篇文章就是给你准备的。1. useFetch 是什么一篇文章拆掉请求模板代码1.1 从重复代码说起先看一段最常见的组件请求代码几乎每个 React 项目里都有这种影子function UserProfile({ userId }) { const [user, setUser] useState(null); const [loading, setLoading] useState(true); const [error, setError] useState(null); useEffect(() { let ignore false; setLoading(true); fetch(/api/users/${userId}) .then((res) res.json()) .then((data) { if (!ignore) { setUser(data); setLoading(false); } }) .catch((err) { if (!ignore) { setError(err); setLoading(false); } }); return () { ignore true; }; }, [userId]); if (loading) return div加载中.../div; if (error) return div加载失败请重试/div; return div{user?.name}/div; }这段逻辑没有问题但它有一个明显的短板如果项目里有十几个页面都要拉取列表、详情、下拉选项每个页面都复制粘贴这么一套代码量膨胀不说还很容易出现几个页面之间的写法不一致。比如有人忘了写 ignore 标志有人忘了 catch有人 loading 初始值都写错了。这不是技术难点这是纯粹的重复劳动带来的维护成本。useFetch 要做的事情就是把这一整套通用的请求逻辑抽出来组件里只关心数据、加载态和错误不关心请求细节。1.2 自定义 HookReact 逻辑复用的正解React 在很早之前有 mixin后来有 render props 和高阶组件但这些方案都有各自的毛病。mixin 的命名冲突、高阶组件的 props 层层透传都会让代码变得越来越拧巴。自定义 Hook 的出现本质上就是让开发者把有状态逻辑提取出来用普通的函数形式复用并且可以在里面调用 React 的内置 Hook。useFetch 就是最典型、最容易上手的自定义 Hook 入门案例。打个比方组件如果是一道菜那自定义 Hook 就是提前配好的酱料包。平时做菜你不用从磨香料开始拿出一包酱料、按步骤一炒就有那个味道。useFetch 就是“数据请求”这道工序的酱料包把繁琐的处理细节都封装好调用方只需要关心数据和状态。1.3 useFetch 要解决的核心问题我把 useFetch 要解决的问题归纳了一下基本都是真实项目里的高频诉求统一管理 data、loading、error 状态避免组件里面堆 useState。当依赖参数比如 userId变化时自动重新发起请求。处理竞态问题快速切换参数时旧请求的返回值不能覆盖新请求的数据。支持手动触发请求比如点击按钮后重新拉取。组件卸载后不再触发 setState避免内存泄漏。最好支持 TypeScript让数据有类型提示。这些问题如果分散在每个组件里去解决很容易顾此失彼。集中到 useFetch 里就能一次解决、处处复用。2. 手写基础版 useFetch最小可用实现2.1 第一版代码先说结论一个最基础的 useFetch核心代码其实没几行。我先写一版最容易理解的适合刚接触自定义 Hook 的读者。import { useState, useEffect } from react; function useFetch(url, options) { const [data, setData] useState(null); const [loading, setLoading] useState(true); const [error, setError] useState(null); useEffect(() { let ignore false; setLoading(true); setError(null); fetch(url, options) .then((res) { if (!res.ok) { throw new Error(HTTP error! status: ${res.status}); } return res.json(); }) .then((json) { if (!ignore) { setData(json); } }) .catch((err) { if (!ignore) { setError(err); } }) .finally(() { if (!ignore) { setLoading(false); } }); return () { ignore true; }; }, [url]); return { data, loading, error }; }看到没有组件里所有请求逻辑都被移到 Hook 里了调用方只需要写一行const { data, loading, error } useFetch(/api/users/${userId});这就是最直观的价值组件关心的是渲染请求交给 Hook。2.2 核心设计点拆解这一版看似简单但有几个细节不是随手写的我来逐个解释为什么。第一个是 ignore 标志。useEffect 的 cleanup 函数里把 ignore 置为 true它的作用是防止组件卸载后或者 URL 变化后旧请求的响应回来执行 setState。React 18 虽然对 unmounted 组件的 setState 不再报警告了但逻辑上这个标志还是必要的。它还能解决一个问题快速切换 userId 时第一次请求和第二次请求几乎同时发出如果第一次响应回来得晚就会把第二次的数据覆盖掉。ignore 在依赖变化后的 cleanup 里置为 true旧的请求回来就不会再执行 setData。第二个是 finally 里关闭 loading。放在 finally 里不管成功还是失败loading 都会被置为 false这比在 then 和 catch 里各写一遍 setLoading 更不容易遗漏。第三个是 res.ok 检查。只判断 res.json() 能解析是不够的HTTP 404、500 也会返回一个可以被 json 解析的响应但业务上它是错的。所以要先判断 res.ok不是 2xx 状态码就直接抛错让后面的 catch 统一处理。2.3 基础版存在的问题基础版能跑但还远达不到实战标准。我梳理一下它的问题options 没有放进依赖数组。如果调用方传入一个每次渲染都新建的对象放进去会导致无限循环不放进去则 options 更新后不会重新发请求。这个需要调用方手动用 useMemo/useRef 处理。没有处理依赖参数变化时原数据是否要保留的问题。有些场景希望加载新数据时旧数据还能显示有些场景希望清空后重新加载基础版做不到。没有手动触发能力。比如用户点击搜索按钮才发请求这种场景不能用自动请求的 Hook。没有取消请求只能靠 ignore 标志忽略 setState但网络请求本身还在飞。这个问题在慢网速和频繁操作时尤其明显。我把基础版作为教学的起点但实际项目里我建议至少做到下一节的进阶版本才能真正省心。3. 进阶实现竞态、取消与依赖追踪3.1 用 AbortController 真正取消请求前面说到 ignore 只是不让 setState但请求本身没有取消。这在某些场景下会造成资源浪费比如用户快速翻页往前翻了五页前四页的请求如果都没取消它们会白白消耗网络带宽有的慢请求还会在返回时触发一些无意义的逻辑。现代浏览器提供了 AbortController可以用来真正取消 fetch 请求。改造后的代码如下import { useState, useEffect } from react; function useFetch(url, options) { const [data, setData] useState(null); const [loading, setLoading] useState(true); const [error, setError] useState(null); useEffect(() { const controller new AbortController(); const { signal } controller; let ignore false; setLoading(true); setError(null); fetch(url, { ...options, signal }) .then((res) { if (!res.ok) { throw new Error(HTTP error! status: ${res.status}); } return res.json(); }) .then((json) { if (!ignore) { setData(json); } }) .catch((err) { if (err.name AbortError) { return; } if (!ignore) { setError(err); } }) .finally(() { if (!ignore) { setLoading(false); } }); return () { ignore true; controller.abort(); }; }, [url]); return { data, loading, error }; }这里多了一个非常关键的处理catch 里要判断 AbortError。因为当请求被主动 abort 时fetch 会抛出一个名为 AbortError 的错误这个不应该被当成业务错误展示给用户所以要提前 return 掉。这里也要提醒一句AbortController 让请求可取消的同时也带来新的使用约束如果同一个请求的响应需要被缓存在全局状态里取消逻辑要和缓存逻辑配合好不然会出现请求被取消但缓存里又需要数据的尴尬情况。这个属于使用场景层面的取舍不是 Hook 本身的缺陷。3.2 支持依赖追踪url 之外还可以传依赖数组很多时候请求的参数不只是 URL。比如列表页有 page 和 pageSize详情页有 userId 和 type这些参数都拼到 URL 里当然可行但不好维护。更好的设计是让 useFetch 接受一个 deps 数组当 deps 里的值变化时自动重新请求。import { useState, useEffect } from react; function useFetch(url, options, deps []) { const [data, setData] useState(null); const [loading, setLoading] useState(true); const [error, setError] useState(null); useEffect(() { const controller new AbortController(); const { signal } controller; let ignore false; setLoading(true); setError(null); fetch(url, { ...options, signal }) .then((res) { if (!res.ok) { throw new Error(HTTP error! status: ${res.status}); } return res.json(); }) .then((json) { if (!ignore) { setData(json); } }) .catch((err) { if (err.name AbortError) { return; } if (!ignore) { setError(err); } }) .finally(() { if (!ignore) { setLoading(false); } }); return () { ignore true; controller.abort(); }; }, [url, ...deps]); return { data, loading, error }; }调用方式变成了const { data, loading, error } useFetch( /api/users, null, [userId, page, pageSize] );这里有个对 useEffect 依赖数组的理解问题。把 deps 展开到 useEffect 的依赖数组里依赖数组里的任何一项变化都会触发 effect 的重新执行。如果调用方在生产代码里给 deps 传了一个每次渲染都新建的对象那就会导致无限请求这个责任部分在调用方但 useFetch 的文档和注释里要写清楚否则坑后会踩得很难看。3.3 用 TypeScript 泛型让数据有类型提示JavaScript 版的 useFetch 用着顺手但对一个 TypeScript 项目来说data 是 unknown 还是 any 会直接影响工程质量。TypeScript 泛型的写法也很简单就是在函数上声明一个泛型参数然后传给 useState。import { useState, useEffect } from react; interface UseFetchResultT { data: T | null; loading: boolean; error: Error | null; refresh: () void; } function useFetchT( url: string, options?: RequestInit, deps: unknown[] [] ): UseFetchResultT { const [data, setData] useStateT | null(null); const [loading, setLoading] useState(true); const [error, setError] useStateError | null(null); const [refreshIndex, setRefreshIndex] useState(0); useEffect(() { const controller new AbortController(); const { signal } controller; let ignore false; setLoading(true); setError(null); fetch(url, { ...options, signal }) .then((res) { if (!res.ok) { throw new Error(HTTP error! status: ${res.status}); } return res.json() as PromiseT; }) .then((json) { if (!ignore) { setData(json); } }) .catch((err) { if (err.name AbortError) { return; } if (!ignore) { setError(err); } }) .finally(() { if (!ignore) { setLoading(false); } }); return () { ignore true; controller.abort(); }; }, [url, refreshIndex, ...deps]); const refresh () setRefreshIndex((i) i 1); return { data, loading, error, refresh }; }使用时传入泛型参数就能享受类型推导interface User { id: number; name: string; email: string; } const { data, loading, error, refresh } useFetchUser[](/api/users);这样 data 就是 User[] 类型data.map 里能拿到完整的属性提示再也不用 as any 去强转。泛型的作用是把类型参数化让一个 Hook 在不同业务场景下推导出不同的数据类型这刚好匹配 useFetch 的通用性定位。在实际编码时我一般建议把泛型默认值设置为 unknown避免有人不传导致隐式 any。3.4 手动触发与刷新别总让请求自动跑自动请求是常态但有些场景需要手动控制。比如搜索页面用户在输入框里敲字不希望每敲一个字母就发一次请求防抖是另一件事后面会有介绍而是按下搜索按钮才发。再比如表单提交后要重新拉取列表数据。我上面的 TypeScript 版本里已经加了一个 refreshIndex 的状态当 refreshIndex 变化时effect 会重新执行从而重新发请求。refresh 函数暴露出去后调用方在任意事件处理器里调用 refresh() 即可触发重新请求。还有一种场景是延迟到某个动作才发请求这种情况下可以把 url 设计成可空function useFetchT(url: string | null, options?: RequestInit, deps: unknown[] []) { const [data, setData] useStateT | null(null); const [loading, setLoading] useState(false); useEffect(() { if (!url) return; // ...请求逻辑 }, [url, ...deps]); // ... }url 为 null 时不发请求等用户触发了某个操作把 url 从 null 变成实际地址请求才真正开始。这种设计在“点击编辑弹窗加载详情”这类场景很实用。3.5 完整版 useFetch 的设计思路把上面几点组合起来一个有基本实战能力的 useFetch 包含这些能力url 为空时不发请求。依赖参数变化自动重新请求。AbortController 取消未完成的请求。手动触发 refresh。TypeScript 泛型支持。防抖能力可选根据项目需要。缓存能力可选复杂项目建议直接用 React Query。设计思路的核心是职责单一。Hook 只负责请求流程的状态管理业务数据处理逻辑交给调用方。不要在 Hook 里做数据转换、业务判断否则会越写越臃肿。4. 为什么推荐自己封装而不是直接用 React Query4.1 React Query、SWR 与自封装 Hook 的取舍写到这里肯定会有人问既然有 React Query 和 SWR 这种成熟的数据请求库为什么还要自己封装 useFetch这个问题我在团队里也被问过我当时给出的是非常实际的建议。如果项目里只有少数几个页面需要请求接口用自封装 useFetch 完全没有问题代码量小、心智负担轻、没有额外依赖。如果是一个中大型项目涉及缓存、失败重试、分页、滚动加载、状态同步这些复杂场景React Query 或 SWR 能帮你节省大量时间它们有完整的文档、活跃的社区和大量经过验证的方案。但自己封装有一个额外的价值就是能帮助你理解数据请求的底层逻辑。这不是一句空洞的话我在面试候选人的时候凡是能清楚解释 useEffect AbortController 竞态处理原理的人我对他的 React 水平会更有信心。4.2 什么场景该用什么方案我整理了一个对比表格方便你判断自己项目该选哪条路维度自封装 useFetchReact QuerySWR依赖体积无额外依赖中等较小缓存能力无需要自己实现强自带缓存与失效策略强重试机制无需要自己实现支持支持分页/无限加载无需要自己实现支持支持学习成本极低中等较低适合场景中小项目、教学项目、面试演示中大型项目、数据密集型应用同左这个表想表达的核心观点是没有银弹。自封装适合轻量场景React Query 适合重场景。但反过来说就算你在生产环境用了 React Query也建议把 useFetch 的封装原理搞明白因为面试题里面它出现的频率一点都不比 Redux 低。4.3 自封装 useFetch 的生态进阶玩法如果你不想引入完整的 React Query但又想解决缓存和请求去重的问题可以在自封装 be based 上做一层轻量缓存。举个例子用一个 Map 保存已经请求成功的 url 和响应数据下次请求同一个 url 时直接从缓存返回const cache new Mapstring, unknown(); async function fetchWithCacheT(url: string): PromiseT { if (cache.has(url)) { return cache.get(url) as T; } const res await fetch(url); const data await res.json(); cache.set(url, data); return data as T; }这个简单方案能解决重复请求的问题但会引入缓存更新策略的复杂度比如编辑数据后要主动清缓存。所以我建议这个思路只在确实需要轻量缓存的场景使用否则别引入不必要的复杂度。5. useFetch 在面试题里怎么答5.1 面试官问 useFetch到底在问什么“手写一个 useFetch”是 React 面试中的高频题但这个问题的背后不只是考察你记不记得代码而是考察几个维度的能力对 React 内建 Hook 的理解。useState 和 useEffect 是 React 中最常用的两个 Hook能不能熟练结合它们是基本功。对副作用的处理方法。useEffect 的清理机制是不是理解到位。对竞态条件和异步流程的认识。请求返回顺序不可控如何处理旧数据覆盖新数据的问题。对代码封装能力的判断。重构通读下来的代码结构是否清晰。把这些维度都想清楚回答就能从背代码变成有逻辑地推导设计方案。5.2 我建议的答题思路我面试别人时如果候选人手写 useFetch我希望听到下面这些思考过程第一先用一句话定义 useFetch 的功能一个自定义 Hook用于在函数组件中执行异步请求返回数据、加载状态和错误信息。第二说明核心实现useState 管理 data/loading/erroruseEffect 执行请求每次 url 变化时重新执行 effectcleanup 函数里处理竞态或取消请求。第三主动提一些改进点比如 AbortController 取消请求、依赖数组传参、TypeScript 泛型支持、手动触发刷新。这些也能在答完后引出面试官的加分项。第四如果被问到网络请求缓存、失败重试这些点可以和 React Query/SWR 做对比解释什么时候用自封装 Hook、什么时候引入成熟库。这个思路的好处是把考点一个一个都覆盖到了同时展示了你有真实项目经验而不仅仅是背了一段代码。5.3 常见追问与回答思路在面试场景里面试官通常会针对 useFetch 做一些追问。我总结几个我见过的追问一useEffect 里可以直接写 async 函数吗 回答思路不建议直接写因为 useEffect 的回调函数要求要么不返回值要么返回一个清理函数。async 函数返回的是一个 Promise不符合这个约定。正确的做法是在 effect 内部定义并调用异步函数或者用 promise 链式调用。追问二如果依赖数组里传了对象导致无限请求怎么办 回答思路问题根源在于每次渲染对象引用都变了所以 effect 每次都会执行。解决办法是调用方用 useMemo 缓存对象或者把对象拆成基本类型参数传给 useFetch。这也是为什么很多 useFetch 设计会把 deps 单独用一个数组参数接收。追问三组件卸载后 setState 会不会报错 回答思路React 18 已经移除了这个警告但逻辑上仍然不推荐在卸载后 setState因为会导致内存泄漏。使用 AbortController 取消请求或者 ignore 标志是标准的处理方式。追问四useFetch 和高阶组件做数据请求有什么区别 回答思路自定义 Hook 没有 props 透传问题没有组件树层级嵌套逻辑更直观。这也是现在项目普遍从 HOC 迁移到 Hook 的原因之一。把这些追问都准备到位面试官对候选人的评价通常不会太低。6. 常见问题与排查实录6.1 无限请求循环依赖数组的经典大坑这个问题的典型表现是页面上某个接口一直在刷浏览器 Network 面板里能看到连续不断的请求。原因一般是 useEffect 的依赖数组里传入了引用类型或者传入了每次渲染都会变化的变量。排查思路是三步走第一步加日志打印 effect 执行的时机和依赖值。第二步检查依赖项在渲染时是否每次都重新创建。第三步对引用类型用 useMemo/useCallback 稳定引用或者把基本类型拆出来传参。如果 useFetch 的使用方传的是 deps[myObj]而 myObj 每次渲染都是新对象那 useEffect 每次都认为依赖变了请求就会一直发。我经常给团队的建议是useFetch 内部把 deps 加入依赖数组没错但使用方必须知道自己传进去的东西是不是稳定的引用。6.2 AbortError 被当成业务错误有人在使用 AbortController 后遇到一个奇怪的现象组件从列表页跳到详情页列表页的请求被中断然后页面控制台里冒出一个 unhandled error内容是 AbortError。这个问题的根源就是 catch 里没有判断 err.name AbortError。解决方式在前面代码里已经写过了catch 里单独处理这种错误类型直接 return不进 setError。这个小细节我在面试中也爱问因为它区分了背代码的人和真正写过请求逻辑的人。6.3 请求竞态旧响应覆盖新数据竞态条件是异步请求里最容易踩的坑。举个例子用户在搜索框里输入“react”然后又输入“vue”第一次请求还没返回第二次请求已经发出去了。如果第一次请求返回得比第二次晚页面上显示的就是“react”的搜索结果但输入框里已经变成了“vue”数据和输入状态不一致。处理竞态条件有三个层面的方案方案一ignore 标志。前面代码里的 ignore 就是最轻量的方案依赖变化时 cleanup 触发 ignore true旧请求的响应回来后被忽略。方案二AbortController 取消旧请求。直接中断网络请求效率更高。方案三为请求加序号只有最新请求的响应才被处理。这个方案在非 fetch 场景比如自封装 WebSocket 或 JSONP更常用。正常项目里前两种组合使用就够了第三种适用于更特殊的场景。6.4 一次真实的排查实例之前做一个后台系统时有个同事反馈切换到某个角色的用户时页面偶尔会展示另一个角色的数据。第一次听描述像是权限 bug后来查了很久发现根本原因是两个页面共用了一个全局 store 里的数据字段切换角色后数据请求有竞态慢的请求把快请求的数据覆盖了。我们当时的修复思路就是给 useFetch 加上了 AbortController 取消机制并且在下一次请求发出时直接把上一次请求 abort 掉。这样旧请求不会返回也就不会覆盖新数据。这个案例让我意识到竞态问题在异步渲染过程中普遍存在尤其是在快速切换筛选条件、分页、角色这类场景。useFetch 的封装能力是解决这个问题的一个关键切入点。6.5 常见问题速查表我把使用 useFetch 过程中可能遇到的典型问题整理成一个速查表方便大家排查时对照问题现象可能原因解决方案请求无限循环依赖数组传入了不稳定的引用类型用 useMemo/useCallback 稳定引用或拆成基本类型页面出现 AbortError 错误catch 里没有判断 AbortError判断 err.name AbortError 后直接 return快速切换参数后数据错乱竞态条件旧请求覆盖新数据增加 ignore 标志或用 AbortController 取消旧请求组件卸载后仍打印日志或刷新状态忘记处理 cleanup在 effect 返回的清理函数里做置位依赖参数变化时 loading 闪烁loading 初始值和请求周期设计不合理按业务诉求决定是否在依赖变化时保留旧数据手动刷新按钮无反应refreshIndex 没有真正参与 effect 依赖检查 refresh 函数是否触发了状态变更接口 404 但页面没有报错没有检查 res.ok先判断 res.ok非 2xx 直接抛错6.6 固化和反思useFetch 的边界在哪里讲了这么多 useFetch 的实现和优化最后还是想多说一句useFetch 有它的边界不是所有请求场景都适合用一个通用 Hook 来包。比如文件上传设置了非常复杂的进度回调逻辑比如 WebSocket 长连接又比如某些需要串行请求和依赖先验条件的接口这些场景我都建议单独抽取对应的 Hook 或直接用 React Query 这种更重型的方案。useFetch 最适合的场景是标准的 REST/JSON 请求一进一出、状态清晰、生命周期和组件保持一致。在实际项目里我通常还会给 useFetch 加一个默认参数请求超时时间。用 AbortController 的 setTimeout 包装一下超过 10 秒就自动中断请求并设置错误状态。这个功能对弱网场景特别管用否则用户看到一个无限 loading 的页面会直接放弃。超时实现的思路也不复杂在 useEffect 里加一个 setTimeoutconst timeout setTimeout(() controller.abort(), 10000); return () { clearTimeout(timeout); ignore true; controller.abort(); };这样超时后请求被 abortcatch 里捕获到 AbortError但你希望展示“请求超时”而不是“请求被取消”可以根据业务需求在 abort 前设置一个标记catch 里判断这个标记来区分是用户取消还是超时取消。这个小技巧也可以作为面试中的加分点。使用 useFetch 的过程中我的体会是自定义 Hook 的设计最重要的是把“变化的部分”和“不变的部分”分开。请求的生命周期、状态管理、错误处理和卸载清理这些骨架逻辑是不变的URL、参数、依赖项和数据处理逻辑是变化的。把不变的写成通用代码把变化的通过参数和泛型暴露出去这个思路不仅适用于 useFetch几乎所有自定义 Hook 的设计都可以沿用这套思维。如果你正在学习 React或者正在梳理面试重点我建议你亲自动手把 useFetch 从基础版改到进阶版然后在真实项目里用它替换掉三五个重复的请求逻辑感受一次代码被删减的爽快感。做过一次之后你对 React 自定义 Hook 的理解会完全不一样。