一、引言
前端接口防止重复请求的实现方案主要基于以下几个原因:
-
用户体验:重复发送请求可能导致页面长时间无响应或加载缓慢,从而影响用户的体验。特别是在网络不稳定或请求处理时间较长的情况下,这个问题尤为突出。
-
服务器压力:如果前端不限制重复请求,服务器可能会接收到大量的重复请求,这不仅增加了服务器的处理负担,还可能导致资源浪费。
-
数据一致性:对于某些操作,如表单提交,重复请求可能导致数据重复插入或更新,从而破坏数据的一致性。
为了实现前端接口防止重复请求,可以采取以下方案:
-
设置请求标志:在发送请求时,为请求设置一个唯一的标识符(如请求ID)。在请求处理过程中,可以通过检查该标识符来判断是否已存在相同的请求。如果存在,则取消或忽略重复请求。
-
使用防抖(debounce)和节流(throttle)技术:这两种技术都可以用来限制函数的执行频率。防抖是在一定时间间隔内只执行一次函数,而节流是在一定时间间隔内最多执行一次函数。这两种技术可以有效防止用户频繁触发事件导致的重复请求。
-
取消未完成的请求:在发送新的请求之前,可以检查是否存在未完成的请求。如果存在,则取消这些请求,以避免重复发送。这通常可以通过使用Promise、AbortController等技术实现。
-
前端状态管理:使用状态管理工具(如Redux、Vuex等)来管理请求状态。在发送请求前,检查状态以确定是否已存在相同的请求。这种方案可以更加灵活地控制请求的行为。
-
后端接口设计:虽然前端可以采取措施防止重复请求,但后端接口的设计也非常重要。例如,可以为接口设置幂等性,确保即使多次调用接口也不会产生副作用。此外,还可以使用令牌(token)等机制来限制请求的重复发送。
综合使用这些方案,可以有效地防止前端接口的重复请求,提高用户体验和系统的稳定性。
二、取消未完成的请求
1、Axios内置的 axios.CancelToken
import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import axios from 'axios'
const CancelToken = axios.CancelToken
const queue: any = [] // 请求队列
const service = axios.create({
baseURL: '/api',
timeout: 10 * 60 * 1000,
headers: {
'Content-Type': 'application/json;charset=UTF-8',
},
})
// 取消重复请求
const removeRepeatRequest = (config: AxiosRequestConfig) => {
for (const key in queue) {
const index = +key
const item = queue[key]
if (
item.url === config.url &&
item.method === config.method &&
JSON.stringify(item.params) === JSON.stringify(config.params) &&
JSON.stringify(item.data) === JSON.stringify(config.data)
) {
// 执行取消操作
item.cancel('操作太频繁,请稍后再试')
queue.splice(index, 1)
}
}
}
// 请求拦截器
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
removeRepeatRequest(config)
config.cancelToken = new CancelToken(c => {
queue.push({
url: config.url,
method: config.method,
params: config.params,
data: config.data,
cancel: c,
})
})
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse): any => {
removeRepeatRequest(response.config)
return Promise.resolve(response)
},
error => {
return Promise.reject(error)
}
)
export default service
2、发布订阅方式
💡灵感来源: 前端接口防止重复请求实现方案
/*
* @Author: LYM
* @Date: 2024-03-28 14:12:54
* @LastEditors: LYM
* @LastEditTime: 2024-03-28 14:56:44
* @Description: 封装axios
*/
import { gMessageError, gMessageWarning, gMessageSuccess } from '@/plugins/naiveMessage'
import type { AxiosRequestConfig, AxiosResponse } from 'axios'
import axios from 'axios'
import { ContentTypeEnum } from './httpEnum'
import { checkResponseHttpStatus, loginStatusExpiresHandler } from './statusHandler'
import type { IRequestOptions, IResult } from './types'
const baseURL = import.meta.env.VITE_USER_BASE_URL
let isRefreshing: boolean = false
let retryRequests: any[] = []
// 发布订阅
class EventEmitter {
[x: string]: {}
constructor() {
this.event = {}
}
on(type: string | number, cbres: any, cbrej: any) {
if (!this.event[type]) {
this.event[type] = [[cbres, cbrej]]
} else {
this.event[type].push([cbres, cbrej])
}
}
emit(type: string | number, res: any, ansType: string) {
if (!this.event[type]) return
else {
this.event[type].forEach((cbArr: ((arg0: any) => void)[]) => {
if (ansType === 'resolve') {
cbArr[0](res)
} else {
cbArr[1](res)
}
})
}
}
}
// 根据请求生成对应的key
const generateReqKey = (
config: { method: string; url: string; params: string; data: string },
hash: string
) => {
const { method, url, params, data } = config
return [method, url, JSON.stringify(params), JSON.stringify(data), hash].join('&')
}
// 判断是否为上传请求
const isFileUploadApi = (config: { data: any }) => {
return Object.prototype.toString.call(config.data) === '[object FormData]'
}
// 存储已发送但未响应的请求
const pendingRequest = new Set()
// 发布订阅容器
const ev = new EventEmitter()
const service = axios.create({
baseURL: import.meta.env.VITE_BASE_URL,
timeout: 10 * 60 * 1000,
headers: {
'Content-Type': ContentTypeEnum.FORM_URLENCODED,
},
})
// 请求拦截器
service.interceptors.request.use(
async (config: any) => {
const hash = location.hash
// 生成请求Key
const reqKey = generateReqKey(config, hash)
if (!isFileUploadApi(config) && pendingRequest.has(reqKey)) {
// 如果是相同请求,在这里将请求挂起,通过发布订阅来为该请求返回结果
// 这里需注意,拿到结果后,无论成功与否,都需要return Promise.reject()来中断这次请求,否则请求会正常发送至服务器
let res = null
try {
// 接口成功响应
res = await new Promise((resolve, reject) => {
ev.on(reqKey, resolve, reject)
})
return Promise.reject({
type: 'limitResSuccess',
val: res,
})
} catch (limitFunErr) {
// 接口报错
return Promise.reject({
type: 'limitResError',
val: limitFunErr,
})
}
} else {
// 将请求的key保存在config
config.pendKey = reqKey
pendingRequest.add(reqKey)
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse): any => {
const res = response.data || {}
// 将拿到的结果发布给其他相同的接口
handleSuccessResponse_limit(response)
switch (res.code) {
case 206:
// 旧密码不正确
break
case 401:
// 业务系统未登录,调用login接口登录
return loginStatusExpiresHandler(response, request, service)
case 402:
// 登录失败
gMessageWarning({
content: '登录失败,请联系管理员',
})
break
case 403:
// 无权限,跳转到无权限页面
gMessageWarning({
content: res.msg || '权限不足',
})
break
case 404:
// 获取csrfToken,重新释放请求
if (
res.msg === '丢失服务器端颁发的CSRFTOKEN' ||
res.msg === '请求中请携带颁发的CSRFTOKEN'
) {
if (!isRefreshing) {
isRefreshing = true
// 请求token
request({ url: '/csrfToken', baseURL }).then((data: any) => {
if (data.code === 200) {
// 遍历缓存队列 发起请求 传入最新token
retryRequests.forEach(cb => cb())
// 重试完清空这个队列
retryRequests = []
}
})
}
return new Promise(resolve => {
// 将resolve放进队列,用一个函数形式来保存,等token刷新后调用执行
retryRequests.push(() => {
resolve(service(response.config))
})
})
}
break
case 500:
// 服务器错误
gMessageError({
content: '服务器错误,请联系管理员',
})
return
}
return Promise.resolve(response)
},
error => {
const { code, message } = error
if (code === 'ECONNABORTED' && message.indexOf('timeout') !== -1) {
gMessageError({
content: '接口请求超时,请刷新页面重试!',
})
return
}
const err = JSON.stringify(error)
if (err && err.includes('Network Error')) {
gMessageError({
content: '网络异常,请检查您的网络连接是否正常!',
})
return
}
// http 状态码提示信息处理
const isCancel = axios.isCancel(error)
if (!isCancel) {
checkResponseHttpStatus(error.response && error.response.status, message)
}
return handleErrorResponse_limit(error)
}
)
// 接口响应成功
const handleSuccessResponse_limit = (response: any) => {
const reqKey = response.config.pendKey
if (pendingRequest.has(reqKey)) {
let x = null
try {
x = JSON.parse(JSON.stringify(response))
} catch (e) {
x = response
}
pendingRequest.delete(reqKey)
ev.emit(reqKey, x, 'resolve')
delete ev.reqKey
}
}
// 接口响应失败
const handleErrorResponse_limit = (error: { type: string; val: any; config: { pendKey: any } }) => {
if (error.type && error.type === 'limitResSuccess') {
return Promise.resolve(error.val)
} else if (error.type && error.type === 'limitResError') {
return Promise.reject(error.val)
} else {
const reqKey = error.config.pendKey
if (pendingRequest.has(reqKey)) {
let x = null
try {
x = JSON.parse(JSON.stringify(error))
} catch (e) {
x = error
}
pendingRequest.delete(reqKey)
ev.emit(reqKey, x, 'reject')
delete ev.reqKey
}
}
return Promise.reject(error)
}
export default service
export const request = (config: AxiosRequestConfig, options?: IRequestOptions) => {
return new Promise((resolve, reject) => {
service(config)
.then((response: AxiosResponse<IResult>) => {
// 返回原始数据 包含http信息
if (options?.isReturnNativeResponse) {
resolve(response)
}
// 返回的接口信息
const msg = response.data.msg
// 是否显示成功信息
if (options?.isShowSuccessMessage) {
gMessageSuccess({
content: options.successMessageText ?? msg ?? '操作成功',
})
}
if (options?.isShowErrorMessage) {
gMessageError({
content: options.errorMessageText ?? msg ?? '操作失败',
})
}
resolve(response.data)
})
.catch(error => {
reject(error)
})
})
}
httpEnum.ts
/**
* @description: ContentType类型
*/
export enum ContentTypeEnum {
// json
JSON = 'application/json;charset=UTF-8',
// json
TEXT = 'text/plain;charset=UTF-8',
// form-data 一般配合qs
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
// form-data 上传
FORM_DATA = 'multipart/form-data;charset=UTF-8',
}
/**
* @description: 请求方法
*/
export enum MethodEnum {
GET = 'GET',
POST = 'POST',
PATCH = 'PATCH',
PUT = 'PUT',
DELETE = 'DELETE',
}
naiveMessage.ts 基于naive-ui分装提示
/*
* @Author: LYM
* @Date: 2023-03-28 08:47:39
* @LastEditors: LYM
* @LastEditTime: 2023-04-25 08:58:25
* @Description: naive message提示
*/
import { createDiscreteApi, lightTheme, type ConfigProviderProps } from 'naive-ui'
import { computed } from 'vue'
import { IconWarningFill, IconInfoFill, IconCircleCloseFilled, IconSuccessFill } from '@/icons'
const configProviderPropsRef = computed<ConfigProviderProps>(() => ({
theme: lightTheme,
}))
const { message } = createDiscreteApi(['message'], {
configProviderProps: configProviderPropsRef,
})
// 警告
export const gMessageWarning = (params?: any) => {
const {
content = '这是一条message warning信息!',
icon = IconWarningFill,
duration = 5000,
} = params || {}
message.warning(content, {
icon: () => h(icon, null),
duration,
})
}
// 成功
export const gMessageSuccess = (params?: any) => {
const {
content = '这是一条message success信息!',
icon = IconSuccessFill,
duration = 5000,
} = params || {}
message.success(content, {
icon: () => h(icon, null),
duration,
})
}
// 失败
export const gMessageError = (params?: any) => {
const {
content = '这是一条message error信息!',
icon = IconCircleCloseFilled,
duration = 5000,
} = params || {}
message.error(content, {
icon: () => h(icon, null),
duration,
})
}
// 信息
export const gMessageInfo = (params?: any) => {
const {
content = '这是一条message info信息!',
icon = IconInfoFill,
duration = 5000,
} = params || {}
message.info(content, {
icon: () => h(icon, null),
duration,
})
}
// loading
export const gMessageLoading = (params?: any) => {
const { content = '这是一条message Loading信息!', duration = 5000 } = params || {}
message.loading(content, {
duration,
})
}
const gMessageObj = {
info: {
icon: IconInfoFill,
},
warning: {
icon: IconWarningFill,
},
success: {
icon: IconSuccessFill,
},
error: {
icon: IconCircleCloseFilled,
},
}
// 合并
export const gMessage = (params?: any) => {
const { content = '这是一条message信息!', duration = 5000, type = 'info' } = params || {}
message.create(content, {
duration,
type,
icon: () => h(gMessageObj[type], null),
})
}
checkResponseHttpStatus请求状态码收集处理---自行分装
loginStatusExpiresHandler登录过期或者token失效收集处理---自行分装
注意: 心跳、轮询等请求可以在入参中透传随机key值解决