HomeBlogAboutLogin
返回列表

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);
}

image-20260529163702923

network中查看,只有一条请求

© 2026 转载请注明出处