陌上人如玉
公子世无双

11.4 手写 useFetch / useRequest

11.4 手写 useFetch / useRequest

一句话:手写请求 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]);

七、面试延伸

  1. 「手写一个 useFetch / useRequest」
    核心要处理五件事:① 状态机——用 { status, data, error }(或 data/error/loading)而不是多个布尔值,避免非法组合;② 依赖触发——把 deps 传给内部的 useEffect(依赖变化时重新请求),并用 useRef 保存 fetcher(避免把它放进依赖导致每次渲染都请求);③ 竞态取消——每次请求创建新的 AbortController,在发起新请求前/组件卸载时 abort()(保证旧响应不会覆盖新数据);④ 错误处理——区分 AbortError(取消不应该被展示为错误);⑤ 保留旧数据——刷新时不要把 data 清空(避免骨架闪烁)。

  2. 「为什么不能把 fetcher 放进依赖数组?」
    因为调用方通常写成内联箭头函数((signal) => api.getUser(id, { signal })),它在每次渲染时都是新的引用——如果放进依赖,useEffect 就会「每次渲染都重新执行」,导致无限请求。解法是「用 useRef 保存最新的 fetcher(在无依赖的 effect 里每次渲染更新),依赖只写调用方传进来的 deps([id])」。这也是许多请求 Hook 的标准写法(与 useInterval/useEventListener 同一个模式)。

  3. 「怎么处理请求竞态?」
    两种手段,推荐组合使用:① AbortController——在发起新请求前/卸载时 abort() 上一个请求(真正取消,节省带宽);② 请求序号——用一个 useRef 记录「最新一次请求的序号」,响应返回后先比较序号,不是最新的就丢弃(这能防止「取消失败或时序问题导致的过期响应覆盖」)。同时要在 catch 里也判断(避免「取消的错误」被当成「真实错误」展示)。

  4. 「loading 和 loadingInitial 为什么要分开?」
    因为它们对应不同的 UI 策略:① loadingInitial(首次加载,还没有数据)——应该显示骨架屏(页面没有内容可用);② refreshing(已有数据,正在刷新)——应该保留旧数据 + 轻提示(顶部进度条、半透明),而不是清空重显骨架。如果不区分,用户在「切换筛选/刷新」时会看到「列表闪一下变成骨架再变回来」,体验很差(这与 7.3 的「避免闪烁」是同一个问题)。

  5. 「手写版和 TanStack Query 有什么区别?」
    手写版覆盖的是「单个组件内的请求生命周期」(状态、竞态、取消、重试、防抖);TanStack Query 额外提供「跨组件的缓存与去重」(同一个 queryKey 共享数据、并发只发一次)、「失效与自动重验证」(mutation 后 invalidateQueries、窗口聚焦/网络重连重新验证)、「分页/无限滚动」、「乐观更新的完整闭环」、「DevTools」等。结论:手写版适合「面试题、教学、需求极简、或需要特殊语义」的场景,生产项目应该用 TanStack Query(见 7.4、7.5)。

  6. 「怎么让 Hook 支持『手动触发』?」
    加一个 manual 选项:为 true 时不在 useEffect 里自动执行,而是返回一个 run(...params) 方法供调用方主动调用。实现上要注意:① run 应该记住最近的参数(这样 refresh() 可以「用上次的参数重新请求」);② manual 模式常与 debounceWait 配合(如搜索:输入变化时调 run,内部做防抖);③ 还需要一个 ready 选项(依赖参数还没准备好时不请求,如「关键词为空时不搜」)。

  7. 「重试怎么做?」
    三个要点:① 只重试「值得重试的错误」——网络错误、超时、429、5xx 可以重试;4xx(参数/权限/不存在)不该重试(见 7.5);② 指数退避——delay * 2 ** attempt(避免「大量客户端同时重试」打垮服务端),并加一点随机抖动;③ 可取消——重试的等待期间如果「用户取消了」或「依赖变了」,要立刻停止(在 await 之后判断 signal.aborted)。此外写操作(mutation)默认不该自动重试(可能重复创建),除非接口是幂等的。

  8. 「怎么测试这个 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,重点测竞态。

赞(0) 打赏
未经允许不得转载:陌上寒 » 11.4 手写 useFetch / useRequest

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫