Axios 拦截器防重复提交
基于 Axios 拦截器实现全局防重复提交
在后台管理系统里,经常会遇到按钮连点、表单重复提交的问题。如果每个页面都单独写 loading 或防抖逻辑,代码会很分散,也容易漏。
更好的方式是把防重复请求逻辑放到 Axios 请求拦截器里,做成一个全局能力。
目标
我们希望实现:
- 同一个接口、同一种 method、相同 params、相同 data,在上一个请求未完成前,不允许重复发送。
- 第一次请求正常放行。
- 第二次重复请求直接在前端拦截,不发送到后端。
- 请求成功、失败、超时、取消后,都释放锁。
- 默认只拦截 POST,GET 默认不拦截。
- 支持单个接口关闭重复锁。
- 支持通过配置让 GET 也开启重复锁。
- 不使用 axios 已废弃的 CancelToken。
- TypeScript 类型友好。
核心思路
核心是维护一个全局的 pendingMap。
每次请求发出前,根据请求信息生成一个唯一 key:
method + url + params + data;
如果 pendingMap 里没有这个 key,说明当前没有相同请求正在进行,则把 key 存进去,并正常发送请求。
如果 pendingMap 里已经有这个 key,说明相同请求还没有结束,则直接返回一个 rejected Promise,阻止请求继续发送。
请求结束后,无论成功、失败、超时还是被取消,都根据请求配置里的 key 从 pendingMap 中删除,避免接口被永久锁死。
重复锁工具文件
新建文件:
src / axios / repeatLock.ts;
核心代码如下:
import type { AxiosError, AxiosRequestHeaders, InternalAxiosRequestConfig } from 'axios';
export const REPEAT_REQUEST_MESSAGE = '重复请求已拦截,请勿重复提交';
const repeatLockKey = '__repeatLockKey';
const pendingMap = new Map<string, true>();
type RepeatLockConfig = InternalAxiosRequestConfig & {
repeatLock?: boolean;
[repeatLockKey]?: string;
};
type RepeatLockError = Error & {
isRepeatRequest?: boolean;
config?: InternalAxiosRequestConfig;
};
pendingMap 用来保存正在请求中的接口 key。
repeatLockKey 是挂在请求 config 上的内部字段,用来在响应或错误时找到对应的 key 并释放锁。
稳定序列化参数
普通 JSON.stringify 有一个问题:
JSON.stringify({ a: 1, b: 2 });
JSON.stringify({ b: 2, a: 1 });
虽然两个对象语义相同,但字符串结果可能不同。
所以需要做稳定序列化,对对象 key 进行排序:
const stableStringify = (value: unknown): string => {
if (value == null) {
return '';
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
}
if (Object.prototype.toString.call(value) === '[object Object]') {
const obj = value as Record<string, unknown>;
return `{${Object.keys(obj)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`)
.join(',')}}`;
}
if (typeof value === 'string') {
try {
return stableStringify(JSON.parse(value));
} catch {
return JSON.stringify(value);
}
}
return JSON.stringify(value);
};
这样即使参数对象 key 顺序不同,也能生成相同的请求 key。
生成请求 key
export const getRepeatLockKey = (config: InternalAxiosRequestConfig) => {
const method = config.method?.toLowerCase() || 'get';
const url = config.url || '';
const params = stableStringify(config.params);
const data = stableStringify(config.data);
return [method, url, params, data].join('&');
};
key 由四部分组成:
method;
url;
params;
data;
只有这四项都相同,才认为是重复请求。
判断是否启用重复锁
默认只拦截 POST。
const shouldUseRepeatLock = (config: RepeatLockConfig) => {
const headerValue = getHeaderValue(config.headers, 'Repeat-Lock');
if (headerValue === false || String(headerValue).toLowerCase() === 'false') {
return false;
}
if (config.repeatLock === false) {
return false;
}
if (headerValue === true || String(headerValue).toLowerCase() === 'true') {
return true;
}
if (config.repeatLock === true) {
return true;
}
return config.method?.toLowerCase() === 'post';
};
规则是:
POST默认开启。GET默认不开启。repeatLock: false可以关闭。headers['Repeat-Lock'] = 'false'可以关闭。repeatLock: true可以强制开启。headers['Repeat-Lock'] = 'true'可以强制开启。
添加锁
export const addRepeatLock = (config: InternalAxiosRequestConfig) => {
const repeatConfig = config as RepeatLockConfig;
if (!shouldUseRepeatLock(repeatConfig)) {
return config;
}
const key = getRepeatLockKey(config);
if (pendingMap.has(key)) {
const error = new Error(REPEAT_REQUEST_MESSAGE) as RepeatLockError;
error.name = 'RepeatRequestError';
error.isRepeatRequest = true;
error.config = config;
return Promise.reject(error);
}
pendingMap.set(key, true);
repeatConfig[repeatLockKey] = key;
return config;
};
这里是整个功能的关键。
第一次请求进来时:
pendingMap.has(key) === false;
于是写入:
pendingMap.set(key, true);
然后放行请求。
第二次重复请求进来时:
pendingMap.has(key) === true;
直接抛出错误:
重复请求已拦截,请勿重复提交
并且不会真正发送到后端。
释放锁
export const removeRepeatLock = (config?: InternalAxiosRequestConfig) => {
const key = (config as RepeatLockConfig | undefined)?.[repeatLockKey];
if (key) {
pendingMap.delete(key);
}
};
export const removeRepeatLockByError = (error: AxiosError | RepeatLockError) => {
removeRepeatLock(error.config);
};
为什么失败时可以从 error.config 里取配置?
因为 axios 的错误对象中默认会带上本次请求的配置:
error.config;
所以请求失败、超时、取消时,也能拿到之前挂在 config 上的 key,然后释放锁。
接入 Axios 拦截器
在 axios 封装文件里接入:
axiosInstance.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
try {
const lockedConfig = await addRepeatLock(config);
return await defaultRequestInterceptors(lockedConfig);
} catch (error) {
if (!isRepeatRequestError(error)) {
removeRepeatLock(config);
}
return Promise.reject(error);
}
});
这里先执行 addRepeatLock(config)。
如果不是重复请求,就继续执行原本的请求拦截逻辑。
如果后续请求拦截器里发生异常,也会释放锁,避免请求还没真正发出去就锁死。
响应成功时释放锁:
axiosInstance.interceptors.response.use((res) => {
removeRepeatLock(res.config);
return res;
});
响应失败时释放锁:
axiosInstance.interceptors.response.use(
(res) => {
removeRepeatLock(res.config);
return res;
},
(error) => {
removeRepeatLockByError(error);
if (isRepeatRequestError(error)) {
return Promise.reject(error);
}
ElMessage.error(error.message);
return Promise.reject(error);
},
);
其中这段比较重要:
if (isRepeatRequestError(error)) {
return Promise.reject(error);
}
它的作用是:重复请求被拦截后,不走全局红色错误提示。
也就是说,重复请求会被静默拦截,但业务层仍然可以在 catch 里拿到错误。
TypeScript 类型扩展
为了让业务代码可以直接写:
repeatLock: false;
需要扩展 Axios 类型:
declare module 'axios' {
export interface AxiosRequestConfig<D = any> {
repeatLock?: boolean;
}
export interface InternalAxiosRequestConfig<D = any> {
repeatLock?: boolean;
}
}
如果项目里还有自己的全局 AxiosConfig,也需要加上:
declare interface AxiosConfig {
repeatLock?: boolean;
}
使用方式
POST 默认开启重复锁:
request.post({
url: '/user/save',
data: formData,
});
跳过重复锁:
request.post({
url: '/user/save',
data: formData,
repeatLock: false,
});
或者:
request.post({
url: '/user/save',
data: formData,
headers: {
'Repeat-Lock': 'false',
},
});
GET 默认不拦截,如果需要开启:
request.get({
url: '/user/list',
params: query,
repeatLock: true,
});
业务层如果想忽略重复请求错误:
api().catch((err) => {
if (err?.isRepeatRequest) return;
ElMessage.error(err.message || '请求失败');
});
总结
这个方案的优点是:
- 页面无需单独维护 loading 状态。
- 防重复提交逻辑集中在 Axios 层。
- 不影响正常请求。
- 支持接口级别开关。
- 请求结束后自动释放锁。
- 兼容成功、失败、超时、取消等情况。
- 不依赖已废弃的 CancelToken。
最终效果是:用户连续点击提交按钮时,只有第一次请求会真正发送到后端,后续相同请求会被前端直接拦截,从而避免重复提交。
测试
const btn = [...document.querySelectorAll('button')].find((btn) => btn.innerText.includes('暂存'));
console.log(btn);
for (let i = 0; i < 5; i++) {
setTimeout(() => {
btn.click();
}, i * 20);
}

network中查看,只有一条请求