结合Typescript和Axios源码,设计一套实用的API层架构

4,663 阅读21分钟

前言

本文大纲

本文主旨是基于 TypescriptAxios ,设计编写一套实用性强的API 层架构。整个设计过程会分三个章节去讲解:

  1. 基础篇:借助 Typescript 以及 高阶函数 实现:
    • Typescript泛型对请求参数和响应数据的格式进行约束
    • 支持报错时正常返回错误和响应消息,无需使用try~catch或者Promise.catch去捕捉错误
  2. 完善篇:通过 Axios拦截器 实现:
    • 支持请求在失败和成功后的弹框反馈显示
    • 支持 URL 路径参数替换,如传入{arg: 1}时会把"/api/{arg}"转换为"/api/1"
    • 接口限流,即限制请求并发数
  3. 拓展篇:通过 Axios适配器 实现:
    • 支持缓存数据
    • 支持在接口报错后重试

下面我们就直接开始吧。

阅前先知:本文设计所基于的axios的版本为 0.27.2。 0.27.0 以上的 axios 提供了很多新的实用功能,建议大家上手新版本

更好的阅读体验

读者们也可以去git clone本文章对应的gitee项目axios-using-docs后,根据README.md进行配置后,以更好的方式进行阅读,如下所示:

dumi-introduce.gif

该项目用dumi进行编写,保证了每个示例代码会被渲染成React组件供读者测试(如同ahooksantd官网的风格),且用Apifox保证该项目调用到的API可访问。大家在学习到本文的知识点的同时,也可以学习到dumiApifox的玩法。

基础篇

1. 响应数据的约束和获取

1.1 约束响应数据

axios所提供的常用的请求方法有:

axios.request(config)
axios.get(url[, config])
axios.post(url[, data[, config]])

我们来看一下 Axios源码 中对这些请求方法的声明类型,如下所示:

export class Axios {
  request<T = any, R = AxiosResponse<T>, D = any>(
    config: AxiosRequestConfig<D>
  ): Promise<R>;
  get<T = any, R = AxiosResponse<T>, D = any>(
    url: string,
    config?: AxiosRequestConfig<D>
  ): Promise<R>;
  post<T = any, R = AxiosResponse<T>, D = any>(
    url: string,
    data?: D,
    config?: AxiosRequestConfig<D>
  ): Promise<R>;
  // delete,put,options这些方法的声明类型都和上面的大同小异,这里就省略不展示了....
}
// 其中上面AxiosResponse的声明类型如下所示
export interface AxiosResponse<T = any, D = any> {
  data: T;
  status: number;
  statusText: string;
  headers: AxiosResponseHeaders;
  config: AxiosRequestConfig<D>;
  request?: any;
}

这里我们注意到其中的泛型变量R可用于定义返回数据的类型,其默认为AxiosResponse<T>,而AxiosResponse<T>的泛型变量T用于定义其中的属性data。借助该类型声明,我们可以这样编写请求函数:

/**
 * @description 这里的请求函数用到的接口中:
 *    url为'/admins-no-wrapper',
 *    响应数据格式为{ admins: string[] }
 */
async function getAdmins() {
  const { data } = await instance.get<{ admins: string[] }>(
    "/admins-no-wrapper"
  );
  return data;
}

// 调用函数,展示如何调用请求函数getAdmins
async function requestAdmins() {
  const { admins } = await getAdmins();
  setAdmins(admins);
}

从下图的冒泡弹框可知,ts已经自动推导getAdmins的返回数据为Promise<admins: string[]>,这就是借助axios已有泛型来约束响应数据类型的写法。

image.png

这样子,我们就解决了通过ts去约束响应数据的格式

1.2 规范返回数据

针对上面的请求函数,我们还考虑两种情况:

  1. 当请求接口报错时,需要返回错误给开发者处理。
  2. 部分场景下,开发者需要获取Response对象里的部分属性(例如响应头的部分字段值、http 状态码),以实现一些复杂的功能。

对于上面的情况,我们完善一下getAdmins函数:

// 这种写法下的getAdmins有两种返回情况:
//  1. 无报错时:Promise<{err: null, data: {admins: string[]},response: AxiosResponse }>
//  2. 报错时:Promise<{err: err, data: null, response: null }>
async function getAdmins() {
  // 用try~catch来捕捉错误
  try {
    const response = await instance.get<{ admins: string[] }>(
      "/admins-no-wrapper"
    );
    const { data } = response;
    return { data, err: null, response };
  } catch (err) {
    return { data: null, err, response: null };
  }
}

// 当我们调用getAdmins,可用以下写法
async function requestAdmins() {
  // 这里不需要用到response,所以就没解构出response出来
  const { err, data } = await getAdmins();
  // 处理错误
  if (err) {
    // 此时data为null值,因此直接return跳出函数,避免继续执行下面的逻辑导致报错
    return;
  }
  // 处理数据
  // 注意data在这一步里是真值而非null,但data的ts声明类型中包含null值
  // 因此用!强制声明该变量不会是null和undefined
  setAdmins(data!.admins);
}

但如果每次写请求函数,我们都要写重复的try~catch逻辑,就会非常累赘。为了减少重复代码的工作量和统一返回的数据格式,我们可以编写一个makeRequest高阶函数,用来生成getAdmins这类请求函数,如下所示:

// RequestConfig用来修饰下面的config,以让url为必填项
interface RequestConfig extends AxiosRequestConfig {
  url: NonNullable<AxiosRequestConfig["url"]>;
}

const instance = axios.create({
  timeout: 10000,
  baseURL: "/api",
});

const makeRequest = <T>(config: RequestConfig) => {
  return async () => {
    try {
      const response = await instance.request<T>(config);
      const { data } = response;
      return { data, err: null, response };
    } catch (err) {
      return { data: null, err, response: null };
    }
  };
};

/** 调用makeRequest,只需要做两步:
 *   1. 在泛型参数T中定义响应数据的类型
 *   2. 在形参中传入config以定义url和method,若method为get则可缺省
 */
const getAdmins = makeRequest<{ admins: string[] }>({
  url: "/admins-no-wrapper",
});

// 和之前的调用方式一样
async function requestAdmins() {
  const { data, err } = await getAdmins();
  if (err) return;
  setAdmins(data!.admins);
}

上面例子中,请求函数统一用makeRequest来生成,从而高效准确实现了规范请求函数的返回数据

1.3 处理后端返回的响应对象

在大多数实际开发中,后端不会把响应数据直接放在响应体里,而是放在一个对象(下称返回对象)的其中一个属性里,然后把返回对象放在响应体里返回给前端。返回对象往往包含三个大同小异的属性,分别如下所示:

image.png

  • code: number类型,用于存放状态代码,有时候 http 的状态码不能完全满足业务场景需求,需要额外定义一些状态码来说明请求失败的情况。这里设计 code 为 0 时代表请求通过。
  • data: 用于存放数据
  • message: string类型,用于存放额外信息。当 code 不为 0 时,需要 message 来提供更多说明信息以反馈给用户和开发者。

对此,我们还需要完善makeRequest来解构处理返回对象,如下所示:

// 先定义返回对象的声明类型
export interface BackendResultFormat<T = any> {
  code: number;
  data: T;
  message: string;
}

// 定义一个返回对象中code不为0时的错误
export class CodeNotZeroError extends Error {
  code: number;

  constructor(code: number, message: string) {
    super(message);
    this.code = code;
  }
}

const makeRequest = <T>(config: RequestConfig) => {
  return async () => {
    try {
      const response = await instance.request<BackendResultFormat<T>>(config);
      const res = response.data;
      // 当返回对象的code不为0时,生成CodeNotZeroError的实例存放到err里返回出去
      if (res.code !== 0) {
        const error = new CodeNotZeroError(res.code, res.message);
        return { err: error, data: null, response };
      }
      return { err: null, data: res.data, response };
    } catch (err) {
      return { data: null, err, response: null };
    }
  };
};

这样子就完成了处理返回对象的逻辑。

2. 请求参数的传入和约束

2.1 请求参数的传入

AxiosRequestConfig声明类型里的属性中,可以按确定时期分为两类:

  1. 配置属性:这部分参数在在调用makeRequest生成请求函数时就可以确定下来,例如method,url。之前的RequestConfig声明类型中会把配置属性定义为必填项。
  2. 调用属性:这部分参数在调用请求函数时可以确定下来,例如data,param
export interface AxiosRequestConfig<D = any> {
  // 配置属性
  url?: string;
  method?: Method | string;
  transformRequest?: AxiosRequestTransformer | AxiosRequestTransformer[];
  transformResponse?: AxiosResponseTransformer | AxiosResponseTransformer[];
  // ....

  // 调用属性
  params?: any;
  data?: D;
  cancelToken?: CancelToken; //用于中断上一次重复请求,0.27版本推荐用signal来代替该属性
  signal?: GenericAbortSignal; //用于中断上一次重复请求
  onUploadProgress?: (progressEvent: AxiosProgressEvent) => void; // 上传回调
  onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void; // 下载回调
  // ...
}

当然有些属性可能同时属于配置属性调用属性,例如headers,timeout

而本节中要在请求函数中支持传入的那些请求参数就是上面所说的调用属性。为了让定义调用属性时的做法和调用axios.request(config)一致,我们可以指定请求函数的形参为Partial<RequestConfig>声明类型。下面我们按照这个思路来实现:

// config用于指定配置属性,因此用RequestConfig修饰
// requestConfig用于指定调用属性或者覆盖配置属性,因此先用Partial<RequestConfig>修饰,在下面章节中会有所改动
const makeRequest = <T>(config: RequestConfig) => {
  return async (requestConfig?: Partial<RequestConfig>) => {
    // 最终两个config会进行浅合并
    const mergedConfig: RequestConfig = {
      ...config,
      ...requestConfig,
      // 对于headers需要特殊处理做深度合并
      headers: {
        ...config.headers,
        ...requestConfig?.headers,
      },
    };

    // 下面逻辑与之前一样
    try {
      const response = await instance.request<
        BackendResultFormat<T>,
        RequestConfig
      >(mergedConfig);
      const res = response.data;
      if (res.code !== 0) {
        const error = new CodeNotZeroError(res.code, res.message);
        return { err: error, data: null, response };
      }
      return { err: null, data: res.data, response };
    } catch (err) {
      return { data: null, err, response: null };
    }
  };
};

const getNames = makeRequest<{ names: string[] }>({
  url: "/names",
  method: "get",
});

const requestNames = async (search?: string) => {
  const { data, err } = await getNames({
    // 此处params等同于axios.request(config)中的config.params
    params: { search },
  });
  if (err) return;
  setNames(data!.names);
};

这样子就实现了请求函数的形参传入

2.2 请求参数的约束

上节中我们简单用Partial<RequestConfig>来修饰请求函数的形参,而RequestConfig继承于AxiosRequestConfig,我们先来看看AxiosRequestConfig这个声明类型:

export interface AxiosRequestConfig<D = any> {
  url?: string;
  method?: Method | string;
  baseURL?: string;
  headers?: AxiosRequestHeaders;
  params?: any;
  data?: D;
  // ..还有很多属性,不过不涉及到本章节分析因此省略
}

AxiosRequestConfig声明类型可知,如果直接用Partial<RequestConfig>修饰请求函数的形参有两个缺陷:

  1. config.data的约束不够严谨: 当我们在定义AxiosConfig的泛型参数D时,是对config.data进行类型约束,但由于?:的存在,data在不赋值的情况下不会报出ts的错误
  2. 缺乏对config.params的约束: 部分请求需要传入config.params,因此也需要对config.params进行类型约束,但AxiosConfig没有泛型参数去对此进行约束

为了解决上面存在的问题,我们对makeRequest设计一个类型声明MakeRequest,如下所示:

/**
 * 允许定义三个泛型参数:
 *    Payload为响应数据
 *    Data为请求体参数,对应config.data
 *    Params对应URL的请求参数,对应config.params
 *  对不同泛型值有不同的传参方式
 */
interface MakeRequest {
  // 当不定义泛型或只定义Payload泛型参数时,返回对象data为any或你定义的Payload
  <Payload = any>(config: RequestConfig): (
    requestConfig?: Partial<RequestConfig>
  ) => Promise<Payload>;
  // 当泛型参数Data被定义时,config和config.data不能为空
  <Payload, Data>(config: RequestConfig): (
    requestConfig: Partial<Omit<RequestConfig, "data">> & { data: Data }
  ) => Promise<Payload>;
  // 当泛型参数Params被定义时,config和config.params不能为空
  // 但如果Data为undefined时,config.data可以不填写
  <Payload, Data, Params>(config: RequestConfig): (
    requestConfig: Partial<Omit<RequestConfig, "data" | "params">> &
      (Data extends undefined ? { data?: undefined } : { data: Data }) & {
        params: Params;
      }
  ) => Promise<Payload>;
}

我们把MakeRequest声明类型加到makeRequest函数上,如下所示:

// 加上MakeRequest声明类型,其余保持不变
const makeRequest: MakeRequest = <T>(config: RequestConfig) => {
  //... 省略
};

下面来看一下在MakeRequest的作用下,加上泛型参数生成的请求函数,在调用过程中对请求参数校验的结果:

  • 在只定义Payload的情况下

    image.png

  • 定义PayloadData的情况下

    image.png

  • 定义PayloadDataParam的情况下

    image.png

  • 定义PayloadParam的情况下

    image.png

从上面的效果可知,我们已经完成了对请求参数的格式约束


下面说一下在一些复杂场景下,我们要怎么约束请求参数

2.2.1 提交Form表单

提交Form类型的数据时,按照之前的约束我们可以这么写:

export default const register1 = makeRequest<null, FormData>({
  url: '/register',
  method: 'post',
});

但这样我们只是指定了config.data必须是一个FormData类型的变量,但不能指定FormData中要有那些字段值。而在v0.27.0axios官方提供一种新的写法支持传FormData格式的数据,如下所示:

import axios from "axios";

axios
  .post(
    "https://httpbin.org/post",
    { x: 1 },
    {
      // 把Content-Type设为multipart/form-data后,axios内部会自动把{x: 1}对象转换为FormData类型的变量
      headers: {
        "Content-Type": "multipart/form-data",
      },
    }
  )
  .then(({ data }) => console.log(data));

详情可看官方文档此处,按照上面的写法,我们可以把开头的register1请求函数改成下面的写法:

export const register1 = makeRequest<
  null,
  // 这样子就可以定义FormData中需要的数据类型
  { username: string; password: string }
>({
  url: "/register",
  method: "post",
  headers: {
    // 按照上面的写法修改Content-type
    "Content-Type": "multipart/form-data",
  },
});

在调用时,控制台信息如下所示

image.png

image.png

2.2.2 上传文件

假设文件是通过<input type="file"/>获取的,那可以通过下面这个例子来了解:

export const uploadPhoto = makeRequest<null, { photo: FileList }>({
  url: "/photo",
  method: "post",
  headers: {
    "Content-Type": "multipart/form-data",
  },
});

我们可以通过以下代码例子来进行测试:

const Widget: FC<null> = () => {
  const [fileList, setFileList] = useState<FileList | null>(null);
  const [progress, setProgress] = useState<number>(0);

  const handleFileChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
    setFileList(e.target.files);
    setProgress(0);
  }, []);

  const upload = useCallback(() => {
    if (fileList) {
      uploadPhoto({
        data: {
          photo: fileList,
        },
        // 此处通过onUploadProgress属性监听上传进度
        onUploadProgress: function (progressEvent) {
          setProgress(
            Math.round((progressEvent.loaded / progressEvent.total) * 100)
          );
        },
      });
    }
  }, [fileList]);

  return (
    <>
      <input type="file" onChange={handleFileChange} />
      <br />
      <button onClick={upload}>上传文件</button>
      <br />
      {progress ? <div>上传进度:{progress}%</div> : null}
    </>
  );
};

效果如下所示:

upload-photo.gif

此时调用接口时请求参数如下所示:

image.png


总结:在基础篇中,我们编写了用于生成请求函数的高阶函数makeRequest。且通过ts的类型声明和泛型约束了请求参数和响应数据的格式。

深入篇

1. 了解Axios的拦截器实现原理

在实现深入篇所支持的特性前,这里需要介绍一下Axios拦截器的工作原理:

假设我们注册AB作为请求中的成功和失败拦截器,CD作为相应中的成功和失败拦截器。如下所示会放在对应的handlers中。

当调用axios或者axios.request发出请求时,就会运行Array.prototype.request方法。此时在Array.prototype.request中会定义一个数组类型的变量chainchain在执行过程中会有如下变化:

最终chain中的元素会双双取出放到promise链中,然后把promise链返回出去。

关于Axios这部分涉及到拦截器的源码分析可看我之前写的文章如何避免 axios 拦截器上的代码过多

除了知道所有注册的拦截器会放在promise链执行之外,我们需要知道,config会作为参数贯穿整个promise链,由此我们可以把所有的拦截器都注册在一个Axios实例上,然后在调用请求函数时,通过config中的属性来控制拦截器的是否执行和执行效果。

有人会提到新版本中的runWhen这个属性可以控制拦截器是否执行,但这个属性仅限于决定请求拦截器的执行与否,不能决定响应拦截器的执行与否。

2. 实现自动反馈请求结果

在接口响应后,如果响应失败或返回错误,前端会通过弹框反馈原因。而在一些有关增删改的接口在响应成功后,前端也会反馈操作成功的信息给用户。

如果我们在每个调用请求函数的函数里都写反馈逻辑,会非常累赘。而借助拦截器我们可以很巧妙地对所有接口统一添加上这层逻辑。

实现上述功能需要以下新增的属性:

  1. desc: 用来描述该接口的用途,以便用户通过desc来开启反馈,且知道弹出的反馈来自哪个请求的操作。
  2. notifyWhenSuccess: 用于手动关闭或开启该请求在响应成功后的反馈。get,head,option请求成功后默认不反馈,post,delete,put等涉及到增删改的接口在该请求成功后默认反馈。
  3. notifyWhenFailure: 用于手动关闭或开启该请求在响应失败后的反馈。默认任何请求失败后都会反馈。

以上属性都会添加到RequestConfig里,如下所示:

interface RequestConfig extends AxiosRequestConfig {
  url: NonNullable<AxiosRequestConfig["url"]>;
  // 添加属性
  desc?: string;
  notifyWhenSuccess?: boolean;
  notifyWhenFailure?: boolean;
}

借助上面的属性,我们可以通过以下方式开启反馈:

const  getSomething = makeRequest({
  url: '/something',
  // 当desc被填写时,会自动开启反馈,且desc中填写的内容会作为反馈信息
  desc: '这里时接口描述',
}),

// 调用时,可通过notifyWhenFailure和notifyWhenSuccess强制开启或关闭成功失败时的反馈
getSomething({
  // notifyWhenFailure: false
  // notifyWhenSuccess: false
})

根据上面的开启反馈方式,我们开始编写拦截器代码,如下所示:

// notify.tsx
import { notification } from "antd";
import type { AxiosResponse, AxiosError } from "axios";
import React from "react";

const notify = {
  response: {
    onFulfilled: (response: AxiosResponse<BackendResultFormat>) => {
      const { code, message } = response.data;
      const { desc, notifyWhenFailure, notifyWhenSuccess, method } =
        response.config as RequestConfig;
      // 如果desc被定义,则执行反馈逻辑
      if (desc) {
        // 对code为0的响应做成功反馈
        if (code === 0) {
          if (notifyWhenSuccess !== false) {
            if (
              ["delete", "put", "post"].includes(method?.toLowerCase() || "") ||
              notifyWhenSuccess === true
            ) {
              notification.success({
                message: `${desc}成功`,
              });
            }
          }
          // 针对code不为0的响应做失败反馈
        } else if (notifyWhenFailure !== false) {
          notification.error({
            message: `${desc}错误`,
            description: `原因:${message}`,
          });
        }
      }
      return response;
    },
    onRejected: (error: AxiosError<BackendResultFormat>) => {
      const { response, config } = error;
      // 对4xx,5xx状态码做失败反馈
      const { url, desc } = config as RequestConfig;
      if (desc) {
        if (response?.status && response?.statusText) {
          notification.error({
            message: `${desc}错误`,
            description: (
              <div>
                <div>
                  状态:{response.status}~{response.statusText}
                </div>
                <div>路径:{url}</div>
                {/*可能存在后端直接返回错误码,但没返回对象的情况*/}
                {response.data?.message && (
                  <div>原因:{response.data.message}</div>
                )}
              </div>
            ),
          });
        } else {
          // 处理请求响应失败,例如网络offline,超时等做失败反馈
          notification.error({
            message: `${desc}失败`,
            description: (
              <div>
                <div>原因:{error.message}</div>
                <div style={{ whiteSpace: "nowrap" }}>路径:{url}</div>
              </div>
            ),
          });
        }
      }
      return error;
    },
  },
};

// instance为axios.create生成的axios实例
instance.interceptors.response.use(
  notify.response.onFulfilled,
  notify.response.onRejected
);

下面我们来分情况测试一下拦截效果。先用makeRequest生成几个请求函数,其中都写上desc用于描述请求:

import makeRequest from "../request";

export default {
  getAdmins: makeRequest<{ admins: string[] }>({
    url: "/admins",
    desc: "获取管理员列表",
  }),

  register: makeRequest<null, { username: string; password: string }>({
    url: "/register",
    method: "post",
    desc: "注册新用户",
  }),

  updatePassword: makeRequest<null, { password: string }, { username: string }>(
    {
      url: "/password",
      method: "put",
      desc: "更换密码",
    }
  ),

  getDelay: makeRequest({
    url: "/delay",
    desc: "延时测试请求",
  }),
};
  1. get请求成功时默认不反馈,但我们可以通过设置notifyWhenSuccess来让其强制显示反馈,如下代码所示:
const getAdmins1 = async () => {
  getAdmins();
};

const getAdmins2 = async () => {
  // 强制让其反馈
  getAdmins({
    notifyWhenSuccess: true,
  });
};

效果如下所示:

get成功反馈.gif

  1. post,put,delete请求成功后默认反馈,如下代码所示:
const register1 = async () => {
  register({
    data: {
      username: "123",
      password: "123",
    },
  });
};

效果如下所示:

post成功反馈.gif

  1. 因响应状态码为 4xx 或 5xx 导致的请求失败时,会有弹窗反馈
const updatePassword1 = async () => {
  updatePassword({
    data: {
      // 缺乏password参数
    },
    params: {
      username: "root",
    },
  });
};

const updatePassword2 = async () => {
  updatePassword({
    data: {
      password: "123",
    },
    params: {
      // 当usernamr为Tom时,正常返回但返回对象里code不为0且message显示不存在该用户信息
      username: "Tom",
    },
  });
};

效果如下所示:

put失败反馈.gif

  1. 响应失败的反馈,例如超时
const requestDelayTest = async () => {
  getDelay({
    // 设置接口超时阈值为1s,此接口响应时间被设为2s,因此必定超时
    timeout: 1000,
  });
};

效果如下所示:

timeout.gif

  1. 请求失败的反馈,例如网络中断

network-error.gif

到此我们完成了请求反馈结果的功能。

3. 支持 URL 路径参数替换

存在部分URL需要在调用时要对路径中的路径参数进行替换,如/account/{username}在调用中,传入的usernamejenny时,发出请求的路径为/account/jenny,本章节我们就要对这部分内容进行支持。

我们这里设计成可以通过给请求参数传入args开启路径参数替换功能,如下所示:

// MakeRequest第四泛型参数用于定义args的类型
const getAccount = makeRequest<
  { id: string; name: string; role: string },
  undefined,
  undefined,
  { username: string }
>({
  url: "/account/{username}",
});

const getAccount = async () => {
  getAccount({
    // 在args中指定路径参数
    args: {
      username: "jenny",
    },
  });
};

接下来开始根据上面的开启方式实现该功能,首先我们要往RequestConfig声明类型里新增args属性,如下所示:

interface RequestConfig extends AxiosRequestConfig {
  url: NonNullable<AxiosRequestConfig["url"]>;
  desc?: string;
  notifyWhenSuccess?: boolean;
  notifyWhenFailure?: boolean;
  // 新增args属性
  args?: Record<string, any>;
}

与此同时对MakrRequest声明类型添加Args泛型参数:

interface MakeRequest {
  <Payload = any>(config: RequestConfig): (
    requestConfig?: Partial<RequestConfig>
  ) => Promise<ResultFormat<Payload>>;

  <Payload, Data>(config: RequestConfig): (
    requestConfig: Partial<Omit<RequestConfig, "data">> & { data: Data }
  ) => Promise<ResultFormat<Payload>>;

  <Payload, Data, Params>(config: RequestConfig): (
    requestConfig: Partial<Omit<RequestConfig, "data" | "params">> &
      (Data extends undefined ? { data?: undefined } : { data: Data }) & {
        params: Params;
      }
  ) => Promise<ResultFormat<Payload>>;
  // 加上如果带Args泛型参数的情况,同样的,如果指定Params或Data泛型参数为undefined,则可忽略不填
  <Payload, Data, Params, Args>(config: RequestConfig): (
    requestConfig: Partial<Omit<RequestConfig, "data" | "params" | "args">> &
      (Data extends undefined ? { data?: undefined } : { data: Data }) &
      (Params extends undefined
        ? { params?: undefined }
        : { params: Params }) & {
        args: Args;
      }
  ) => Promise<ResultFormat<Payload>>;
}

拦截器代码如下所示:

import { notification, Space, Typography } from "antd";

const urlArgsHandler = {
  request: {
    onFulfilled: (config: AxiosRequestConfig) => {
      const { url, args } = config as RequestConfig;
      // 如果args被定义,则执行路径参数替换逻辑
      if (args) {
        const lostParams: string[] = [];
        const replacedUrl = url.replace(/\{([^}]+)\}/g, (res, arg: string) => {
          if (!args[arg]) {
            lostParams.push(arg);
          }
          return args[arg] as string;
        });
        if (lostParams.length) {
          notification.error({
            message: "args参数缺少警告",
            description: (
              <div>
                <div>
                  内容:在args中找不到
                  <Space>
                    {lostParams.map((arg) => (
                      <Typography.Text key={arg} code>
                        {arg}
                      </Typography.Text>
                    ))}
                  </Space>
                  属性
                </div>
                <div>路径:{url}</div>
              </div>
            ),
          });
          return Promise.reject(new Error("在args中找不到对应的路径参数"));
        }
        return { ...config, url: replacedUrl };
      }
      return config;
    },
  },
};

instance.interceptors.request.use(urlArgs.request.onFulfilled, undefined);

最后通过代码进行测试,如下所示:

const getAccount1 = async () => {
  apis.standard.getAccount({
    args: {
      username: "jenny",
    },
  });
};

// 这里故意少写args部分参数看反馈效果
const getAccount2 = async () => {
  apis.standard.getAccount({
    args: {},
  });
};

效果如下所示:

  1. 当调用getAccount1时,从 URL 上可看出username已被替换成jenny

urlArgs-success.gif

  1. 当调用getAccount2时,由于args中缺乏username参数,因此会弹框显示且停止发出请求:

urlArgs-failure.gif

至此我们完成 URL 路径参数替换功能。

4. 支持接口限流

存在部分页面在交互过程中会瞬间对同一个接口发出大量的请求,而如果被请求的接口占用后端较多的计算资源,有可能会导致后端运行缓慢且响应超时。

对此,其中一种解决办法就是在前端通过代码限制请求并发数,又称接口限流。而这个特性借助axios拦截器可以轻易实现的。

这里我们把请求函数设计成可以通过传入limit开启限流替换功能,如下所示:

getRequest({
  // 限制该请求函数对应接口的前端并发数为2
  limit: 2,
});

下面我们来实现这种方式,首先依旧往RequestConfig声明类型里新增limit属性如下所示:

interface RequestConfig extends AxiosRequestConfig {
  url: NonNullable<AxiosRequestConfig["url"]>;
  desc?: string;
  notifyWhenSuccess?: boolean;
  notifyWhenFailure?: boolean;
  args?: Record<string, any>;
  // 新增limit属性
  limit?: number;
}

然后在拦截器上实现该功能,如下所示:

type ResolveFn = (value: unknown) => void;

const records: Record<string, { count: number; queue: ResolveFn[] }> = {};

const generateKey = (config: RequestConfig) => `${config.url}-${config.method}`;

const limiter = {
  request: {
    onFulfilled: async (config: RequestConfig) => {
      const { limit } = config;
      // 如果limit被定义,则执行限流逻辑
      if (typeof limit === "number") {
        const key = generateKey(config);
        if (!records[key]) {
          records[key] = {
            count: 0,
            queue: [],
          };
        }
        const record = records[key];
        record.count += 1;
        if (record.count <= limit) {
          return config;
        }
        // 把该请求通过await阻塞存储在queue队列中
        await new Promise((resolve) => {
          record.queue.push(resolve);
        });
        return config;
      }
      return config;
    },
  },
  response: {
    onFulfilled: (response: AxiosResponse<BackendResultFormat>) => {
      const config = response.config as RequestConfig;
      const { limit } = config;
      if (typeof limit === "number") {
        const key = generateKey(config);
        const record = records[key];
        record.count -= 1;
        if (record.queue.length) {
          record.queue.shift()!(null);
        }
      }
      return response;
    },
    onRejected: (error: AxiosError<BackendResultFormat>) => {
      const config = error.config as RequestConfig;
      const { limit } = config as RequestConfig;
      if (typeof limit === "number") {
        const key = generateKey(config);
        const record = records[key];
        record.count -= 1;
        if (record.queue.length) {
          record.queue.shift()!(null);
        }
      }
      return error;
    },
  },
};

最后通过代码例子查看效果,下面调用getDelay请求函数,该请求函数对应的接口要 2s 后才响应,这里分三种情况来查看效果:

//1. 没有限流的请求
const request1 = async () => {
  getDelay();
};

//2. 限制并发数为1的请求
const request2 = async () => {
  getDelay({
    limit: 1,
  });
};

//3. 限制并发数为2的请求
const request3 = async () => {
  getDelay({
    limit: 2,
  });
};

下面通过请求时间瀑布流来看上面三种情况的效果:

  1. 没有限流的请求:request1

image.png

  1. 限流数量为 1 的请求:request2

image.png

  1. 限流数量为 2 的请求:request3

image.png

至此我们完成请求限流功能。

拓展篇

1. 了解Axios适配器adpater的原理

在运用adpater之前,我们先要知道其原理。在拦截器原理的章节中,我们说到promise链,如下所示:

整个promise链中真正执行异步请求的是dispatchRequest环节,我们来看一下dispatchRequest的涉及到adpater的源码:

// lib/core/dispatchRequest.js
module.exports = function dispatchRequest(config) {
  // 这里忽略的代码是对config的调整...

  var adapter = config.adapter || defaults.adapter;

  return adapter(config).then(
    function onAdapterResolution(response) {
      // 这里忽略的代码是对config.transformResponse的调用...
      return response;
    },
    function onAdapterRejection(reason) {
      // 这里忽略的代码是对config.transformResponse的调用...
      return Promise.reject(reason);
    }
  );
};

在不传入config.adapter情况下,默认调用的是defaults.adapter。因此我们继续分析defaults.adapter的核心源码,注意此处分析的源码是在浏览器环境下执行的lib/adapters/xhr.js代码,如果是在node.js环境下则执行lib/adapters/http.js代码:

// lib/adapters/xhr.js
function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    var requestData = config.data;
    var requestHeaders = config.headers;
    var responseType = config.responseType;

    var request = new XMLHttpRequest();

    var fullPath = buildFullPath(config.baseURL, config.url);

    request.open(
      config.method.toUpperCase(),
      buildURL(fullPath, config.params, config.paramsSerializer),
      true
    );

    request.timeout = config.timeout;

    function onloadend() {
      // ... 忽略,里面会调用resolve和reject
    }

    if ("onloadend" in request) {
      request.onloadend = onloadend;
    } else {
      request.onreadystatechange = function handleLoad() {
        if (!request || request.readyState !== 4) {
          return;
        }

        if (
          request.status === 0 &&
          !(request.responseURL && request.responseURL.indexOf("file:") === 0)
        ) {
          return;
        }

        setTimeout(onloadend);
      };
    }

    request.onabort = function handleAbort() {};

    request.onerror = function handleError() {};

    request.ontimeout = function handleTimeout() {};

    if (!requestData) {
      requestData = null;
    }

    request.send(requestData);
  });
}

我们可知,在浏览器环境下default.adapter内部的核心操作是创建XHR实例,根据config进行部分配置修改和监听操作后,调用xhr.open进行异步请求。我们这里可以用类似lodash的高阶函数写法去增强adapter去实现更多功能,如下enchance所示,enhance最终也是返回一个和default.adapter同一声明类型的结果。

const request = makeRequest({
  url: "/something",
  adpater: enhance(axios.default.adapter),
});

2. 支持接口数据缓存

对于部分频繁请求且非常占用后端资源的查询接口,前端可以对其请求结果做缓存,在一定时间内的多次请求中,重复返回首次请求获取的数据,从而缓解后端服务器压力的同时,提高前端的页面渲染速度。

关于缓存的实现这里不打算自己写代码了,因为网上有很完美的轮子做推荐:axios-extensions#cacheadapterenhancer,该轮子基于LRU-cache做缓存。这里展示一下如何在本文设计的API 层架构中使用该轮子:

// 此instance为makeRequest里的instance
// 也可以把adpater放在requestConfig里,如:makeRequest({adapter}),不过此处我直接挂载到实例里
const instance = axios.create({
  timeout: 10000,
  baseURL: "/api",
  adapter: cacheAdapterEnhancer(axios.defaults.adapter!, {
    // 默认不缓存
    enabledByDefault: false,
  }),
});

我们通过此下面的代码例子来看看效果:

const Widget: FC<null> = () => {
  const [admins1, setAdmins1] = useState<string[]>([]);
  const [admins2, setAdmins2] = useState<string[]>([]);

  const getAdmins1 = useCallback(async () => {
    const { err, data } = await getAdmins();
    if (err) return;
    setAdmins1(data!.admins);
  }, []);

  const getAdmins2 = useCallback(async () => {
    const { err, data } = await getAdmins({
      // 此处配置用缓存
      cache: true,
    });
    if (err) return;
    setAdmins2(data!.admins);
  }, []);

  return (
    <Space direction="vertical">
      <div>
        默认请求:
        <Button type="primary" onClick={getAdmins1}>
          获取管理员
        </Button>
      </div>
      <div>{admins1.toString()}</div>
      <div>
        带缓存:
        <Button type="primary" onClick={getAdmins2}>
          获取管理员
        </Button>
      </div>
      <div>{admins2.toString()}</div>
    </Space>
  );
};

效果如下所示:

cache.gif

除了例子中的配置项,cacheadapterenhancer还提供了控制缓存提取次数和存在周期等功能,具体详细的可直接去官网查看。

关于cacheadapterenhancer的源码也非常简单,这里就不做分析了。

3. 支持接口自动重试

有时候因为网络或后端的影响,接口会偶尔请求失败,但再次请求则可成功。针对这种特殊的错误情况,前端可以添加接口错误后自动重试机制。

这里需要注意的是,不是所有的错误都要重试,大多数HTTP 状态码为 4xx 和 5xx 的错误是没必要重复请求的,因为结果都一样,例如访问信息却因为用户角色权限原因报403 Forbidden的错误,就没必要重试请求。目前我认为需要重复请求的错误原因有以下:

  1. 网络中断:存在媒体切换网络环境时存在短暂的网络中断,从而导致请求报错,此时可以再次尝试请求。
  2. 响应超时:存在部分查询请求比较消耗后端的资源,而在前端因超时中断请求连接时,后端的查询进程并未中断,且在完成查询后把数据放在类似redis的缓存上。此时如果前端再次请求时,可以迅速获取到数据。

虽然针对此机制也有现有的轮子axios-extensions#retryadapterenhancer,但该轮子是无论什么错误都会重试,不符合上面的观点。但该轮子的代码思路值得借鉴,因此我们来借鉴该轮子的源码,还是增强adapter的思路,来实现自己的一套接口重试机制

// 定义配置的声明类型
interface RetryAdapterOption {
  // 重试次数
  retryTimes: number;
  // 重试间隔时间
  retryInterval: number;
}

// 判断错误的方法,如果错误符合条件则重试
// 下面逻辑中,如果错误是超时或网络错误,则返回true
const judgeError = (error: any) => {
  return (
    error instanceof AxiosError &&
    (error.message.startsWith("timeout") ||
      error.message.startsWith("Network Error"))
  );
};

const retryAdapter = (
  adapter: AxiosAdapter,
  retryAdapterOption?: Partial<RetryAdapterOption>
) => {
  // retryTimes即重复请求次数默认为3次
  const retryTimes =
    retryAdapterOption?.retryTimes === undefined
      ? 3
      : retryAdapterOption?.retryTimes;
  // retryInterval即重复请求时间间隔默认为500
  const retryInterval =
    retryAdapterOption?.retryInterval === undefined
      ? 500
      : retryAdapterOption?.retryInterval;
  return (config: AxiosRequestConfig): Promise<AxiosResponse<any>> => {
    const { retry } = config as RequestConfig;
    // 如果config.retry被定义,则启用重试机制
    if (retry) {
      let count = 0;
      let finalRetryTimes = retryTimes;
      let finalRetryInterval = retryInterval;
      if (typeof retry === "object") {
        finalRetryTimes =
          typeof retry.retryTimes === "number" ? retry.retryTimes : retryTimes;
        finalRetryInterval =
          typeof retry.retryInterval === "number"
            ? retry.retryInterval
            : retryInterval;
      }
      // 核心函数,如果报错且错误符合条件,则调用自身
      const request = async (): Promise<AxiosResponse<any>> => {
        try {
          return await adapter(config);
        } catch (err) {
          if (!judgeError(err)) {
            return Promise.reject(err);
          }
          count++;
          if (count > finalRetryTimes) {
            return Promise.reject(err);
          }
          await new Promise((resolve) => {
            setTimeout(() => {
              resolve(null);
            }, finalRetryInterval);
          });
          return request();
        }
      };
      return request();
    } else {
      return adapter(config);
    }
  };
};

RequestConfig中添加retry属性,如下所示:

export interface RequestConfig extends AxiosRequestConfig {
  url: NonNullable<AxiosRequestConfig["url"]>;
  desc?: string;
  notifyWhenSuccess?: boolean;
  notifyWhenFailure?: boolean;
  limit?: number;
  args?: Record<string, any>;
  // 新增retry属性
  retry?: boolean | Partial<RetryAdapterOption>;
}

生成请求函数

export default {
  getDelayWithRetry: makeRequest({
    url: "/delay",
    desc: "延时请求测试",
    // 该接口的响应时间为2s,因此调用请求函数必定报超时错误
    timeout: 1000,
    // 定义retry。retry的类型也可以是{retryTimes: number;retryInterval: number;}
    retry: true,
    // 使用上面刚写的retryAdapter
    adapter: retryAdapter(axios.defaults.adapter!),
  }),

  get500Error: makeRequest({
    // 这是一个报HTTP状态码为500的错误
    url: "/500-error",
    desc: "500请求测试",
    // 定义重试,但因该错误不符合重试条件,因此不会重试
    retry: true,
    adapter: retryAdapter(axios.defaults.adapter!),
  }),
};

下面来编写代码进行测试,如下所示:

const Widget: FC<null> = () => {
  return (
    <Space direction="vertical">
      <div>
        延时请求测试:
        <Button onClick={() => apis.advanced.getDelayWithRetry()}>
          发出请求
        </Button>
      </div>
      <div>
        500错误请求测试:
        <Button onClick={() => apis.advanced.get500Error()}>发出请求</Button>
      </div>
    </Space>
  );
};

下面来分情况看看测试效果:

  1. 超时情况下,首次请求错误后,会依次间隔 0.5s 去重试直至三次请求都失败:

timeout-retry.gif

  1. 报状态码为 500 的情况下,会直接报错,不会重试:

500-retry.gif

  1. 网络错误情况下,也会和第 1 点一样重复请求三次:

network-error-retry.gif

至此,我们完成了接口自动重试机制。

后记

关于API 层架构的设计说明到此为止了,完整的API 层架构代码可看axios-using-docs/service。以上API 层架构是自己在企业中实践了三年不断完善出来的,还有些拦截器因为和业务比较耦合因此没有展示出来,但基本常用的基本都分享到文章里了,这篇文章写到这里就结束了,如果觉得有用可以点赞收藏,如果有疑问可以直接在评论留言,欢迎交流 👏👏👏。