🎁 手把手教你如何进行 axios 的二次封装

454 阅读10分钟

前言:最近在学习 soybean 这个开源后台管理系统的设计风格,感觉受益匪浅。这周借鉴其中 axios 的封装思想,写出了符合自己公司项目中实际需求的 axios 实例,所以想分享一下自己整个封装的思路,顺便做一下笔记。

⚠️ Tips:本文不是 axios 的教程,我默认你对 axios 有一定的使用经验。

🎁:所有代码已放到 encapslate-axios-demo,并且使用 express 搭建了简单的后端服务供你自己理解本文内容,或者你也可以直接 copy 项目 /src/service/index修改为适合自己项目的 axios

一. 封装的意义

  1. 我们可以先想一想不封装 axios 难道我们就不能做请求了吗?当然不是,下面就是一个最基础的 axios 用法,它也能得到我们想要的结果。 image.png

  2. 这样不挺简单的吗?那我们考虑封装是为了什么?

  3. 在最开始接触前端的时候,那时候只写过一些自己的 demo 项目,我也不理解封装的含义,上面这种写法既简单又直接,何必搞那么麻烦呢?但随着工作年龄不断增长,所处公司业务不同,渐渐了解到发送一个请求这样一个看似简单的事情,背后需要考虑的事情实际上很多。比如开发中需要区分开发服务器测试服务器、角色鉴权、Token 刷新、请求成功时界面要做出提示等等这些操作无一不在影响着一个请求的发送。

  4. 并且上面这些事情有很大的重复性,如果对每一个请求都做同样的处理,那么就会无限 repeat 你自己,而封装的目的就是 DRY​​(​​Don't Repeat Yourself!​​)

  5. 我们就需要考虑,一个完整的 http 请求大致分为了请求开始前等待服务器响应得到响应后,这三个阶段。其中在客户端能影响的有两个部分请求开始前得到响应后。我们讲的所谓的“封装”,实际上指的就是对这两个阶段做一些逻辑处理。 image.png

  6. 接下来我将谈一谈一个项目刚创建后,我是如何思考为 axios 进行二次封装的。

二. 创建一个项目

  1. 我这里使用的是 vite + TS +Vue ,所以我选择了使用 vite 的模版

    pnpm create vite encapslate-axios --template vue-ts

  2. 安装 axios

    pnpm i axios

三. 配置环境变量

  1. 项目初期,我们通常需要考虑开发服务器线上服务器地址,所以我们会创建两个 env 文件。在 env.dev 中配置开发服务器地址,在 env.prod 写线上服务器。
    image.png

        // 根据自己实际项目地址进行替换下列内容
         VITE_DEV_URL=http://localhost:8765   //对应 .env.dev 文件
         VITE_PROD_URP=http://localhost:8765  //对应 .env.prod 文件
    
  2. 我们还需要告诉 TS 全局确保是有这两个变量的,所以我们需要补充 vite-env.d.ts 文件的内容。(或者你也可以自己在根目录下创建一个 .env.global.d.ts 文件) image.png

        interface ImportMeta {
          readonly env: ImportMetaEnv
        }
    
        interface ImportMetaEnv {
          readonly VITE_DEV_URL: string  // 声明开发环境下的 URL
          readonly VITE_PROD_URL: string // 声明生产环境下的 URL
          // more env variables...
    }
    

三. 准备一个 axios 模版

  1. 首先创建 service/request/index.ts 文件,我们马上动手来封装属于自己的 axios image.png
    我们创建一个基础模板。
    import axios from 'axios'

        import type { AxiosRequestConfig  } from 'axios'

        function createRequest(config: AxiosRequestConfig) {
          const instance = axios.create(config)
        }

2. 我的用法是,通过一个工厂函数,通过不同的配置来创造一个请求实例,用户在使用的时候,可以通过配置来适配不一样的场景,至于如何细分,就需要根据不同的项目结构需求来自定义设计了。

四. 封装 request 拦截器和 response 拦截器

  1. axios请求拦截器执行时机发生在请求之前。在这个阶段,我们可以为请求添加一些统一的操作,我们首先考虑请求有可能在特定条件被取消的情况(比如重复请求,token 过期)。这里我的思路是:在请求拦截器里为每一个请求创建一个 AbortController 实例,同时使用 nanoid 这个库创建一个唯一 id 来作为 AbortController 实例的 key。

    function createRequest<ResponseData = any>(config: AxiosRequestConfig) {
      const instance = axios.create(config)
    
      const abortControllerMap = new Map<string, AbortController>() //创建一个 abortController 的 map
      let requestId :string = nanoid()//存储唯一 Id
    
      instance.interceptors.request.use((conf: InternalAxiosRequestConfig<ResponseData>) => {
        const config: InternalAxiosRequestConfig = { ...conf } //1. 复制一份原有的 config 对象
        const aborter = new AbortController()
        requestId = nanoid()
    
        if (!config.signal) { 
          config.signal = aborter.signal
          abortControllerMap.set(requestId, aborter) // 将 aborter 设置到 Map 中
        }
    
        return config
      })
    }
    
    
  2. 相应地,我们应该在请求完成后,在响应拦截器里去删除 Map 中的数据。

        instance.interceptors.response.use(async (resp: AxiosResponse) => {
            console.log('响应拦截器', resp)
            console.log('resp.config', resp.config)
            if (abortControllerMap.get(requestId)) {
              abortControllerMap.delete(requestId)
            }
            return resp
          })
    
  3. 然后我们在 createRequest 内部声明两个函数 cancelRequestcancelAllRequests,分别用来取消单次请求和所有请求。

            // 取消特定请求
            function cancelRequest(requestId: string) {
            const aborter = abortControllerMap.get(requestId)
            if (aborter) {
            aborter.abort()
            abortControllerMap.delete(requestId)
            }
            }

            // 取消所有请求
            function cancelAllRequest() {
            abortControllerMap.forEach((aborter) => aborter.abort())
            }

4. 这里你可能会好奇 cancelRequest 该如何拿到特定的 requestId?我是这样设计的,首先我们需要当前文件夹下声明了一个常量 REQUEST_ID_HEADER_KEY(同样你可以放在 .env 文件里),然后在请求拦截器config 设置与这个值相同的请求头,并且将 nanoid 作为请求头的 value。 关于 cancelRequest这个函数什么时机调用,我们会在后面封装请求生命周期钩子中讲解。(Tips:因为这里设计了自定义相应头,所以可能需要和后端同学沟通解决跨域问题 比如需要在 Access-Control-Allow-Headers 添加对应的请求头信息)。
image.png

  1. 如果你和我一起书写到了这里,那么现在你的代码应该是这个样子。
            const REQUEST\_ID\_HEADER\_KEY = 'X-Request-Id'

            function createRequest(config: AxiosRequestConfig) {
             const instance = axios.create(config)

             const abortControllerMap = new Map<string, AbortController>() //创建一个 abortController 的 map
             let requestId: string = ''

             instance.interceptors.request.use((conf: InternalAxiosRequestConfig) => {
               const config: InternalAxiosRequestConfig = { ...conf } //1. 复制一份原有的 config 对象
               const aborter = new AbortController()
               requestId = nanoid()
               config.headers.set(REQUEST_ID_HEADER_KEY, requestId)

               if (!config.signal) {
                 config.signal = aborter.signal
                 abortControllerMap.set(requestId, aborter)
               }

               return config
             })

             instance.interceptors.response.use(async (resp: AxiosResponse) => {
               if (abortControllerMap.get(requestId)) {
                 abortControllerMap.delete(requestId)
               }
               return resp
             })

             // 取消特定请求
             function cancelRequest(requestId: string) {
               const aborter = abortControllerMap.get(requestId)
               if (aborter) {
                 aborter.abort()
                 abortControllerMap.delete(requestId)
               }
             }

             // 取消所有请求
             function cancelAllRequest() {
               abortControllerMap.forEach((aborter) => aborter.abort())
             }

             return { instance, cancelRequest, cancelAllRequest }
            }

五. 增加失败重试机制

  1. 关于失败重试的机制,我这里选用的是 axios-retry 这个库。

    pnpm i axios-retry

  2. 第一个参数它接收一个 axios 实例作为参数,第二个参数为配置项,可支持的配置项很多,这里不过多赘述,大家可以从 axios-retry 查看具体参数。
    image.png

六. 增加请求生命周期函数

  1. 请求发生后,我们可能会需要干预某些特定阶段,比如请求前我们需要为请求头配置 token,或者请求前在某些条件下需要取消本次请求,较为常见的场景就是取消重复请求,又或者在请求结果返回后,需要根据不同的请求状态去做出不同的逻辑处理。

  2. 此时需要考虑向后拓展,所以我们不直接使用 creatRequest 作为整个 api 的基础函数,而是在此之上进一步封装。这里我们首先声明了一个 interface RequestOption,其中有一个函数 onBeforeRequest,从名字就可以看出来,它是拿来干预请求前的一些行为。你可能会有疑问,这个函数内部逻辑直接放拦截器里不就可以了吗?何必多此一举?如果你的项目比较简单,那么你确实可以直接在拦截器里书写。但我们还是需要考虑到项目后期,不同的功能模块可能对于请求前的处理逻辑不同,所以我们需要把业务逻辑的处理解耦出去,交给使用者自行去拓展。
    image.png

  3. 关于 onBeforeRequest 的返回值为什么是需要 InternalAxiosRequestConfig?这是因为该函数有可能修改请求拦截器中的参数 config 的配置项,所以需要和 axios 请求拦截器的返回值保持一致。

  4. 并且我们还需要考虑,使用者可能不会使用这些配置项,所以我们需要提前准备一份默认值,如果使用者传递了相应的配置项,则覆盖默认值。

    function createRequestOption(option?: RequestOption) {
    const defaultOptions: RequestOption = {
    onBeforeRequest: async (config) => config,
    }
    return Object.assign(defaultOptions, option)
    }

5. 然后在 createRequest 里解构使用,将请求拦截器的参数传递给它即可。
image.png

  1. 你还可以自行封装自己的生命周期函数在配置项里,我这边主要是提供我自己的设计思路,不过我会在最后的代码阶段贴上一份直接复制即可使用的代码。

七. 封装统一返回格式的请求函数

  1. 有了上面的基础 createRequest 函数,我们接下来就可以封装真正要用到项目中的一个函数,我期望这个函数可以有统一的返回格式,这样可以统一开发团队的书写风格,并且减少开发维护的成本。

  2. 这是具体的代码,逻辑比较简单,只是使用 try catch 将返回结果做了一层统一格式,这边就不过多解释了。

    // 这是我们后端统一的返回格式
    type BaseResponse<T> = {
    code: number
    status: number
    message: string
    data: T
    }

            function createFlatRequest(config: AxiosRequestConfig, options?: RequestOption) {
              const { instance, cancelRequest, cancelAllRequest } = createRequest(config, options)

              async function flatRequest<T = any>(config: AxiosRequestConfig) {
                try {
                  const response: AxiosResponse = await instance(config)
                  return {
                    data: (response.data as BaseResponse<T>).data,
                    error: null,
                    response,
                  }
                } catch (error) {
                  return { data: null, error, response: (error as AxiosError).response }
                }
              }

              flatRequest.cancelRequest = cancelRequest // 将取消函数作为属性赋值给 flatRequest 本身
              flatRequest.cancelAllRequest = cancelAllRequest
              return flatRequest
            }

八. 使用案例

  1. 此时我们 axios 功能基本上完善了,我们就可以通过 createFlatRequest 去创造一个具体发送请求的统一 api
    image.png

  2. service 的用法就和普通的 api 请求一样。

    export async function getUserInfo() {
    const resp = await service<{ name: string; age: number }>({
    url: '/user',
    })
    console.log('回复', resp)
    return
    }

3. 这里我自己写了一个简单的 http-server,可以看到相应的请求头也有了。
image.png

  1. 我们再来测试如果没有 token ,请求是否会被取消掉。
    image.png 可以看到请求确实没有发生出去,且在 catch 捕获到的错误也正确展示为 canceledError
    4.gif

  2. 测试重试机制,我现在把服务端停掉,看看我们的请求是否可以被正确重试,可以看到重试也可以正常工作。
    5.gif

七. 最终源码

import axios from 'axios'
import { nanoid } from 'nanoid'
import axiosRetry from 'axios-retry'

import type { AxiosResponse, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'
import type { AxiosError } from 'axios'

const REQUEST_ID_HEADER_KEY = 'X-Request-Id'
const YOUR_BACKEND_SUCCESS_CODE = 200

interface RequestOption {
  onBeforeRequest: (
    config: InternalAxiosRequestConfig
  ) => Promise<InternalAxiosRequestConfig> | InternalAxiosRequestConfig
  isRequestSuccess(resp: AxiosResponse): boolean
  onRequestSuccess: () => any
  onRequestFaild: (resp: AxiosResponse) => any
  onResponseBack: (response: AxiosResponse) => AxiosResponse | Promise<AxiosResponse>
}

function createRequestOption(option?: Partial<RequestOption>) {
  const defaultOptions: RequestOption = {
    onBeforeRequest: async (config) => config,
    isRequestSuccess: (response) => true,
    onRequestSuccess: async () => {},
    onRequestFaild: async (response) => {},
    onResponseBack: async (response) => response,
  }
  return Object.assign(defaultOptions, option)
}

function createRequest(config: AxiosRequestConfig, options?: Partial<RequestOption>) {
  const instance = axios.create(config)
  axiosRetry(instance, {
    retries: 3,
    retryDelay: () => 2000,
  })
  const abortControllerMap = new Map<string, AbortController>() //创建一个 abortController 的 map
  let requestId: string = ''
  const { onBeforeRequest, isRequestSuccess, onRequestSuccess, onRequestFaild, onResponseBack } =
    createRequestOption(options)

  instance.interceptors.request.use((conf: InternalAxiosRequestConfig) => {
    const config: InternalAxiosRequestConfig = { ...conf } //1. 复制一份原有的 config 对象
    const aborter = new AbortController()
    requestId = nanoid()
    config.headers.set(REQUEST_ID_HEADER_KEY, requestId)

    if (!config.signal) {
      config.signal = aborter.signal
      abortControllerMap.set(requestId, aborter)
    }

    return onBeforeRequest(config)
  })

  instance.interceptors.response.use(async (resp: AxiosResponse) => {
    if (abortControllerMap.get(requestId)) {
      abortControllerMap.delete(requestId)
    }
    const isSuccess = isRequestSuccess(resp)
    if (isSuccess) {
      await onRequestSuccess()
    } else {
      await onRequestFaild(resp)
    }
    return onResponseBack(resp)
  })

  // 取消特定请求
  function cancelRequest(requestId: string) {
    const aborter = abortControllerMap.get(requestId)
    if (aborter) {
      aborter.abort()
      abortControllerMap.delete(requestId)
    }
  }

  // 取消所有请求
  function cancelAllRequest() {
    abortControllerMap.forEach((aborter) => aborter.abort())
  }

  return { instance, cancelRequest, cancelAllRequest }
}

type BaseResponse<T> = {
  code: number
  status: number
  message: string
  data: T
}

function createFlatRequest(config: AxiosRequestConfig, options?: Partial<RequestOption>) {
  const { instance, cancelRequest, cancelAllRequest } = createRequest(config, options)

  async function flatRequest<T = any>(config: AxiosRequestConfig) {
    try {
      const response: AxiosResponse = await instance(config)
      return {
        data: (response.data as BaseResponse<T>).data,
        error: null,
        response,
      }
    } catch (error) {
      return { data: null, error, response: (error as AxiosError).response }
    }
  }

  flatRequest.cancelRequest = cancelRequest
  flatRequest.cancelAllRequest = cancelAllRequest
  return flatRequest
}

export const service = createFlatRequest(
  {
    baseURL: import.meta.env.MODE === 'dev' ? import.meta.env.VITE_DEV_URL : import.meta.env.VITE_PROD_URL,
  },
  {
    onBeforeRequest: (config) => {
      const token = 'ceshi'
      if (!token) {
        const requestId = config.headers.get(REQUEST_ID_HEADER_KEY)
        if (requestId) service.cancelRequest(requestId as string)
      }
      config.headers.set('Authorization', token)
      return config
    },
    isRequestSuccess(resp) {
      const successCode = resp.data.code
      return successCode === YOUR_BACKEND_SUCCESS_CODE //你们项目后端返回中的业务状态码
    },
    onRequestSuccess() {
      // 可以设置一个全局统一提醒
    },
    onRequestFaild(resp) {
      console.log('resp', resp)
      // 可以判断后端返回的结果,来判断是否退出登录状态
    },
  }
)