request.ts: 一个基于 Axios 封装的异步请求函数

雷尚林  更新于:
创建于:

1 背景

在 Web 前端领域,有很多方法进行异步请求:xhr、fetch、axios、swr ...

不同方法混用,会把项目搞得一团乱,并且,同一个项目中,跟 API 有关的配置往往是相同的,比如:Domain、Timeout、Base URL、Authorization、异常处理 ...。

为了统一异步请求的行为、“抹平”不同方式之间的差异、减少重复代码的书写,我们理应在它们之上,封装一层通用的请求处理。

2 封装

以下是完整的、基于 Axios + TypeScript 的异步请求封装👇🏻,可以直接复制使用:

import axios, {
  AxiosError,
  type AxiosInstance,
  type AxiosRequestConfig,
  type AxiosResponse,
} from 'axios'
import { message as msg } from 'ant-design-vue'

export interface IResponseData<T> {
  data: T
  code: number
  success: boolean
  message?: string
}

export const getApiPrefix = () => import.meta.env.VITE_APP_API_PREFIX

/**
 * 接口响应值类型;
 * 当接口请求正常时,返回 T;
 * 当接口请求异常时,返回 undefined;
 */
export type ResponseType<T> = Promise<T | undefined>

/**
 * 基于 Axios 封装的 Http 请求类,在 Axios 的基础上,它还具有以下特性:
 * @特性1 在请求拦截器中处理授权;
 * @特性2 在响应拦截器中"分层处理"各种异常;
 * @特性3 接口不再返回 AxiosResponse, 而是返回 AxiosResponse.data.data,
 * 同时添加了一个"逃生舱",用于返回 AxiosResponse,即:request.request({ ..., withAxiosResponse: true });
 * @特性4 内置了取消请求的方法:request.createAbortController;
 */
export class HttpRequest {
  /**
   * 这是 Axios 实例;
   * 在想要获取 AxiosResponse 时使用,比如:
   * 它会返回 AxiosResponse 包裹的 ResponseType
   */
  readonly instance: AxiosInstance

  constructor(config: AxiosRequestConfig) {
    this.instance = axios.create(config)

    // 请求拦截
    this.instance.interceptors.request.use(
      (config) => {
        // 这里添加授权相关的信息
        return config
      },

      (error: AxiosError) => {
        msg.error(`${error}(${error.status})`)
        console.error(error)
      },
    )

    // 响应拦截
    this.instance.interceptors.response.use(
      (response: AxiosResponse & { config: { withAxiosResponse?: boolean } }) => {
        // 非JSON类型,直接返回
        if (!response.headers['content-type']?.toString().includes('application/json')) {
          if (!response.config.withAxiosResponse) {
            return response.data
          } else {
            return response
          }
        }

        const { success, code: svrCode, message: svrMsg } = response.data as IResponseData<unknown>

        if (!success) {
          // 第一层: 自定义错误提示及错误码
          msg.error(`${svrMsg}(${svrCode})`)
        } else if (svrMsg) {
          // 操作成功的提示,比如“登录成功”
          msg.info(svrMsg)
        }

        // 实际使用时,大多数情况都不会用到 response, 所以默认只返回 response.data
        // 又因为后端规范了 ResponseData 的数据结构: IResponseData 👆🏻, 所以只需要返回 response.data.data 即可
        if (!response.config.withAxiosResponse) {
          return response.data.data
        } else {
          return response
        }
      },

      (error: AxiosError) => {
        let message = ''

        // 第二层: 异常 HTTP Status 下的自定义错误提示及错误码
        if (error.response?.data) {
          const {
            code: svrCode,
            message: svrMsg,
            success,
          } = error.response.data as IResponseData<unknown>
          if (success === false) {
            message = `${svrMsg}(${svrCode})`
          } else {
            // Error 时,success 必为 false,若为 true,则为 BUG
            message = '你找到了一个 BUG,请尽快联系管理员!(BUG)'
          }
        }
        // 第三层: HTTP Status & Status Text
        else if (error.response) {
          switch (error.response.status) {
            case 400:
              message = '请求错误(400)'
              break
            case 401:
              message = '未授权,请重新登录(401)'
              // 可以在这里触发重新登录逻辑
              break
            case 403:
              message = '拒绝访问(403)'
              break
            case 404:
              message = '请求的资源不存在(404)'
              break
            case 405:
              message = '请求方法不允许(405)'
              break
            case 408:
              message = '请求超时(408)'
              break
            case 500:
              message = '服务器内部错误(500)'
              break
            case 501:
              message = '服务未实现(501)'
              break
            case 502:
              message = '网关错误(502)'
              break
            case 503:
              message = '服务不可用(503)'
              break
            case 504:
              message = '网关超时(504)'
              break
            case 505:
              message = 'HTTP 版本不受支持(505)'
              break
            default:
              message = `${error.response.statusText}(${error.response.status})`
          }
        }
        // 第四层: 请求无响应
        else if (error.message || error.code) {
          message = `${error.message}(${error.code})`
        }
        // 第五层: 未知异常
        else {
          message = `${error}(???)`
        }

        msg.error(message)
        console.error(error)

        // return undefined
      },
    )
  }

  /**
   * 创建一个取消控制器;
   * 用于在某些情况下取消请求;
   */
  createAbortController() {
    return new AbortController()
  }

  getUri(...params: Parameters<AxiosInstance['getUri']>): string {
    return this.instance.getUri.call(this, ...params) as string
  }

  request<T = unknown, R = AxiosResponse<T>, D = unknown>(
    config: AxiosRequestConfig<D> & { withAxiosResponse?: boolean },
  ): Promise<R> {
    return this.instance.request.call(this, config) as Promise<R>
  }

  get<T>(...params: Parameters<AxiosInstance['get']>): ResponseType<T> {
    return this.instance.get.call(this, ...params) as ResponseType<T>
  }

  post<T>(...params: Parameters<AxiosInstance['post']>): ResponseType<T> {
    return this.instance.post.call(this, ...params) as ResponseType<T>
  }

  put<T>(...params: Parameters<AxiosInstance['put']>): ResponseType<T> {
    return this.instance.put.call(this, ...params) as ResponseType<T>
  }

  patch<T>(...params: Parameters<AxiosInstance['patch']>): ResponseType<T> {
    return this.instance.patch.call(this, ...params) as ResponseType<T>
  }

  delete<T>(...params: Parameters<AxiosInstance['delete']>): ResponseType<T> {
    return this.instance.delete.call(this, ...params) as ResponseType<T>
  }

  postForm<T>(...params: Parameters<AxiosInstance['postForm']>): ResponseType<T> {
    return this.instance.postForm.call(this, ...params) as ResponseType<T>
  }

  putForm<T>(...params: Parameters<AxiosInstance['putForm']>): ResponseType<T> {
    return this.instance.putForm.call(this, ...params) as ResponseType<T>
  }

  patchForm<T>(...params: Parameters<AxiosInstance['patchForm']>): ResponseType<T> {
    return this.instance.patchForm.call(this, ...params) as ResponseType<T>
  }
}

export const request = new HttpRequest({
  baseURL: getApiPrefix(),
  timeout: 30 * 1000,
})

3 特性

3.1 无 try ... catch ...

没有恶心的 try ... catch ... 异常处理, 只需要判断返回值是否为 undefined,就能知道是否发生异常(因为 undefined 不是 JSON 类型,不会从后端返回 undefined),比如:

const todoList = await todoApi.query()
setList(todoList || [])

取而代之的,是在 interceptors.response 中统一处理异常。

3.2 异常分层

在 interceptors.response 中,异常的处理是“分层”的,优先级从高到底分别是:error.response.data.success === false > error.response.status > error.code > error

“异常分层”可以让开发者快速定位问题,也更利于测试的有效性。

3.3 响应值解构

在日常开发者,大多数情况下都不会用到异步请求的 header、status 等,所以在 interceptors.response 中,默认只返回 response.data。

又因为后端返回的 JSON 结构是经过 IResponseData<T> 统一包装过的,所以默认返回 response.data.data。

3.4 响应”逃生舱“

尽管大多数情况都不需要 header、status 等,但极个别的情况还是会用到,所以还是需要预留一个“口子”,用于获取这些信息,做法是使用 request.request({..., withAxiosResponse: true})

const res = await request.request({ /* ... */, withAxiosResponse: true});
if (res.headers['content-type'] === 'application/octet-stream') {
	// ...
}

3.5 取消请求

使用 request.createAbortController() 可以生成一个用于取消请求的 controller,它可以被多个接口使用,用于取消请求,这在一些性能至上的场景下会用上:

const controller = request.createAbortController()
request.get('/userInfo', { signal: controller.signal })

setTimeout(() => {
	controller.abort()
})

值得注意的是:取消的请求仍旧会在后端执行,“取消”只是一种客户端行为。

3.6 TS 类型友好

在 TS 中使用 Axios 时,往往会这样标注类型:

const res: AxiosResponse<IUserInfo> = await axios.get<IUserInfo>('xxx')
// res.data.nickname

经过封装后,响应即 Data,会更加简洁:

const userInfo = await axios.get<IUserInfo>('xxx')
// userInfo.nickname

4 Github 仓库

Github 链接:axios-request-ts