我在实际开发里见过太多人把 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 自定义 Hook:React 逻辑复用的正解
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 UseFetchResult<T> { data: T | null; loading: boolean; error: Error | null; refresh: () => void; } function useFetch<T>( url: string, options?: RequestInit, deps: unknown[] = [] ): UseFetchResult<T> { const [data, setData] = useState<T | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<Error | 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 Promise<T>; }) .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 } = useFetch<User[]>('/api/users');这样 data 就是 User[] 类型,data.map 里能拿到完整的属性提示,再也不用 as any 去强转。泛型的作用是把类型参数化,让一个 Hook 在不同业务场景下推导出不同的数据类型,这刚好匹配 useFetch 的通用性定位。在实际编码时,我一般建议把泛型默认值设置为 unknown,避免有人不传导致隐式 any。
3.4 手动触发与刷新:别总让请求自动跑
自动请求是常态,但有些场景需要手动控制。比如搜索页面,用户在输入框里敲字,不希望每敲一个字母就发一次请求(防抖是另一件事,后面会有介绍),而是按下搜索按钮才发。再比如表单提交后要重新拉取列表数据。
我上面的 TypeScript 版本里已经加了一个 refreshIndex 的状态,当 refreshIndex 变化时,effect 会重新执行,从而重新发请求。refresh 函数暴露出去后,调用方在任意事件处理器里调用 refresh() 即可触发重新请求。
还有一种场景是延迟到某个动作才发请求,这种情况下可以把 url 设计成可空:
function useFetch<T>(url: string | null, options?: RequestInit, deps: unknown[] = []) { const [data, setData] = useState<T | 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 Query
4.1 React Query、SWR 与自封装 Hook 的取舍
写到这里,肯定会有人问:既然有 React Query 和 SWR 这种成熟的数据请求库,为什么还要自己封装 useFetch?这个问题我在团队里也被问过,我当时给出的是非常实际的建议。
如果项目里只有少数几个页面需要请求接口,用自封装 useFetch 完全没有问题,代码量小、心智负担轻、没有额外依赖。如果是一个中大型项目,涉及缓存、失败重试、分页、滚动加载、状态同步这些复杂场景,React Query 或 SWR 能帮你节省大量时间,它们有完整的文档、活跃的社区和大量经过验证的方案。
但自己封装有一个额外的价值,就是能帮助你理解数据请求的底层逻辑。这不是一句空洞的话,我在面试候选人的时候,凡是能清楚解释 useEffect + AbortController + 竞态处理原理的人,我对他的 React 水平会更有信心。
4.2 什么场景该用什么方案
我整理了一个对比表格,方便你判断自己项目该选哪条路:
| 维度 | 自封装 useFetch | React Query | SWR |
|---|---|---|---|
| 依赖体积 | 无额外依赖 | 中等 | 较小 |
| 缓存能力 | 无,需要自己实现 | 强,自带缓存与失效策略 | 强 |
| 重试机制 | 无,需要自己实现 | 支持 | 支持 |
| 分页/无限加载 | 无,需要自己实现 | 支持 | 支持 |
| 学习成本 | 极低 | 中等 | 较低 |
| 适合场景 | 中小项目、教学项目、面试演示 | 中大型项目、数据密集型应用 | 同左 |
这个表想表达的核心观点是:没有银弹。自封装适合轻量场景,React Query 适合重场景。但反过来说,就算你在生产环境用了 React Query,也建议把 useFetch 的封装原理搞明白,因为面试题里面它出现的频率一点都不比 Redux 低。
4.3 自封装 useFetch 的生态进阶玩法
如果你不想引入完整的 React Query,但又想解决缓存和请求去重的问题,可以在自封装 be based 上做一层轻量缓存。举个例子,用一个 Map 保存已经请求成功的 url 和响应数据,下次请求同一个 url 时直接从缓存返回:
const cache = new Map<string, unknown>(); async function fetchWithCache<T>(url: string): Promise<T> { 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/error,useEffect 执行请求,每次 url 变化时重新执行 effect,cleanup 函数里处理竞态或取消请求。
第三,主动提一些改进点:比如 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 里加一个 setTimeout:
const timeout = setTimeout(() => controller.abort(), 10000); return () => { clearTimeout(timeout); ignore = true; controller.abort(); };这样超时后请求被 abort,catch 里捕获到 AbortError,但你希望展示“请求超时”而不是“请求被取消”,可以根据业务需求在 abort 前设置一个标记,catch 里判断这个标记来区分是用户取消还是超时取消。这个小技巧也可以作为面试中的加分点。
使用 useFetch 的过程中,我的体会是:自定义 Hook 的设计最重要的是把“变化的部分”和“不变的部分”分开。请求的生命周期、状态管理、错误处理和卸载清理这些骨架逻辑是不变的,URL、参数、依赖项和数据处理逻辑是变化的。把不变的写成通用代码,把变化的通过参数和泛型暴露出去,这个思路不仅适用于 useFetch,几乎所有自定义 Hook 的设计都可以沿用这套思维。如果你正在学习 React,或者正在梳理面试重点,我建议你亲自动手把 useFetch 从基础版改到进阶版,然后在真实项目里用它替换掉三五个重复的请求逻辑,感受一次代码被删减的爽快感。做过一次之后,你对 React 自定义 Hook 的理解会完全不一样。