本篇文档将结合各种业务场景,带你学习到以下内容:
- axios的简单封装
- 我们为什么需要API层
- 给axios添加自定义配置项
- 增加配置项,缓存重复请求
- 增加配置项,重复请求归一
- 增加配置项,接口loading相关处理
- 增加配置项,中断重复请求
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 })
中断重复请求
现在有这样一个页面。左右分栏布局
左边是一个任务列表,点击左侧列表中的某一个任务,前端调用接口,根据接口返回值。 右侧展示这个任务的详情。
这样的场景可能会遇到这些问题。
- 我鼠标不停的点左侧的某一个任务。查询详情的接口就会一直调用。这样一方面会增加服务器压力。另一方面,因为浏览器有接口最大并发数的限制。同时调用过多的接口会导致接口阻塞。导致返回十分缓慢。防抖,或者请求中不允许再次点击能解决吗?能,但是都很麻烦。而且还要考虑到点了任务A ,不管怎么样,你不能阻止他去点任务B的情况。
- 接口 返回时机 和 发出时机 是没有任何关系的。例如我先点了任务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 }