前端接口防止重复请求实现方案
前言
前段时间,老板要求我们前端组对整个项目进行接口防止重复请求的处理(原因是有用户通过快速点击薅到了一些优惠券)。当时第一反应是,这种防止薅羊毛的方案在服务端加限制最保险,前端的限制毕竟能拦截的有限。但既然老板要求,那我们就搞一下吧。虽然大部分接口处理都加了 loading,但不能确保每个接口都加了。如果要一个个接口排查维护,这个已经有四五年历史的系统,成百上千的接口排查起来太费精力,所以我们决定通过全局处理。以下是我们这次防重复请求的实现方案总结。
方案一
最容易想到的方案是使用 axios 拦截器,在请求拦截器中开启全屏 Loading,然后在响应拦截器中将 Loading 关闭。
// 代码示例
axios.interceptors.request.use(config => {
showLoading();
return config;
}, error => {
hideLoading();
return Promise.reject(error);
});
axios.interceptors.response.use(response => {
hideLoading();
return response;
}, error => {
hideLoading();
return Promise.reject(error);
});
这个方案虽然可以满足需求,但直接搞全屏 Loading 并不太美观,何况在项目中有一些局部 Loading,会出现 Loading 套 Loading 的情况,看起来不友好。
方案二
考虑到全屏 Loading 方案不太友好,针对同一个接口,如果传参相同,一般来说没有必要连续请求多次。所以我们可以通过代码逻辑直接拦截掉相同的请求,不让它到达服务端。
首先,我们要确定什么样的请求算相同请求:
一个请求包含的内容不外乎是请求方法、地址、参数以及请求发出的页面 hash。这些数据组合起来生成一个 key 作为请求的标识。
// 根据请求生成对应的key
function generateReqKey(config, hash) {
const { method, url, params, data } = config;
return [method, url, JSON.stringify(params), JSON.stringify(data), hash].join("&");
}
有了请求的 key,就可以在请求拦截器中收集每次发起的请求,如果有相同的请求进来,就在集合中比对,如果已经存在,就拦截掉。当请求完成响应后,再将请求从集合中移除。
// 代码示例
const pendingRequest = new Set();
axios.interceptors.request.use(config => {
const hash = location.hash;
const reqKey = generateReqKey(config, hash);
if (pendingRequest.has(reqKey)) {
return Promise.reject(new Error('重复请求'));
}
config.pendKey = reqKey;
pendingRequest.add(reqKey);
return config;
}, error => Promise.reject(error));
axios.interceptors.response.use(response => {
const reqKey = response.config.pendKey;
pendingRequest.delete(reqKey);
return response;
}, error => {
const reqKey = error.config.pendKey;
pendingRequest.delete(reqKey);
return Promise.reject(error);
});
这个方案看起来还不错,但它可能会引发更多问题,比如错误提示可能重复,或者多组件同时调用同一接口时的逻辑错误。
方案三
我们最终采用的方案是延续方案二的思路,但这次我们对相同的请求进行挂起,等最先发出去的请求拿到结果后,将结果共享给后续相同的请求。
import axios from "axios"
let instance = axios.create({ baseURL: "/api/" })
// 发布订阅
class EventEmitter {
constructor() { this.event = {} }
on(type, cbres, cbrej) {
if (!this.event[type]) {
this.event[type] = [[cbres, cbrej]]
} else {
this.event[type].push([cbres, cbrej])
}
}
emit(type, res, ansType) {
if (!this.event[type]) return
this.event[type].forEach(cbArr => {
if (ansType === 'resolve') {
cbArr[0](res)
} else {
cbArr[1](res)
}
});
}
}
// 生成请求key
function generateReqKey(config, hash) {
const { method, url, params, data } = config;
return [method, url, JSON.stringify(params), JSON.stringify(data), hash].join("&");
}
const pendingRequest = new Set();
const ev = new EventEmitter()
// 请求拦截器
instance.interceptors.request.use(async (config) => {
let hash = location.hash
let reqKey = generateReqKey(config, hash)
if(pendingRequest.has(reqKey)) {
let res = null
try {
res = await new Promise((resolve, reject) => ev.on(reqKey, resolve, reject))
return Promise.reject({ type: 'limiteResSuccess', val: res })
} catch (limitFunErr) {
return Promise.reject({ type: 'limiteResError', val: limitFunErr })
}
} else {
config.pendKey = reqKey
pendingRequest.add(reqKey)
}
return config;
}, error => Promise.reject(error));
// 响应拦截器
instance.interceptors.response.use(response => {
handleSuccessResponse_limit(response)
return response;
}, error => handleErrorResponse_limit(error));
// 处理成功响应
function handleSuccessResponse_limit(response) {
const reqKey = response.config.pendKey
if(pendingRequest.has(reqKey)) {
let x = JSON.parse(JSON.stringify(response))
pendingRequest.delete(reqKey)
ev.emit(reqKey, x, 'resolve')
}
}
// 处理错误响应
function handleErrorResponse_limit(error) {
if(error.type && error.type === 'limiteResSuccess') {
return Promise.resolve(error.val)
} else if(error.type && error.type === 'limiteResError') {
return Promise.reject(error.val);
} else {
const reqKey = error.config.pendKey
if(pendingRequest.has(reqKey)) {
let x = JSON.parse(JSON.stringify(error))
pendingRequest.delete(reqKey)
ev.emit(reqKey, x, 'reject')
}
return Promise.reject(error);
}
}
export default instance;
补充
到这里,我们的逻辑已经基本完善,但线上情况复杂多样,尤其是文件上传的场景。
function isFileUploadApi(config) {
return Object.prototype.toString.call(config.data) === "[object FormData]"
}
在生成请求 key 时需要注意请求体的数据类型,如果是 FormData 类型的数据直接放行。
最后
通过这种全局拦截的方法,我们实现了前端接口防止重复请求的功能,不需要一个个接口改代码,就可以愉快地打代码啦!