
一句话:手写请求 Hook 的关键是「状态机建模(
idle/loading/success/error)+ 依赖触发 + 竞态取消(AbortController)+ 卸载清理」;进阶再加「手动触发、重试、防抖、回调」。
一、需求分析
一个「生产可用」的请求 Hook 要解决:
① 状态建模 —— idle / loading / success / error(不要用多个布尔值)
② 依赖触发 —— 依赖变化时自动重新请求(且要处理「参数变化」)
③ 竞态取消 —— 上一次请求要取消(否则旧响应覆盖新数据,见 7.2)
④ 卸载清理 —— 组件卸载时取消请求(避免「在已卸载组件里 setState」)
⑤ 手动触发 —— manual 模式 / 提供 refresh 方法
⑥ 错误处理 —— 区分「取消」与「真实错误」
⑦ 惰性初始化 —— 不要在渲染中发请求
⑧ 可选能力 —— 重试、防抖、成功/失败回调、缓存
二、基础版实现
import { useCallback, useEffect, useRef, useState } from 'react';
type Status = 'idle' | 'loading' | 'success' | 'error';
interface State<T> {
status: Status;
data: T | null;
error: Error | null;
}
/**
* 基础版:自动请求 + 状态机 + 竞态取消 + 卸载清理
*/
export function useFetch<T>(
fetcher: (signal: AbortSignal) => Promise<T>,
deps: unknown[] = []
) {
const [state, setState] = useState<State<T>>({ status: 'idle', data: null, error: null });
// ⭐ 用 ref 保存最新 fetcher(避免把它放进依赖导致死循环)
const fetcherRef = useRef(fetcher);
useEffect(() => {
fetcherRef.current = fetcher;
});
useEffect(() => {
const controller = new AbortController();
setState((prev) => ({ status: 'loading', data: prev.data, error: null })); // ⭐ 保留旧数据
fetcherRef.current(controller.signal)
.then((data) => {
if (controller.signal.aborted) return; // ⭐ 已取消 → 忽略
setState({ status: 'success', data, error: null });
})
.catch((err: Error) => {
if (err.name === 'AbortError') return; // ⭐ 取消 → 静默
setState({ status: 'error', data: null, error: err });
});
return () => controller.abort(); // ⭐ 依赖变化/卸载时取消
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return state;
}
// ✅ 使用
function UserDetail({ id }: { id: string }) {
const { status, data, error } = useFetch(
(signal) => api.getUser(id, { signal }),
[id] // ⭐ id 变化 → 自动重新请求
);
if (status === 'loading' && !data) return <Skeleton />;
if (status === 'error') return <ErrorView error={error!} />;
if (!data) return null;
return <div>{data.name}</div>;
}
⭐ 基础版的五个关键点:
① `fetcherRef` —— 保存最新函数,这样依赖只写 `deps`(不写 fetcher)
② 请求前保留旧数据(`data: prev.data`)→ 避免「刷新时骨架闪一下」
③ **`AbortController`** —— 依赖变化/卸载时取消(解决竞态与泄漏)
④ **区分 `AbortError`** —— 取消不应被当作错误展示
⑤ 渲染中不发请求(在 `useEffect` 里发)
三、完整版(含 options)
import { useCallback, useEffect, useRef, useState } from 'react';
export interface UseRequestOptions<T, P extends unknown[]> {
/** 是否「手动触发」(为 true 时不会自动请求) */
manual?: boolean;
/** 初始数据(SSR / 缓存场景) */
initialData?: T;
/** 请求成功回调 */
onSuccess?: (data: T, params: P) => void;
/** 请求失败回调 */
onError?: (error: Error, params: P) => void;
/** 无论成败都调用 */
onFinally?: (params: P) => void;
/** 失败重试次数(默认 0) */
retryCount?: number;
/** 重试间隔(毫秒,默认 1000,指数退避) */
retryDelay?: number;
/** 防抖等待(毫秒,>0 时启用) */
debounceWait?: number;
/** 是否就绪(false 时不请求,用于「依赖参数还没准备好」) */
ready?: boolean;
}
export interface UseRequestResult<T, P extends unknown[]> {
data: T | undefined;
error: Error | undefined;
loading: boolean;
/** 是否「首次加载」(没有数据时) */
loadingInitial: boolean;
/** 是否「刷新中」(已有数据) */
refreshing: boolean;
/** 是否已取消/未触发 */
run: (...params: P) => Promise<T | undefined>;
refresh: () => Promise<T | undefined>;
cancel: () => void;
mutate: (data: T | ((prev: T | undefined) => T)) => void;
}
export function useRequest<T, P extends unknown[] = []>(
service: (signal: AbortSignal, ...params: P) => Promise<T>,
deps: unknown[] = [],
options: UseRequestOptions<T, P> = {}
): UseRequestResult<T, P> {
const {
manual = false,
initialData,
onSuccess,
onError,
onFinally,
retryCount = 0,
retryDelay = 1000,
debounceWait = 0,
ready = true,
} = options;
const [data, setData] = useState<T | undefined>(initialData);
const [error, setError] = useState<Error | undefined>();
const [loading, setLoading] = useState(false);
// 用 ref 保存「最新的一切」(避免把它们放进依赖导致反复请求)
const serviceRef = useRef(service);
const optionsRef = useRef({ onSuccess, onError, onFinally, retryCount, retryDelay });
useEffect(() => {
serviceRef.current = service;
optionsRef.current = { onSuccess, onError, onFinally, retryCount, retryDelay };
});
const controllerRef = useRef<AbortController | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** 记录「最新一次请求的序号」——用于丢弃过期响应 */
const seqRef = useRef(0);
const paramsRef = useRef<P>([] as unknown as P);
/** 核心:真正执行请求 */
const execute = useCallback(async (params: P): Promise<T | undefined> => {
// ① 取消上一次请求
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
const seq = ++seqRef.current;
paramsRef.current = params;
setLoading(true);
setError(undefined);
const { retryCount, retryDelay, onSuccess, onError, onFinally } = optionsRef.current;
const runOnce = async (attempt: number): Promise<T | undefined> => {
try {
const result = await serviceRef.current(controller.signal, ...params);
// ⭐ 过期响应直接丢弃(防止竞态)
if (seq !== seqRef.current || controller.signal.aborted) return undefined;
setData(result);
onSuccess?.(result, params);
return result;
} catch (err) {
const error = err as Error;
// ⭐ 取消 → 静默返回
if (error.name === 'AbortError' || controller.signal.aborted) return undefined;
// ⭐ 重试(指数退避)
if (attempt < retryCount) {
await new Promise((r) => setTimeout(r, retryDelay * 2 ** attempt));
if (controller.signal.aborted) return undefined;
return runOnce(attempt + 1);
}
setError(error);
onError?.(error, params);
return undefined;
} finally {
if (seq === seqRef.current) {
setLoading(false);
onFinally?.(params);
}
}
};
return runOnce(0);
}, []);
/** 手动触发(带防抖) */
const run = useCallback(
(...params: P): Promise<T | undefined> => {
if (debounceWait > 0) {
if (timerRef.current) clearTimeout(timerRef.current);
return new Promise((resolve) => {
timerRef.current = setTimeout(() => {
timerRef.current = null;
void execute(params).then(resolve);
}, debounceWait);
});
}
return execute(params);
},
[debounceWait, execute]
);
/** 用「上次的参数」重新请求 */
const refresh = useCallback(() => execute(paramsRef.current), [execute]);
/** 取消(并清掉待执行的防抖定时器) */
const cancel = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
controllerRef.current?.abort();
setLoading(false);
}, []);
/** 本地更新数据(乐观更新用) */
const mutate = useCallback((next: T | ((prev: T | undefined) => T)) => {
setData((prev) => (typeof next === 'function' ? (next as (p: T | undefined) => T)(prev) : next));
}, []);
// ⭐ 自动触发(依赖变化时)
useEffect(() => {
if (manual || !ready) return;
void execute([] as unknown as P);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, manual, ready]);
// ⭐ 卸载时清理
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
controllerRef.current?.abort();
},
[]
);
return {
data,
error,
loading,
loadingInitial: loading && data === undefined,
refreshing: loading && data !== undefined,
run,
refresh,
cancel,
mutate,
};
}
// ✅ 用法一:自动请求(依赖变化自动重新请求)
function UserDetail({ id }: { id: string }) {
const { data, loadingInitial, error, refresh } = useRequest(
(signal) => api.getUser(id, { signal }),
[id],
{ retryCount: 2 }
);
if (loadingInitial) return <Skeleton />;
if (error) return <ErrorView error={error} onRetry={refresh} />;
return <div>{data?.name}</div>;
}
// ✅ 用法二:搜索(防抖 + 手动触发)
function Search() {
const [keyword, setKeyword] = useState('');
const { data, loading, run } = useRequest(
(signal, kw: string) => api.search(kw, { signal }),
[],
{
manual: true,
debounceWait: 300,
ready: keyword.length > 0,
}
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setKeyword(value); // 受控值实时
if (value) void run(value); // ⭐ 防抖后请求
};
return (
<>
<input value={keyword} onChange={handleChange} />
{loading ? <Skeleton /> : <List items={data ?? []} />}
</>
);
}
// ✅ 用法三:提交 + 乐观更新
function TodoList() {
const { data: todos, mutate, refresh } = useRequest((signal) => api.getTodos({ signal }), []);
const handleAdd = async (text: string) => {
const temp = { id: `temp-${Date.now()}`, text };
mutate((prev) => [...(prev ?? []), temp]); // ⭐ 乐观更新
try {
await api.addTodo({ text });
await refresh(); // 成功后以服务端为准
} catch {
await refresh(); // 失败也回滚(重新拉取)
toast.error('添加失败');
}
};
return <>{/* ... */}</>;
}
四、测试思路
// ✅ 用 MSW + fake timers 测试
import { renderHook, waitFor, act } from '@testing-library/react';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { vi } from 'vitest';
const server = setupServer(
http.get('/api/user/1', () => HttpResponse.json({ id: '1', name: '张三' }))
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('请求成功后返回数据', async () => {
const { result } = renderHook(() => useRequest((s) => fetch('/api/user/1', { signal: s }).then((r) => r.json()), []));
expect(result.current.loadingInitial).toBe(true);
await waitFor(() => expect(result.current.data).toEqual({ id: '1', name: '张三' }));
expect(result.current.error).toBeUndefined();
});
test('id 变化时取消上一个请求(不会用旧数据覆盖新数据)', async () => {
const { result, rerender } = renderHook(({ id }) => useRequest(
(s) => fetch(`/api/user/${id}`, { signal: s }).then((r) => r.json()),
[id]
), { initialProps: { id: '1' } });
server.use(
http.get('/api/user/1', async () => {
await new Promise((r) => setTimeout(r, 200)); // 故意慢
return HttpResponse.json({ id: '1', name: '慢的旧数据' });
}),
http.get('/api/user/2', () => HttpResponse.json({ id: '2', name: '快的新数据' }))
);
rerender({ id: '2' }); // ⭐ 切换 id
await waitFor(() => expect(result.current.data?.name).toBe('快的新数据'));
// ⭐ 稍等一会,确认「慢的旧数据」没有把它覆盖
await new Promise((r) => setTimeout(r, 300));
expect(result.current.data?.name).toBe('快的新数据');
});
test('防抖:快速多次调用只请求一次', async () => {
vi.useFakeTimers();
const spy = vi.fn((s) => Promise.resolve([]));
const { result } = renderHook(() => useRequest(spy, [], { manual: true, debounceWait: 300 }));
act(() => { void result.current.run(); void result.current.run(); void result.current.run(); });
vi.advanceTimersByTime(300);
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
vi.useRealTimers();
});
五、与 TanStack Query 的对比
| 能力 | 自研 useRequest |
TanStack Query |
|---|---|---|
| 状态机 | ✅(自己实现) | ✅ 内置 |
| 竞态取消 | ✅(自己实现) | ✅ 内置 |
| 缓存(跨组件共享) | ❌ | ✅ 按 queryKey |
| 请求去重 | ❌ | ✅ 自动 |
| 失效与重新验证 | ⚠️(自己实现) | ✅ + 焦点/重连自动重验证 |
| 分页/无限滚动 | ❌ | ✅ useInfiniteQuery |
| 乐观更新 | ⚠️(手动 mutate) |
✅ onMutate 自动化 |
| DevTools | ❌ | ✅ |
⭐ 什么时候用自研?
① 需求简单(就是「取一次数据、显示 loading/error」)
② 不想引入依赖(体积敏感)
③ 需要「特殊行为」(如特定的重试/防抖/取消语义)
④ 面试题 / 教学
⭐ 什么时候用 TanStack Query?
① 需要在多个组件间「共享同一份数据」
② 需要「缓存 + 失效 + 重验证」
③ 需要「分页 + 无限滚动 + 乐观更新」
→ 生产项目强烈推荐后者(见 7.4)
六、常见坑
// ① 在渲染中发请求
function Bad() {
useFetch(() => api.get()); // ⚠️ 若 Hook 内部在渲染中调用 → 每次渲染都请求
}
// ② 不取消上一次请求(竞态)
// ③ 取消后仍 setState(在已卸载组件上)
// ④ 把 AbortError 当真实错误展示
// ⑤ 用「多个布尔值」建模状态(非法组合)
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); // ⚠️ 可能出现「loading 且 error」
// ⑥ 用「一个全局 loading」管多个并发请求
// ⑦ 把 fetcher 放进依赖(每次渲染新建 → 无限请求)
useFetch((signal) => api.get(id, { signal }), [id, () => {}]); // ❌
// ✅ 用 ref 保存 fetcher,依赖只写「参数」
// ⑧ 请求参数是对象 → 依赖不稳定 → 反复请求
useFetch((s) => api.list(filters, { s }), [filters]); // ⚠️ filters 每次新建
// ✅ 依赖具体字段:[filters.page, filters.keyword]
// ⑨ 忘记「卸载时清理」(内存泄漏 + 卸载后 setState)
// ⑩ 刷新时清空数据(骨架闪烁)→ 应该「保留旧数据 + 轻提示」
// ⑪ 重试对 4xx 也生效(永远失败)
// ⑫ 防抖与「受控输入」冲突(输入卡字,见 11.3)
// ⑬ 用「序号」判过期但忘记在 catch 里也判
// ⑭ 忘记「依赖为空数组但参数为空」的首次请求(或反过来「每次渲染都请求」)
// ⑮ 用 useState 存 controller(会触发多余渲染)→ 用 useRef
// ⑧ 的修复
// ❌
const filters = { page, keyword, sort }; // 每次渲染新建
useRequest((s) => api.list(filters, { signal: s }), [filters]);
// ✅ 方式一:依赖具体字段
useRequest((s) => api.list({ page, keyword, sort }, { signal: s }), [page, keyword, sort]);
// ✅ 方式二:用 useMemo 稳定对象
const filters2 = useMemo(() => ({ page, keyword, sort }), [page, keyword, sort]);
useRequest((s) => api.list(filters2, { signal: s }), [filters2]);
七、面试延伸
- 「手写一个
useFetch/useRequest」
核心要处理五件事:① 状态机——用{ status, data, error }(或data/error/loading)而不是多个布尔值,避免非法组合;② 依赖触发——把deps传给内部的useEffect(依赖变化时重新请求),并用useRef保存 fetcher(避免把它放进依赖导致每次渲染都请求);③ 竞态取消——每次请求创建新的AbortController,在发起新请求前/组件卸载时abort()(保证旧响应不会覆盖新数据);④ 错误处理——区分AbortError(取消不应该被展示为错误);⑤ 保留旧数据——刷新时不要把data清空(避免骨架闪烁)。 -
「为什么不能把
fetcher放进依赖数组?」
因为调用方通常写成内联箭头函数((signal) => api.getUser(id, { signal })),它在每次渲染时都是新的引用——如果放进依赖,useEffect就会「每次渲染都重新执行」,导致无限请求。解法是「用useRef保存最新的 fetcher(在无依赖的 effect 里每次渲染更新),依赖只写调用方传进来的deps([id])」。这也是许多请求 Hook 的标准写法(与useInterval/useEventListener同一个模式)。 -
「怎么处理请求竞态?」
两种手段,推荐组合使用:①AbortController——在发起新请求前/卸载时abort()上一个请求(真正取消,节省带宽);② 请求序号——用一个useRef记录「最新一次请求的序号」,响应返回后先比较序号,不是最新的就丢弃(这能防止「取消失败或时序问题导致的过期响应覆盖」)。同时要在catch里也判断(避免「取消的错误」被当成「真实错误」展示)。 -
「
loading和loadingInitial为什么要分开?」
因为它们对应不同的 UI 策略:①loadingInitial(首次加载,还没有数据)——应该显示骨架屏(页面没有内容可用);②refreshing(已有数据,正在刷新)——应该保留旧数据 + 轻提示(顶部进度条、半透明),而不是清空重显骨架。如果不区分,用户在「切换筛选/刷新」时会看到「列表闪一下变成骨架再变回来」,体验很差(这与 7.3 的「避免闪烁」是同一个问题)。 -
「手写版和 TanStack Query 有什么区别?」
手写版覆盖的是「单个组件内的请求生命周期」(状态、竞态、取消、重试、防抖);TanStack Query 额外提供「跨组件的缓存与去重」(同一个queryKey共享数据、并发只发一次)、「失效与自动重验证」(mutation 后invalidateQueries、窗口聚焦/网络重连重新验证)、「分页/无限滚动」、「乐观更新的完整闭环」、「DevTools」等。结论:手写版适合「面试题、教学、需求极简、或需要特殊语义」的场景,生产项目应该用 TanStack Query(见 7.4、7.5)。 -
「怎么让 Hook 支持『手动触发』?」
加一个manual选项:为true时不在useEffect里自动执行,而是返回一个run(...params)方法供调用方主动调用。实现上要注意:①run应该记住最近的参数(这样refresh()可以「用上次的参数重新请求」);②manual模式常与debounceWait配合(如搜索:输入变化时调run,内部做防抖);③ 还需要一个ready选项(依赖参数还没准备好时不请求,如「关键词为空时不搜」)。 -
「重试怎么做?」
三个要点:① 只重试「值得重试的错误」——网络错误、超时、429、5xx可以重试;4xx(参数/权限/不存在)不该重试(见 7.5);② 指数退避——delay * 2 ** attempt(避免「大量客户端同时重试」打垮服务端),并加一点随机抖动;③ 可取消——重试的等待期间如果「用户取消了」或「依赖变了」,要立刻停止(在await之后判断signal.aborted)。此外写操作(mutation)默认不该自动重试(可能重复创建),除非接口是幂等的。 -
「怎么测试这个 Hook?」
四条:① 用 MSW 拦截网络(而不是 mock 掉整个 API 模块)——这样 Hook 内部的逻辑(状态机、取消、重试)都会真实执行;② 用renderHook+waitFor测试「状态变化」(loadingInitial→data);③ 测试竞态——让「旧请求慢、新请求快」,断言最终数据是新请求的(这是这个 Hook 最有价值的测试);④ 测试防抖/重试用 fake timers(vi.useFakeTimers()+advanceTimersByTime),避免真的等几百毫秒。
一句话速记
手写请求 Hook 的五件核心事情是「状态机(不要多个布尔值)、依赖触发(
useRef存 fetcher,依赖只写参数)、AbortController取消(+ 请求序号双重保障竞态)、区分AbortError、保留旧数据避免闪烁」;进阶加「manual+run/refresh/cancel/mutate、debounceWait、retryCount+ 指数退避(只重试网络/5xx)、ready条件、成功/失败回调」;区分loadingInitial(骨架)与refreshing(保留旧数据 + 轻提示);卸载时必须清理(清定时器 + abort);手写版的定位是「面试与教学」,生产项目应该用 TanStack Query(它多了「跨组件缓存/去重/失效/重验证/分页/乐观更新/DevTools」);测试用 MSW +renderHook+ fake timers,重点测竞态。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!