我不想一开始就把 Axios 封装的太完美

0 阅读5分钟

我不想(也不能)一开始就把 Axios 封装的太完美,因为不同类型、不同复杂度的项目需要的能力不一样,不是所有项目都需要重试、refresh token、埋点、统一 loading 等诸如此类的功能。

所以我只处理了一些最常见的、最值得封装的功能,比如:

  • 统一创建请求实例,集中管理 baseURL、超时、默认请求头等
  • 请求拦截器自动携带 token
  • 响应拦截器区分 HTTP 成功/失败、业务成功/失败,并统一弹出错误提示、处理登录失效等情况
  • 封装 getpost 方法,供业务代码调用

所以本文的目标是先完成一套基础的封装链路,方便其他项目复用,或者在此基础上根据需要进行修改和拓展。

与后端的约定

根据我的经验,目前遇到比较多的后端接口返回数据格式如下,所以本文 Axios 的封装也以这个为前提。

interface ApiResponse<T> {
  code: number // 0 表示业务成功,非 0 表示业务失败
  message: string // 错误提示信息
  data: T // 业务失败时为 null
}

创建 Axios 实例

const http = axios.create({
  // 从环境变量中获取基础请求路径,让开发、测试、生产环境使用不同的路径
  baseURL: import.meta.env.VITE_API_BASE_URL ?? '',
  // 设置请求超时时间
  timeout: 10000,
  // 设置请求头
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
})

由于大部分请求都是使用 JSON 格式,所以这里设置了 Content-Type: application/json,如果涉及到文件上传下载等其他场景,可以再做处理。

请求拦截器

统一注入 Authorization:在请求拦截器中做的最多的事情,应该就是在请求头中携带 token 了。

http.interceptors.request.use((config) => {
  // 从 store 中获取 token
  const token = useAuthStore.getState().token

  if (!token) {
    return config
  }

  // 往请求头中添加 token
  const headers = AxiosHeaders.from(config.headers)
  headers.set('Authorization', `Bearer ${token}`)
  config.headers = headers

  return config
})

响应拦截器

HTTP 成功/失败

Axios 响应拦截器可以接收两个回调,用以区分 HTTP 请求本身的成功与失败。

http.interceptors.response.use(
  // HTTP 请求成功的回调(状态码 2xx)
  (response) => {
  },
  // HTTP 请求异常的回调(状态码非 2xx)
  (error: AxiosError<ApiResponse<null>>) => {
  },
)

业务成功/失败

HTTP 请求成功后,再进一步判断业务的成功/失败。

http.interceptors.response.use(
  // HTTP 请求成功的回调(状态码 2xx)
  (response) => {
    const responseData = response.data

    // 业务成功
    // 这里保持 axios 默认返回值不变,继续返回完整的 response
    // 在之后封装 get/post 方法时,可以按需决定是否向业务侧暴露 response.data
    if (responseData.code === 0) {
      return response
    }

    // 业务失败
    // 如果当前请求没有关闭错误提示,就弹出后端返回的 message
    if (responseData.message && shouldShowErrMsg(response.config as RequestConfig | undefined)) {
      message.error(responseData.message)
    }

    // 登录态已失效
    if (responseData.code === 401) {
      handleUnauthorized()
    }

    // reject 的是后端返回的业务响应对象
    // 业务代码可以在 catch 中直接拿到 {code/message/data}
    return Promise.reject(responseData)
  },
  // HTTP 请求异常的回调(状态码非 2xx)
  (error: AxiosError<ApiResponse<null>>) => {
  },
)

shouldShowErrMsg 是自己封装的一个方法,用于支持每个接口可以自行决定是否弹出统一的错误提示,因为有的接口可能不需要弹出错误提示,而是要自己在 catch 中做其他处理。

handleUnauthorized 也是自己封装的方法,用于在登录失效时,统一清空 token、用户信息、菜单树、权限等数据,并跳转登录页。

需要注意的是,状态码应该是与后端约定好的,而不是前端自定义的。

HTTP 请求本身失败

若 HTTP 请求本身是失败的,则需要针对不同情况,弹出不同的错误提示。

http.interceptors.response.use(
  // HTTP 请求成功的回调(状态码 2xx)
  (response) => {
  },
  // HTTP 请求异常的回调(状态码非 2xx)
  (error: AxiosError<ApiResponse<null>>) => {
    const requestConfig = error.config as RequestConfig | undefined

    // 有 response,说明请求已经到达服务端,服务端也返回了响应
    // 但 HTTP 状态码不是 2xx,所以 axios 仍然把它当作错误
    if (error.response) {
      // 服务端直接返回了 HTTP 401,说明当前请求已被鉴权层拦截,登录态已失效
      if (error.response.status === 401) {
        handleUnauthorized()
      }

      if (!shouldShowErrMsg(requestConfig)) {
        return Promise.reject(error)
      }

      const responseData = error.response.data
      // 优先使用后端返回的 message,其次才回退到 axios 自带的错误信息
      const errorMessage = responseData?.message || error.message || '请求失败'
      message.error(errorMessage)
      return Promise.reject(error)
    }

    if (!shouldShowErrMsg(requestConfig)) {
      return Promise.reject(error)
    }

    // 有 request 但没有 response,说明请求已经发出,但没有收到任何响应
    // 常见于断网、超时、跨域拦截等网络层问题
    if (error.request) {
      message.error('网络异常,请稍后重试')
      return Promise.reject(error)
    }

    // 既没有 response,也没有 request
    // 通常说明错误发生在“请求真正发出之前”,例如配置错误、参数处理异常等
    message.error(error.message || '未知错误')
    return Promise.reject(error)
  },
)

业务错误时,reject 的是后端返回的业务响应对象;而 HTTP 错误时,reject 的是 AxiosError

之所以返回的错误对象类型不统一,是因为某一个具体的业务可能要在 catch 中对错误做特殊处理,而 HTTP 错误通常只需要在框架层统一处理即可。

如果要统一上报错误日志,或者把错误转成自定义的错误对象,这一块可以继续改造。

封装 get、post 方法

为了让业务代码中的调用保持简单,还需要封装一下 get、post 方法。

export function get<T = unknown, P = Record<string, unknown>>(
  url: string,
  params?: P,
  config?: RequestConfig,
): Promise<ApiResponse<T>> {
  return http
    .get<ApiResponse<T>>(url, {
      ...config,
      params,
    })
    // 统一返回 response.data,让业务代码可以直接拿到后端返回的业务响应对象
    .then((response) => response.data)
}

export function post<T = unknown, D = unknown>(
  url: string,
  data?: D,
  config?: RequestConfig<D>,
): Promise<ApiResponse<T>> {
  return http
    .post<ApiResponse<T>>(url, data, config)
    .then((response) => response.data)
}

这样一来,业务代码里就可以这么调用:

export function getProfileApi(): Promise<ApiResponse<AuthProfile>> {
  return get<AuthProfile>('/api/auth/profile')
}

页面里拿到的就是统一结构的响应对象,就不用每次都写 .then((res) => res.data)

而且,经过测试,即使没有指定 getProfileApi() 的返回值类型,TS 也能推导出它的返回值类型为 Promise<ApiResponse<AuthProfile>>

完整代码

这是一个 React 项目模板,Axios 封装相关的代码可以查看 http 目录:github.com/donghao-doc…