一、痛点引入:写数据请求时的“重复劳动陷阱”

做前端开发的人,大概率都遇过这种情况:一个页面里有3个不同的接口请求,比如拉商品列表、用户信息、订单状态,每个请求都得写一套差不多的逻辑:先设一个加载状态,请求开始时改成true,结束时改成false;再设一个错误状态,请求失败时存错误信息,成功时清空;还要写请求方法,处理返回结果。 要是页面多了,比如商品页、个人中心页、订单页,每个页面都要重复写这些逻辑,不仅代码冗余,改起来还麻烦——比如要给所有请求加个超时处理,得一个个页面改,很容易漏。更头疼的是,不同人写的逻辑还可能不一样,比如有的把加载状态叫isLoading,有的叫loading,有的错误处理只弹个提示,有的存到状态里,维护起来一团乱。

二、核心思路:用自定义Hook把“通用逻辑”打包

要解决这个问题,核心就是把所有数据请求都要用到的“加载、错误、请求逻辑”抽出来,做成一个可以重复用的工具。这个工具就是React里的自定义Hook——它本质就是一个普通的函数,只是名字必须以use开头,能用到React的状态(比如useState、useEffect),还能返回给组件用的状态和方法。 举个最简单的例子,比如我们要抽一个叫useFetch的自定义Hook,它的作用就是:接收一个接口地址,自动处理加载状态、错误状态、请求结果,最后把这些内容返回给调用它的组件。

三、完整实现:useFetch自定义Hook的开发与解析

3.1 技术栈说明

本次所有示例统一使用React 18 + TypeScript,用原生fetch做请求(不用axios,方便大家理解核心逻辑,后续换成axios也很简单)。

3.2 基础版useFetch实现

先写一个最基础的版本,覆盖最核心的功能:

// 定义请求返回的类型,方便组件调用时知道返回值的结构
type FetchState<T> = {
  data: T | null; // 请求成功返回的数据,初始为null
  loading: boolean; // 加载状态,初始为true(刚发起请求时就是加载中)
  error: Error | null; // 错误信息,初始为null
};

// 自定义Hook:接收请求地址,返回状态和刷新方法
function useFetch<T = unknown>(url: string) {
  // 用useState管理三个状态:data、loading、error
  const [state, setState] = React.useState<FetchState<T>>({
    data: null,
    loading: true,
    error: null,
  });

  // 定义发起请求的方法,抽出来方便后续手动刷新
  const fetchData = React.useCallback(async () => {
    // 每次发起新请求时,重置状态:加载中,错误清空
    setState({ data: null, loading: true, error: null });
    try {
      // 发起请求
      const response = await fetch(url);
      // 要是请求失败(比如404、500),主动抛出错误
      if (!response.ok) {
        throw new Error(`请求失败:状态码${response.status}`);
      }
      // 解析返回的JSON数据
      const result = await response.json() as T;
      // 请求成功,更新状态:加载结束,存数据
      setState({ data: result, loading: false, error: null });
    } catch (err) {
      // 捕获错误,更新状态:加载结束,存错误
      setState({ data: null, loading: false, error: err as Error });
    }
  }, [url]); // 依赖项只有url,url变了才会重新生成fetchData

  // 组件挂载时自动发起第一次请求
  React.useEffect(() => {
    fetchData();
  }, [fetchData]); // 依赖fetchData,fetchData变了(也就是url变了)就重新请求

  // 返回给组件用的状态和刷新方法
  return { ...state, refetch: fetchData };
}

3.3 基础版的使用示例

写一个商品列表组件,用这个useFetch来拉数据:

// 定义商品的类型,方便类型检查
type Product = {
  id: number;
  name: string;
  price: number;
};

function ProductList() {
  // 调用useFetch,指定返回的data是Product[]类型
  const { data: products, loading, error, refetch } = useFetch<Product[]>(
    "https://api.example.com/products"
  );

  // 加载中显示提示
  if (loading) return <div>商品加载中...</div>;
  // 出错显示错误信息和重试按钮
  if (error) return <div>加载失败:{error.message} <button onClick={refetch}>重试</button></div>;
  // 有数据显示列表
  return (
    <div>
      <h2>商品列表</h2>
      <button onClick={refetch}>刷新列表</button>
      <ul>
        {products?.map((product) => (
          <li key={product.id}>{product.name} - ¥{product.price}</li>
        ))}
      </ul>
    </div>
  );
}

3.4 进阶版useFetch:增加更多实用功能

基础版已经能解决大部分问题,但实际开发中还需要更多功能,比如请求参数、请求方法(GET/POST)、超时控制、缓存(避免重复请求)、取消请求(组件卸载时取消未完成的请求,避免内存泄漏)。 下面是进阶版的实现:

// 定义请求配置的类型
type FetchOptions = {
  method?: "GET" | "POST" | "PUT" | "DELETE"; // 请求方法
  body?: BodyInit; // 请求体(POST等方法用)
  headers?: HeadersInit; // 请求头
  timeout?: number; // 超时时间(毫秒)
};

type FetchState<T> = {
  data: T | null;
  loading: boolean;
  error: Error | null;
};

function useFetch<T = unknown>(url: string, options?: FetchOptions) {
  const [state, setState] = React.useState<FetchState<T>>({
    data: null,
    loading: true,
    error: null,
  });

  // 用Ref存AbortController,用来取消请求
  const abortControllerRef = React.useRef<AbortController | null>(null);

  const fetchData = React.useCallback(async () => {
    // 取消上一次未完成的请求,避免内存泄漏
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
    // 创建新的AbortController
    abortControllerRef.current = new AbortController();
    const signal = abortControllerRef.current.signal;

    // 重置状态
    setState({ data: null, loading: true, error: null });

    // 处理超时:创建一个定时器,到时间就取消请求
    const timeoutId = options?.timeout
      ? setTimeout(() => {
          abortControllerRef.current?.abort();
          setState({ data: null, loading: false, error: new Error("请求超时") });
        }, options.timeout)
      : null;

    try {
      const response = await fetch(url, {
        method: options?.method || "GET",
        body: options?.body,
        headers: options?.headers,
        signal, // 把AbortController的signal传给fetch,用来取消请求
      });

      // 清除超时定时器
      if (timeoutId) clearTimeout(timeoutId);

      if (!response.ok) {
        throw new Error(`请求失败:状态码${response.status}`);
      }

      const result = await response.json() as T;
      setState({ data: result, loading: false, error: null });
    } catch (err) {
      // 清除超时定时器
      if (timeoutId) clearTimeout(timeoutId);
      // 忽略AbortError(主动取消请求的错误,不用显示给用户)
      if ((err as Error).name !== "AbortError") {
        setState({ data: null, loading: false, error: err as Error });
      }
    }
  }, [url, options]); // 依赖项加上options,options变了就重新请求

  React.useEffect(() => {
    fetchData();
    // 组件卸载时取消请求
    return () => {
      abortControllerRef.current?.abort();
    };
  }, [fetchData]);

  return { ...state, refetch: fetchData };
}

3.5 进阶版的使用示例

写一个提交表单的组件,用POST请求:

// 定义表单数据的类型
type SubmitFormData = {
  name: string;
  email: string;
};

// 定义接口返回的类型
type SubmitResponse = {
  success: boolean;
  message: string;
};

function SubmitForm() {
  const [formData, setFormData] = React.useState<SubmitFormData>({
    name: "",
    email: "",
  });

  // 调用useFetch,配置POST方法、请求体、超时时间
  const { data, loading, error, refetch } = useFetch<SubmitResponse>(
    "https://api.example.com/submit",
    {
      method: "POST",
      body: JSON.stringify(formData),
      headers: {
        "Content-Type": "application/json",
      },
      timeout: 5000, // 5秒超时
    }
  );

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    refetch(); // 提交时重新发起请求
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>姓名:</label>
        <input
          type="text"
          value={formData.name}
          onChange={(e) => setFormData({ ...formData, name: e.target.value })}
          required
        />
      </div>
      <div>
        <label>邮箱:</label>
        <input
          type="email"
          value={formData.email}
          onChange={(e) => setFormData({ ...formData, email: e.target.value })}
          required
        />
      </div>
      <button type="submit" disabled={loading}>
        {loading ? "提交中..." : "提交"}
      </button>
      {error && <div className="error">提交失败:{error.message}</div>}
      {data?.success && <div className="success">提交成功:{data.message}</div>}
    </form>
  );
}

四、关键技术解析:自定义Hook的核心设计细节

4.1 useCallback的作用

在useFetch里,我们把fetchData用useCallback包起来,是为了避免不必要的重新生成。因为useEffect的依赖项是fetchData,如果fetchData每次都变,useEffect就会反复执行,导致重复请求。useCallback会缓存fetchData,只有当它的依赖项(url、options)变化时,才会重新生成,这样就能保证useEffect的执行次数是可控的。

4.2 AbortController的作用

AbortController是浏览器原生的API,用来取消fetch请求。在进阶版useFetch里,我们用它的Ref存实例,每次发起新请求时,先取消上一次的请求,避免出现“先发起的请求后返回”的问题——比如用户快速切换商品列表的分类,第一次请求还没完成,第二次请求已经发起,要是第一次请求后返回,就会覆盖第二次请求的结果,导致显示错误的数据。另外,组件卸载时取消请求,还能避免内存泄漏。

4.3 类型定义的作用

用TypeScript定义FetchState、FetchOptions、接口返回类型,能让代码更严谨,减少错误。比如调用useFetch时指定返回的data是Product[],TypeScript就会检查你能不能对data做数组操作,要是写错了(比如把data当对象用),编译时就会报错,不用等到运行时才发现问题。

五、应用场景与优缺点分析

5.1 应用场景

这种自定义Hook的设计,适合所有需要统一管理数据请求的场景:

  • 前端项目中多个页面需要调用类似的接口(比如用户信息、商品列表);
  • 表单提交类的请求(需要处理加载、错误、成功状态);
  • 项目中需要统一的请求规范(比如超时时间、错误处理逻辑);
  • 小型项目或中大型项目的基础请求层(要是项目用了React Query、SWR等请求库,也可以用自定义Hook再做一层封装,适配项目的需求)。

5.2 优点

  • 代码复用性高:不用每个组件都写一套加载、错误、请求逻辑,一个useFetch就能搞定;
  • 维护成本低:要是要修改请求逻辑(比如加全局的错误处理、换axios),只需要改useFetch内部的代码,所有调用它的组件都会生效;
  • 逻辑统一:所有请求的处理逻辑一致,不会出现不同人写的逻辑不一样的问题;
  • 类型安全:用TypeScript定义类型,能提前发现错误,减少调试时间。

5.3 缺点

  • 灵活性有限:自定义Hook的功能是固定的,要是某个组件需要特殊的请求逻辑(比如不需要自动发起请求、需要多个请求),可能需要修改useFetch,或者写新的自定义Hook;
  • 学习成本:对刚接触React自定义Hook的开发者来说,需要理解useCallback、useEffect、AbortController等概念,才能完全掌握;
  • 不适合复杂请求场景:要是项目需要复杂的缓存、分页、乐观更新等功能,自定义Hook的实现会变得很复杂,不如用React Query、SWR等成熟的请求库。

六、注意事项

6.1 依赖项的处理

在useFetch里,useCallback和useEffect的依赖项一定要写对,要是漏了依赖项,会导致逻辑错误。比如useCallback的依赖项漏了options,那么options变化时,fetchData不会重新生成,useEffect也不会重新请求,导致请求参数不对。

6.2 内存泄漏的避免

一定要处理组件卸载时的请求取消,要是不取消,组件卸载后请求返回的结果会尝试更新已经不存在的组件状态,导致控制台报错(比如“Can't perform a React state update on an unmounted component”)。

6.3 错误处理的统一

可以在useFetch里加全局的错误处理,比如所有请求失败都弹一个提示框,或者把错误信息上报到监控系统。比如在catch块里加:

// 全局错误处理示例
if ((err as Error).name !== "AbortError") {
  console.error("请求错误:", err);
  alert(`请求出错:${err.message}`);
  setState({ data: null, loading: false, error: err as Error });
}

这样所有调用useFetch的组件,都会自动有全局的错误处理,不用每个组件都写一遍。

6.4 状态的复用

要是多个组件需要调用同一个接口,可以把useFetch的返回值提升到父组件,或者用Context API把状态存到全局,避免重复请求。比如用户信息,所有页面都要用到,可以在根组件里调用useFetch拉用户信息,然后通过Context传给所有子组件,这样只会请求一次。

七、总结

自定义Hook封装数据请求逻辑,是一种非常实用的代码优化方式,能有效解决重复劳动、逻辑不统一、维护成本高的问题。通过把加载、错误、请求逻辑抽成一个通用的useFetch,不仅能提升代码的复用性,还能让项目的代码更规范、更易维护。 当然,这种方式也不是万能的,要是项目的请求逻辑很复杂,还是建议用成熟的请求库(比如React Query、SWR),但自定义Hook的设计思路,同样可以用到这些请求库的封装上,让项目的代码更符合自己的需求。