这是我参与8月更文挑战的第1天,活动详情查看: 8月更文挑战

如题,本文想要实现一个完整的,可在大型项目中实际应用的一个 useFetchData Hooks ,网上有很多类似的实现,但是都没有找到最合适自己的(一千个读者就有一千个哈姆雷特,并非人家实现的不好),所以干脆不如自己撸一个,在此记录一下过程,希望能对大家有点帮助。

【实现的功能如下】:

  • 自定义 hooks,使用方式统一,内部捕获错误,返回数据和 loading
  • 基于 Typescript 封装了 fetch,实现了 cancel 请求,前端超时等功能
  • 先看demo: awesome-use-fetch-data_Demo

    Demo 仓库地址 -> awesome-use-fetch-data_Repo

    本文大体上分为如下几个步骤讲解实现过程:
        1 - 为什么要封装一个 useFetchData Hooks
        2 - 使用 Typescript 封装一个前端超时取消、页面销毁/路由跳转取消、带 loading 的 fetch 库
        3 - 结合二者,实现一个完整版的 useFetchData
    

    为什么要封装一个 useFetchData Hooks

    首先,就来说说为什么要实现这样一个 Custom Hooks 呢?原因有两点:

  • 第一,如果你项目里大量使用 Hooks 开发代码,就避免不了封装高复用的 Custom Hooks
  • 如果你还是一直在复制粘贴代码,那么看完本篇文章,相信对你还是有些许裨益的。

  • 第二,在业务场景里,基本上每个页面大部分组件都会请求数据接口渲染,所以对请求进行高复用封装能提高开发效率
  • 具体为什么能提高开发效率,我这边简单写了两个伪代码,参考如下:

    // page1.jsx
    import { useState, useEffect } from 'react';
    import axios from 'axios';
    export default function Page1() {
       const [data, setData] = useState([]);
       const [loading, setLoading] = useState(false);
       // 组件加载完毕,请求数据
       useEffect(() => {
          setLoading(true);
          axios.get('/user/list', { params: { ID: 12345 } })
          // 更新数据
          .then(function(res) {
              setData(res.data);
              setLoading(false);
          .catch(function(error) {
              console.log(error);
              setLoading(false);
       }, [])
       return (
           <Table loading={loading} data={data} columns={columns} />
    // page2.jsx
    import { useState, useEffect } from 'react';
    import axios from 'axios';
    export default function Page2() {
       const [list, setList] = useState([]);
       const [loading, setLoading] = useState(false);
       // 组件加载完毕,请求数据
       useEffect(() => {
          setLoading(true);
          axios.post('/article/list', { page: 'Fred', pageSize: 'Flintstone'})
          .then(function (res) {
              setList(res.data);
              setLoading(false);
          .catch(function (error) {
              console.log(error);
              setLoading(false);
       }, [])
       return (
           <Table loading={loading} data={list} columns={columns} />
    
    // page1.jsx
    // page1.jsx
    import { useState, useEffect } from 'react';
    import useFetchData from 'use-fetch-data';
    export default function Page1() {
       const [data, setData] = useState([]);
      const options =  { params: { ID: 12345 } };
      const { data, loading } = useFetchData('/user/list', options);
       return (
           <Table loading={loading} data={data} columns={columns} />
    // page2.jsx
    import { useState, useEffect } from 'react';
    import useFetchData from 'use-fetch-data';
    export default function Page2() {
      const options =  { method: 'POST', data: { page: 'Fred', pageSize: 'Flintstone'}};
      const { data, loading } = useFetchData('/article/list', options);
       return (
           <Table data={data} columns={columns} />
    

    从上面两段代码,可以非常清晰的看出来封装前后的代码对比,封装前,每个业务组件在内部处理请求的同时,还要处理数据和 loading 状态,那么一个组件两个组件还好,当项目庞大起来,几十个接口几百个组件都进行请求,那么项目就有了大量的 CV 操作冗余代码。

    由此可见一个 useFetchData Hooks 可以大大精简我们的业务代码,合理的将一个常用的组件或者方法抽象成 Custom Hooks 真的是一个非常好的选择,希望大家也能在平时自己用起来,构建自己的 react-use 之类的 Hooks Utils

    介绍完封装一个 useFetchData Hooks 的充分理由,接下来就是正式进行代码封装了,既然是封装请求库,那么就必须选择一个请求库,我这里以个人比较喜欢的 fetch 为例,此 hooks 核心其实不是请求库,大家只要选择自己擅长的请求库就可以了。

    基于 Typescript 封装一个多功能 fetch

    上面提到了本文核心其实也不在封装 fetch 这里,因为 fetch 这个库并不是很多人喜欢用,可能大家很多人喜欢用 axios,也没关系,反正做的事情都是差不多的,到时候各位进行自己请求库的替换就可以了,我只是比较喜欢 fetch 而已,ts-fetch 实现了如下功能:

    1 - 内部处理异常(需要和后端约定好返回)

    2 - 前端自超时,当请求响应时间超过一定阈值,前端认为超时(可以通过传参覆盖)

    3 - 利用 AbortController 取消请求(页面销毁/路由跳转)

    TS-Fetch 代码地址 -> useful-kit个人 TypeScript 用的一般,如果有大佬们有更好的封装方法,可以留言或者仓库直接共建,非常感谢。

    下面就直接贴封装的请求库代码,写文章,就只是简单的进行封装一下,大家在业务里还可以根据业务特性,再次进行改造到适合自己项目的程度,比如路径参数 api 这种也是可以支持的:

    // 这个是同构 fetch,既能在服务端,又能在客户端
    import fetch from 'isomorphic-unfetch';
    // query 格式化的插件,其实可以自己实现
    import qs from 'query-string';
    // 捕获异常内部处理的一个提示,和你项目用的 ui 库一致就可以
    import { message } from 'antd';
    function filterObject(o: Record<string, string>, filter: Function) {
      const res: Record<string, string> = {};
      Object.keys(o).forEach(k => {
        if (filter(o[k], k)) {
          res[k] = o[k];
      return res;
    export enum EHttpMethods {
      GET = 'GET',
      POST = 'POST',
      PUT = 'PUT',
      PATCH = 'PATCH',
      DELETE = 'DELETE'
    type ICustomRequestError = {
      status: number;
      statusText: string;
      url: string;
    function dealErrToast(err: Error & ICustomRequestError, abortController?: AbortController) {
      switch(err.status) {
        case 408: {
          abortController && abortController.abort();
          (typeof window !== 'undefined') && message.error(err.statusText);
          break;
        default: {
          console.log(err);
          break;
     * @description: 声明请求头header的类型
    interface IHeaderConfig {
      Accept?: string;
      'Content-Type': string;
      [propName: string]: any;
    export interface IResponseData {
      code: number;
      data: any;
      message: string;
    interface IAnyMap { 
      [propName: string]: any;
    export interface IRequestOptions {
      headers?: IHeaderConfig;
      signal?: AbortSignal;
      method?: EHttpMethods;
      query?: IAnyMap;
      params?: IAnyMap;
      data?: IAnyMap;
      body?: string;
      timeout?: number;
      credentials?: 'include' | 'same-origin';
      mode?: 'cors' | 'same-origin';
      cache?: 'no-cache' | 'default' | 'force-cache';
      * Http request
      * @param url request URL
      * @param options request options
    interface IHttpInterface {
      request<T = IResponseData>(url: string, options?: IRequestOptions): Promise<T>;
    const CAN_SEND_METHOD = ['POST', 'PUT', 'PATCH', 'DELETE'];
    class Http implements IHttpInterface {
      public async request<T>(url: string, options?: IRequestOptions, abortController?: AbortController): Promise<T> {
        const opts: IRequestOptions = Object.assign({
          method: 'GET',
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            Accept: 'application/json'
          credentials: 'include',
          timeout: 10000,
          mode: 'cors',
          cache: 'no-cache'
        }, options);
        abortController && (opts.signal = abortController.signal);
        if (opts && opts.query) {
          url += `${url.includes('?') ? '&' : '?'}${qs.stringify(
            filterObject(opts.query, Boolean),
          )}`;
        const canSend = opts && opts.method && CAN_SEND_METHOD.includes(opts.method);
        if
    
    
    
    
        
     (canSend && opts.data) {
          opts.body = JSON.stringify(filterObject(opts.data, Boolean));
          opts.headers && Reflect.set(opts.headers, 'Content-Type', 'application/json');
        console.log('Request Opts: ', opts);
        try {
          const res = await Promise.race([
            fetch(url, opts),
            new Promise<any>((_, reject) => {
              setTimeout(() => {
                return reject({ status: 408, statusText: '请求超时,请稍后重试', url });
              }, opts.timeout);
          const result = await res.json();
          return result;
        } catch (e) {
          dealErrToast(e, abortController);
          return e;
    const { request } = new Http();
    export { request as default };
    

    高阶完整版的 useFetchData

    上面已经封装完了一个基于 fetch 的请求库,接下来就是利用它写一个 useFetchData Hooks。这里思考如下:

  • 1 - 使用起来要简单,统一,尽可能的减少请求库逻辑代码在业务组件内
  • 2 - 在 hooks 内部进行错误处理,组件业务层级无需关心处理
  • 3 - 返回响应数据、异常错误和 loading
  • 有个上面三点目标,接下来就是实现了,具体代码如下:

    * /hooks/useFetchData.tsx import { useState, useEffect, useRef } from 'react'; import request, { IRequestOptions, IResponseData } from '../utils/request'; interface IFetchResData { data: T | undefined; loading: boolean; error: any; function useFetchData<T = any>(url: string, options?: IRequestOptions): IFetchResData { // 如果是一个通用的 fetchData,那么使用any是没办法的,如果只是针对list,any可以替换为对应的数据范型 const [data, setData] = useState<T>(); const [loading, setLoading] = useState<boolean>(false); const [error, setError] = useState<any>(null); * 超时或者页面销毁/路由跳转,取消请求 const abortControllerRef = useRef<AbortController>(); function destory() { setData(undefined); setLoading(false); setError(null); abortControllerRef.current && abortControllerRef.current.abort(); useEffect(() => { setLoading(true); abortControllerRef.current = new AbortController(); request(url, options || {}, abortControllerRef.current).then(res => { const { code, message, data } = res as IResponseData; if (code !== 0) { console.log('Error Msg: ', message); throw new Error(message); setData(data); setLoading(false); }).catch(err => { setError(err); }).finally(() => { setLoading(false); return () => destory(); }, [url, JSON.stringify(options)]); return { loading, data, error }; export default useFetchData;

    代码就是上面那个样子,我们来看看是不是满足上面四点目标:

    1 - 使用起来简单、统一,减少请求逻辑在业务组件里

    // 1 - 基础使用 GET 无参数
    const { loading, data } = useFetchData('/user/list');
    // 2 - 进阶使用 GET 有参数
    const [page, setPage] = useState<number>(1);
    const [pageSize, setPageSize] = useState<number | undefined>(10);
    const options = { query: { page, pageSize } };
    const { loading, data } = useFetchData(getUserList, options);
    // 3 - 进阶使用 POST
    const [page, setPage] = useState<number>(1);
    const [pageSize, setPageSize] = useState<number | undefined>(10);
    const options = useMemo(() => ({
    method: EHttpMethods.POST,
    data: { page, pageSize }
    }), [page, pageSize]);
    const { loading, data } = useFetchData(postUserList, options);
    

    可以看到,使用上应该可以算是清晰简单,你只需要传递对应请求的参数和 api 地址即可获取到数据以及 loading 状态。并且请求的逻辑在业务组件里只有必要的 api url 和参数,这两个是无法减少的,至于其他逻辑,完全封装到了 hooks 内部。

    这里需要注意的是,因为 options 也就是 hooks 的第二个参数 是一个对象,因此会存在一个问题,就是组件重复渲染的时候,hooks 会重复发请求,因为每一次 options 都是一个新对象,即使没有改变也是一个新的内存地址,所以为了避免这种情况,有两种解决方案。

  • 第一种:hooks 层解决,我这边使用了 useEffect + JSON.stringify 来确保参数发生变化才重新请求。
  • 第二种:业务层解决,业务代码使用 useMemo 来进行处理。 个人而言更倾向第二种,目前因为是 Demo 阶段,所以两种方法我里面都用了,大家按需使用即可。
  • 2 - hooks 内部进行错误处理

    错误处理集中在请求库和 hooks 内部,业务组件不需要关心以及处理错误,大大减少了业务代码复杂度,核心代码如下:

    // 请求层错误处理
     * 错误处理
     * @param err 
     * @param abortController 
    function dealErrToast(err: Error & ICustomRequestError, abortController?: AbortController) {
      switch(err.status) {
        case 408: {
          abortController && abortController.abort();
          (typeof window !== 'undefined') && message.error(err.statusText);
          break;
        default: {
          console.log(err);
          break;
    // hooks 层错误处理
    const { code, message, data } = res as IResponseData;
    if (code !== 0) {
        // do something
        console.log('Error Msg: ', message);
        throw new Error(message);
    

    返回响应数据和 loading + TS 增强数据提示

    这个看代码就很简单了,每一个请求都返回了响应的数据以及 loading 状态,同样的大大程度精简了业务代码,非常简便。

    这里可以说明一下,为了清晰明了,我在 hooks 内部增设了 loading 这个 state,其实可以通过 dataerror 两个字段聚合出 loading 状态,这里大家可以参考 swr。不过我觉得增加一个 loading 更清晰,所以就这么写了。

    接下来就是 TS 的好处了,如果你在数据模型里设置里返回的数据类型,那么你获取到的 data 就是一个带提示的数据,在业务开发里非常的好用,只需要在编辑器里 . 一下,就能看到返回的这个数据对象的各种属性,开发简直不要太爽,再也不用边开发边查阅 API 文档了!效果如下图:

    具体代码如下:

    // 数据接口层
    export interface IUserStruct {
      id: number;
      name: string;
      age: number;
    export interface IUserListResData {
      list: IUserStruct[];
      total: number
    // 业务代码层
    const { loading, data } = useFetchData<IUserListResData>(getLimitUserList, options);
    

    最后你的 data 就如上图所示了~

    这篇文章也算是个人想总结想写很久的一篇文章了,毕竟我真的很喜欢用 fetch,但是在公司里很少有人用,所以意难平吧。本文说技术含量其实也没啥技术含量,更多的应该是经验分享吧,代码较多,建议大家 Clone 仓库自己跑一跑,希望对大家有用处。