结合实际业务场景的axios配置项添加

1,028 阅读13分钟

本篇文档将结合各种业务场景,带你学习到以下内容:

  1. axios的简单封装
  2. 我们为什么需要API层
  3. 给axios添加自定义配置项
  4. 增加配置项,缓存重复请求
  5. 增加配置项,重复请求归一
  6. 增加配置项,接口loading相关处理
  7. 增加配置项,中断重复请求

axios的封装

//文件路径: @/services/http

import axios from 'axios'

// 请求拦截器
axios.interceptors.request.use(
  config => {
   //do something...
  },
  error => {
   //do something...
  }
)

// 响应拦截器
axios.interceptors.response.use(
  res => {
    //do something...
  },
  error => {
    //do something...
  }
)

const get = (url = ``, params = {}, config) => {
  return setRequest(axios.request, { url, params, method: `get`, ...config })
}

const post = (url = ``, params = {}, config) => {
  return setRequest(axios.request, { url, params, method: `post`, ...config })
}

const gptGet = (url = ``, params = {}, config) => {
   return get(url, params, { baseURL: import.meta.env.VITE_BASE_URL_GPT_NODE })
}

const gptPost = (url = ``, params = {}, config) => {
   return post(url, params, { baseURL: import.meta.env.VITE_BASE_URL_GPT_NODE })
}

以上是axios封装的一个简单示例。setRequest函数 我们稍后介绍。暂且把他当做一个等同于axios.request的调用即可。

这里值得一提的是,我们在调用后台请求的时候 可能会有不同的baseUrl,也会有不同的请求类型(get,post等)。我这里是针对不同的baseUrl 和 请求类型,都封装了一个调用函数。

API层的封装

这里我觉得需要重点说明一下。很多人对于这一层的概念都是停留在 别人这么写 我也这么写,为什么要这样写却从来没有思考过。这一层的封装是可以做好多事情的,下面我会举几个例子来说明。

​ 相信很多人都遇到这样一个场景。有这样有一个数组 [1,2,3]。后台要求你改为字符串"1,2,3"传给他,他将来也会返给你一个字符串。但是前端回显是需要数组的,这样就导致 每次用到这个接口的时候 我都要写逻辑将字符串数组相互转换。我们完全将这些逻辑封装到一个函数内。我们调用接口的时候就调用这个函数。例如这样:

const getData = async (params)=>{
    //处理传入参数
    params.ids = params.ids.join()
    //接口调用
  	const data = await axios.request(params)
    //处理返回参数
    data.ids = data.ids.split()
    return data
}

​ 然后你可能还会遇到这样一个场景。后台告诉你新增和修改用同一个接口。传参的区别就是传不传id。我直接去这样掉接口太不优雅了,最直接的问题就会导致我们的代码语义不明确。为了避免这些问题 我们就可以再API层对接口二次封装。虽然是同一个接口 但是我们写两个函数,给业务层进行调用。这样代码看起来就会清晰很多。

下面是一个我的项目中一个API层的简单封装。我是用class封装的,这样拓展性更高一些。当然你直接用对象封装也是完全没问题的。

//文件路径: @/services/table

import { post } from '@/services/http'

class FileApi {
  static url = {
 	uploadFile:'/file/upload', 	//上传文件
 	deleteFile:'/file/delete',	//删除文件
  }
  
  /** 上传文件 */
  static uploadFile(params,config) {
    return post(this.url.uploadFile, params, config)
  }
  
  /** 删除文件 */
  static deleteFile(params,config) {
    return post(this.url.deleteFile, params, config)
  }
}

使用的时候是这样的

FileApi.uploadFile({我是接口参数},{我是接口配置项})

给axios添加自定义配置项

有时候我们可能会遇到这样一个场景。

后端让我传一个文件过去并且带上用户id 团队id等这些参数。这时候就必然要用到formData的数据结构了。大概这么写:

const params = {
    userId:'123',
    deptId:'456'
    //等更多参数
}
//转化formData结构
const formData = new FormData()
formData.append(file, file)
for (const key in params) {
   formData.append(key, params[key])
}
//接口调用
FileApi.uploadFile(formData)

但是每次遇到这种情况都这么写感觉有点麻烦,我可不可以这样呢?

const params = {
    userId:'123',
    deptId:'456'
    //等更多参数
}
FileApi.uploadFile(
    {
     	file: file,
     	...params
    }, 
    { formData:true }
)

我只要在axios的config参数中传入了formData为true,他就自动帮我转化为formData的数据结构。想实现也非常的简单,这就要用到我们刚才没有讲的setRequest函数了。

setRequest(axios.request, { url, params, method: `post`, ...config })

const setRequest = (callBack,config) =>{
    	//处理formData结构
       if (config.formData) {
          const formData = new FormData()
          for (const key in params) {
            formData.append(key, params[key])
          }
          params = formData
        }
        //配置不同请求方式的不同传参
        if ([`put`, `post`, `delete`, `path`].includes(config.method as string)) {
          config.data = params
          config.params = {}
        } else {
          config.params = params
        }
    	return	callBack(config)
}

可以看到,代码逻辑是非常简单的。就是简单的判断一下config里面有没有传formData为true。然后整理数据结构即可。

值得一提的是下面的这段代码。

 //配置不同请求方式的不同传参
if ([`put`, `post`, `delete`, `path`].includes(config.method as string)) {
    config.data = params
    config.params = {}
} else {
    config.params = params
}

这是axios的一个特性。根据不同的请求方式 他接收参数的属性是不同的。因此我们做了相关的配置来适应不同的请求类型。

那么这样一个最简单的配置项封装就完成了。下面我再讲几个例子,给你们看一下一些其他场景的简单封装。

const setRequest = (callBack,config) =>{
     	const { deptInfo } = useMainStore()
        
        //配置公共参数
        let params = { currentDeptId: deptInfo?.deptId, ...config.params }
         
        //处理formData结构
        if (config.formData) {
          const formData = new FormData()
          for (const key in params) {
            formData.append(key, params[key])
          }
          params = formData
        }
    
        //配置不同请求方式的不同传参
        if ([`put`, `post`, `delete`, `path`].includes(config.method as string)) {
          config.data = params
          config.params = {}
        } else {
          config.params = params
        }
    
        //配置公共请求头
        config.headers = { currentDeptId: deptInfo?.deptId }
                          
        return	callBack(config)
}

这里我新增了 全局公共参数 与 全局公共请求头的配置。

结合上面的代码我们可以看出来 这些逻辑其实都是针对config的一个修改。因此我们将关于config的修改逻辑全封装到一个函数中,减少我们setRequest中的代码,方便处理后续更复杂的操作。

 /** 配置axios的公共函数 */
  const setRequest: SetRequest = async (callBack, config) => {
    return  callBack(setPublicConfig(config))
  }
  
 /** 设置公共配置处理 */
  const setPublicConfig = (config: RequestConfig): RequestConfig => {
    const { deptInfo } = useMainStore()
    //配置公共参数
    let params = { currentDeptId: deptInfo?.deptId, ...config.params }
    if (config.formData) {
      const formData = new FormData()
      for (const key in params) {
        formData.append(key, params[key])
      }
      params = formData
    }
    //配置不同请求方式的不同传参
    if ([`put`, `post`, `delete`, `path`].includes(config.method as string)) {
      config.data = params
      config.params = {}
    } else {
      config.params = params
    }
    //配置公共请求头
    config.headers = { currentDeptId: deptInfo?.deptId }
    return config
  }

简单的封装就讲到这里了,下面我将会结合一些实际的业务场景来介绍一些更复杂的配置项。

缓存重复请求

现在有这样一个业务场景。

在我们开发的系统中,后台可能会给前端一个纯后端维护的字典信息。

例如 一个字段有哪些类型,这些类型都对应的key是什么。前端要根据后台返回的key回显字段类型的中文名。

但是这些信息,我们系统一加载就请求存入缓存又完全没必要,因为并不是每次进系统都会用到。

更多的时候 我们需要首次用到的时候再去请求,第二次用到的时候可以直接拿到上次的返回结果,优化用户体验。代码实现如下

 //缓存重复请求
 const requestCached = new Map()
 const setRequestCached: SetRequest = async (callBack, config) => {
    //通过url和接口传参判断是否为同一个接口(接口传参中几乎不会出现传函数的情况,因此直接转json比较即可)
    const key = config.url + JSON.stringify(config.params) + JSON.stringify(config.data)
    if (config?.isCached) {
      if (requestCached.has(key)) {
        return requestCached.get(key)
      } else {
        const data = await callBack(config)
        requestCached.set(key, data)
        return data
      }
    } else {
      return callBack(config)
    }
 }

下面将这段逻辑放到我们的 setRequest函数中

  const setRequest: SetRequest = async (callBack, config) => {
    return  setRequestCached(callBack, setPublicConfig(config))
  }

使用起来就是这样

FileApi.uploadFile({我是接口参数},{ isCached:true })

重复请求归一

现在有这样一个业务场景。

我有一个封装好的业务组件。当这个组件加载的时候 我会调用某个接口渲染这个组件的某些内容。

然后有一天产品经理告诉你 有个地方需要同时展示好多个这个组件。这样就会出现一个接口同一时间调用好多次的问题。这不管是对用户体验还是服务器的压力都是很不友好的。

改组件吗?太麻烦了!我们可不可以这样呢?接口A发出后 到完成的这个时间内,如果还有接口A发送并且参数一样,那么我们后发出的请求则不再到服务器请求,而是等第一次发出的接口A返回的时候 一起返回结果。

这样就完美的解决了上述的问题。代码如下:

//缓存正在进行的请求
  const ongoingRequests = new Map()
  const uniqueRequestPerKey = async (callBack, config) => {
    //通过url和接口传参判断是否为同一个接口(接口传参中几乎不会出现传函数的情况,因此直接转json比较即可)
    const key = config.url + JSON.stringify(config.params) + JSON.stringify(config.data)
    if (config.isUniqueRequest && ongoingRequests.has(key)) {
      return ongoingRequests.get(key)
    } else {
      const promise = callBack(config)
      ongoingRequests.set(key, promise)
      promise.finally(() => {
        ongoingRequests.delete(key)
      })
      return promise
    }
  }

下面将这段逻辑放到我们的 setRequest函数中。并考虑到setRequestCached函数的情况。代码如下

  const setRequest: SetRequest = async (callBack, config) => {
    config = setPublicConfig(config)
    const requestFunc = (_config: RequestConfig) => setRequestCached(callBack, _config)
    return  uniqueRequestPerKey(requestFunc, config)
  }

使用起来就是这样

FileApi.uploadFile({我是接口参数},{ isUniqueRequest:true })

接口loading相关处理

这个问题正在我的另一篇博客中有详细的讲解

axios和loading不得不说的故事 - 掘金 (juejin.cn)

不过这篇文章我只分享了 在拦截器中添加loading相关处理。没有给出函数式写法 也就是和我们的setRequest函数结合的实际代码。下面是结合后的代码(setLoading和deleteLoading函数具体怎么来的这里就不细讲了)。

  const setRequest = async (callBack, config) => {
    setLoading(config)
    config = setPublicConfig(config)
    try {
      const requestFunc = (_config: RequestConfig) => setRequestCached(callBack, _config)
      const response = await uniqueRequestPerKey(requestFunc, config)
      deleteLoading(config)
      return response
    } catch (error) {
      deleteLoading(config)
      return Promise.reject(error)
    }
  }

使用起来大概就是这个样子

loading = ref(false)
FileApi.uploadFile({我是接口参数},{ loading })

中断重复请求

现在有这样一个页面。左右分栏布局

左边是一个任务列表,点击左侧列表中的某一个任务,前端调用接口,根据接口返回值。 右侧展示这个任务的详情。

这样的场景可能会遇到这些问题。

  1. 我鼠标不停的点左侧的某一个任务。查询详情的接口就会一直调用。这样一方面会增加服务器压力。另一方面,因为浏览器有接口最大并发数的限制。同时调用过多的接口会导致接口阻塞。导致返回十分缓慢。防抖,或者请求中不允许再次点击能解决吗?能,但是都很麻烦。而且还要考虑到点了任务A ,不管怎么样,你不能阻止他去点任务B的情况。
  2. 接口 返回时机 和 发出时机 是没有任何关系的。例如我先点了任务A。又点了任务B。任务A十秒钟后才返回,任务B一秒就返回了,你的代码中如果没有做特殊处理,那么你的右侧很可能会先显示任务B的详情,然后过一会就会变成任务A的内容。这种情况肯定是不能让他出现的。

怎么解决呢?如果同一个接口 我不管他的传参如何。只要这个接口没有调用完成,又来了一个相同的接口。那么我就把上一个请求直接给中止掉。重新请求新的。

这样就完美的解决了上述的问题。代码如下:

// 定义一个Map来存储请求列表
const requestList = new Map();
// 定义一个函数来取消重复的请求
const cancelDuplicateRequest = (config) => {
  // 检查配置中是否有isCancelToken属性,如果有,则进入逻辑
  if (config.isCancelToken) {
    // 使用axios的CancelToken来创建取消令牌
    const CancelToken = axios.CancelToken;
    const source = CancelToken.source();
    // 将取消令牌分配给配置的cancelToken属性
    config.cancelToken = source.token;
    // 获取请求的URL
    const requestUrl = config.url as string;
    // 检查请求列表中是否已经有这个URL
    if (requestList.has(requestUrl)) {
      // 如果有,则取消之前的请求
      requestList.get(requestUrl).source.cancel({ config: requestList.get(requestUrl).config });
    } else {
      // 如果没有,则在请求列表中为这个URL设置一个空对象
      requestList.set(requestUrl, {});
    }
    // 将取消令牌和配置存储在请求列表中的URL键下
    requestList.get(requestUrl).source = source;
    requestList.get(requestUrl).config = config;
  }
};

注意:这个函数的使用方式就和前面的不一样了!

因为这个功能要中断上一次的请求,所以在setRequest函数中就很难实现。所以我们会把这个函数放到拦截器里面。大概实现是这样的:

axios.interceptors.request.use(
  config => {
    //取消重复请求
    cancelDuplicateRequest(config)
    return config
  },
  error => {
    return error
  }
)

用起来也和前面的方式没什么区别

FileApi.uploadFile({我是接口参数},{ isCancelToken:true })

完整代码实现(TS版本)

config的类型文件

import { AxiosRequestConfig, AxiosError, AxiosResponse } from 'axios'
import type { Ref } from 'vue'

type RequestConfig = {
  /** 传入的组件loading (一般用于控制按钮的loading) */
  loading?: Ref<boolean>
  /** 传入的dom实例 用于生成局部loading */
  dom?: HTMLElement
  /** 是否添加接口未返回再次请求关闭上次请求的功能 */
  isCancelToken?: boolean
  /** 是否缓存 */
  isCached?: boolean
  /** 是否为单次请求。实现了同一时间多次调用同一个接口。只触发一次网络请求 */
  isUniqueRequest?: boolean
  /** 是否将数据转换为formData结构 */
  formData?: boolean
  /** 下载文件的文件名 */
  fileName?: string
} & AxiosRequestConfig

type Response = {
  config: RequestConfig
} & AxiosResponse

export type { RequestConfig, AxiosError, Response }

axios配置项hooks封装

import axios from 'axios'
import { createApp } from 'vue'
import { notification } from 'ant-design-vue'
import { useMainStore } from '@/store'
import PartLoading from '@/components/loading/PartLoading.vue'
import type { AxiosError, AxiosInstance } from 'axios'
import type { RequestConfig } from '@/types/services/http'

export default function (_axios: AxiosInstance) {
  /** 使用WeakMap类型的数据 键名所指向的对象可以被垃圾回收 避免dom对象的键名内存泄漏 */
  const loadingDom = new WeakMap()
  /** 添加loading */
  const setLoading = (config: RequestConfig) => {
    //添加按钮的loading
    if (config.loading) {
      config.loading.value = true
    }
    const loadingTarget = config.dom
    if (loadingTarget === undefined) return
    const loadingDomInfo = loadingDom.get(loadingTarget)
    if (loadingDomInfo) {
      loadingDomInfo.count++
    } else {
      const appExample = createApp(PartLoading)
      const loadingExample = appExample.mount(document.createElement(`div`)) as InstanceType<typeof PartLoading>
      loadingTarget.appendChild(loadingExample.$el)
      loadingExample.show(loadingTarget)
      loadingDom.set(loadingTarget, {
        count: 1, //记录当前dom的loading次数
        appExample,
      })
    }
  }
  /** 删除loading */
  const deleteLoading = (config: RequestConfig) => {
    //关闭组件的loading状态
    if (config.loading) {
      config.loading.value = false
    }
    const loadingTarget = config.dom
    if (loadingTarget === undefined) return
    const loadingDomInfo = loadingDom.get(loadingTarget)
    if (loadingDomInfo) {
      if (--loadingDomInfo.count === 0) {
        loadingDom.delete(loadingTarget)
        loadingDomInfo.appExample.unmount()
      }
    }
  }

  //取消重复请求
  const requestList = new Map()
  const cancelDuplicateRequest = (config: RequestConfig) => {
    if (config.isCancelToken) {
      const CancelToken = axios.CancelToken
      const source = CancelToken.source()
      config.cancelToken = source.token
      const requestUrl = config.url as string
      if (requestList.has(requestUrl)) {
        requestList.get(requestUrl).source.cancel({ config: requestList.get(requestUrl).config })
      } else {
        requestList.set(requestUrl, {})
      }
      requestList.get(requestUrl).source = source
      requestList.get(requestUrl).config = config
    }
  }

  //缓存重复请求
  const requestCached = new Map()
  type SetRequest = (callBack: (config: RequestConfig) => Promise<any>, config: RequestConfig) => Promise<any>
  const setRequestCached: SetRequest = async (callBack, config) => {
    //通过url和接口传参判断是否为同一个接口
    const key = config.url + JSON.stringify(config.params) + JSON.stringify(config.data)
    if (config?.isCached) {
      if (requestCached.has(key)) {
        return requestCached.get(key)
      } else {
        const data = await callBack(config)
        requestCached.set(key, data)
        return data
      }
    } else {
      return callBack(config)
    }
  }

  //缓存正在进行的请求
  const ongoingRequests = new Map()
  const uniqueRequestPerKey: SetRequest = async (callBack, config) => {
    //通过url和接口传参判断是否为同一个接口
    const key = config.url + JSON.stringify(config.params) + JSON.stringify(config.data)
    if (config.isUniqueRequest && ongoingRequests.has(key)) {
      return ongoingRequests.get(key)
    } else {
      const promise = callBack(config)
      ongoingRequests.set(key, promise)
      promise.finally(() => {
        ongoingRequests.delete(key)
      })
      return promise
    }
  }

  /** 配置axios的公共函数 */
  const setRequest: SetRequest = async (callBack, config) => {
    setLoading(config)
    config = setPublicConfig(config)
    try {
      const requestFunc = (_config: RequestConfig) => setRequestCached(callBack, _config)
      const response = await uniqueRequestPerKey(requestFunc, config)
      deleteLoading(config)
      return response
    } catch (error) {
      deleteLoading(config)
      return Promise.reject(error)
    }
  }

  /** 设置公共配置处理 */
  const setPublicConfig = (config: RequestConfig): RequestConfig => {
    const { deptInfo } = useMainStore()
    //配置公共参数
    let params = { currentDeptId: deptInfo?.deptId, ...config.params }
    if (config.formData) {
      const formData = new FormData()
      for (const key in params) {
        formData.append(key, params[key])
      }
      params = formData
    }
    //配置不同请求方式的不同传参
    if ([`put`, `post`, `delete`, `path`].includes(config.method as string)) {
      config.data = params
      config.params = {}
    } else {
      config.params = params
    }
    //配置公共请求头
    config.headers = { currentDeptId: deptInfo?.deptId }
    return config
  }

  /**
   * 网络请求错误处理函数
   * @param error 错误对象
   */
  const errorHandler = (error: AxiosError) => {
    if (String(error.code) === `400401`) {
      notification.warning({ message: `长时间未操作,请重新登陆!` })
      // eslint-disable-next-line
      console.log('长时间未操作,请重新登陆!')
      location.href = `/sso/login`
    } else if (String(error.code) === `200401`) {
      notification.warning({ message: `登录异常` })
      // eslint-disable-next-line
      console.log(`登录异常`)
      location.href = `/sso/login`
    } else if (String(error.code) === `500499`) {
      notification.warning({
        message: `参数异常`,
        description: error.message,
      })
    } else if (String(error.code) === `999999`) {
      notification.warning({
        message: error.message ? undefined : `系统异常`,
        description: error.message,
      })
    } else {
      notification.warning({
        message: error.code,
        description: error.message,
      })
    }
    return Promise.reject(error)
  }
  return {
    setRequest,
    setLoading,
    deleteLoading,
    cancelDuplicateRequest,
    errorHandler,
  }
}

axios封装

import axios from 'axios'
import useAxiosConfig from '@/hooks/useAxiosConfig'
import { downloadFileByBlob } from '@/utils'
import type { RequestConfig, Response } from '@/types/services/http'

const config = {
  method: `post`,
  baseURL: import.meta.env.VITE_BASE_URL,
  timeout: 1000 * 60 * 30,
}

const _axios = axios.create(config)

const { setRequest, cancelDuplicateRequest, errorHandler } = useAxiosConfig(_axios)

// 请求拦截器
_axios.interceptors.request.use(
  config => {
    //取消重复请求
    cancelDuplicateRequest(config)

    return config
  },
  error => {
    return errorHandler(error)
  }
)

// 响应拦截器
_axios.interceptors.response.use(
  async (res: Response) => {
    // 如果是文件下载请求,直接返回响应
    if (res.config.responseType || res.config.fileName) {
      downloadFileByBlob(res.data, res.config.fileName)
      return Promise.resolve(res.data)
    }
    //后台状态码处理
    if (String(res.data.code) === `000000` || (res.data.head && String(res.data.head.code) === `1001`)) {
      return Promise.resolve(res.data.data)
    } else {
      return errorHandler(res.data)
    }
  },
  error => {
    const config = error.config || error.message.config

    if (config.isCancelToken) {
      return Promise.reject(error)
    } else {
      return errorHandler(error)
    }
  }
)

const get = (url = ``, params = {}, config) => {
  return setRequest(axios.request, { url, params, method: `get`, ...config })
}

const post = (url = ``, params = {}, config) => {
  return setRequest(axios.request, { url, params, method: `post`, ...config })
}

const gptGet = (url = ``, params = {}, config) => {
   return get(url, params, { baseURL: import.meta.env.VITE_BASE_URL_GPT_NODE })
}

const gptPost = (url = ``, params = {}, config) => {
   return post(url, params, { baseURL: import.meta.env.VITE_BASE_URL_GPT_NODE })
}

export { get, post, gptGet, gptpost }