一、修复「极速连点并发间隙漏洞」
问题根源
点击判断 if (!loading) 和赋值 loading=true 中间存在执行间隙,超快双击会同时进入逻辑,发起多次请求。
优化写法:先锁,再判断,用原子锁逻辑
let loading = false;
async handleSubmit() {
// 1. 先抢占锁,再判断,消除间隙
if (loading) return;
loading = true;
try {
// 接口逻辑
await requestApi();
} catch (err) {
// 异常统一处理
message.error('提交失败');
} finally {
// 无论成功/失败/报错,强制释放锁
loading = false;
}
}
关键点:
loading = true提前到最顶部,没有任何中间代码,杜绝并发间隙。
进阶:封装全局请求锁(统一处理,不用每个按钮写 finally)
基于 axios 请求拦截器自动加 loading 锁,统一释放:
const pendingMap = new Map();
// 生成唯一key:接口url + 请求方式 + 参数
function getReqKey(config) {
return `${config.method}-${config.url}-${JSON.stringify(config.data)}`;
}
// 请求拦截
axios.interceptors.request.use(config => {
const key = getReqKey(config);
if (pendingMap.has(key)) {
// 相同请求正在执行,直接取消本次重复请求
const cancel = pendingMap.get(key);
cancel();
}
// 创建取消令牌
const controller = new AbortController();
config.signal = controller.signal;
pendingMap.set(key, controller.abort);
return config;
});
// 响应拦截(成功/失败都清除锁)
axios.interceptors.response.use(res => {
pendingMap.delete(getReqKey(res.config));
return res;
}, err => {
if (err.config) pendingMap.delete(getReqKey(err.config));
return Promise.reject(err);
})
效果:短时间相同参数重复调用 API,直接取消重复请求,从网络层面拦截。
二、解决「异常 / 页面跳转导致 loading 永久锁死」
- 强制使用 finally:所有异步请求必须在 finally 重置 loading,禁止只在成功回调释放;
- 页面销毁自动清除 loading(Vue/React)
vue
// Vue3
onUnmounted(() => {
loading = false;
// 取消页面所有pending请求
pendingMap.clear();
})
jsx
// React useEffect清理函数
useEffect(() => {
return () => {
setLoading(false);
}
}, [])
- 超时自动解锁兜底 增加延时兜底,弱网卡死时自动恢复按钮:
js
async handleSubmit() {
if (loading) return;
loading = true;
// 兜底:10秒强制解锁,防止永久卡死
const timer = setTimeout(() => loading = false, 10000);
try {
await api();
} finally {
clearTimeout(timer);
loading = false;
}
}
三、解决「前端禁用可被控制台绕过」
前端只做体验拦截,必须搭配双层防护:
1. 前端增加函数级防重,不只依赖 UI 禁用
哪怕用户手动解除按钮 disabled,重复调用函数也会被拦截:
js
async handleSubmit() {
if (loading) return; // 函数入口锁,不受DOM属性控制
loading = true;
// ...
}
用户删除按钮 disabled,但直接调用 handleSubmit() 依然会被 loading 拦截。
2. 配合防抖 / 节流二次兜底
对点击事件做防抖(300~500ms),双保险:
js
// 工具函数防抖
function debounce(fn, delay = 500) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
}
}
const submit = debounce(handleSubmit);
3. 最终防线:后端幂等校验(不可缺少)
前端所有优化都只是体验优化,无法防爬虫、Postman、抓包重放。
- 前端每次请求携带
idempotentKey(UUID + 时间戳); - 后端 Redis
SETNX校验,相同 key 短时间内直接拒绝; - 核心业务(下单、支付)增加数据库唯一索引。
四、解决「多标签 / 多窗口状态不同步」
前端内存变量 loading 只作用当前页面,多窗口失效,两种优化方案:
方案 1:LocalStorage + storage 事件同步 loading 状态
点击提交时写入 storage 标记,其他页面监听 storage 事件同步按钮禁用:
js
// 提交时
localStorage.setItem('submit_lock', Date.now());
// 其他页面监听
window.addEventListener('storage', (e) => {
if (e.key === 'submit_lock') {
// 5秒内禁止提交
setLoading(true);
setTimeout(() => setLoading(false), 5000);
}
})
方案 2:依赖后端幂等统一控制
不依赖前端同步,跨页面重复提交由后端直接拦截,成本更低、更稳定。
五、解决「多按钮共用 loading、无按钮场景无法拦截」
- 每个按钮独立 loading 状态 不要全局共用一个 loading 变量,每个操作单独定义:
js
const submitLoading = ref(false);
const saveLoading = ref(false);
- 无按钮场景统一封装请求锁 表格点击、回车快捷键、弹窗自动请求统一使用上面的
pendingMap请求拦截,不依赖 DOM 按钮; - 全局封装通用提交组件 封装通用 SubmitButton,内置 loading、防抖、自动清除逻辑,全局复用,避免手写遗漏。
六、解决「弱网 pending 交互差」体验问题
- 增加加载文字提示:
提交中,请稍候...,告知用户当前状态; - 增加取消请求按钮,支持中断 pending 接口;
- 设置统一接口超时时间(6~10s),超时自动释放 loading 并提示用户。
七、完整最优组合(工业级标准)
前端三层拦截(体验层)
- UI 层:按钮
disabled + loading视觉拦截; - 函数层:函数开头 loading 锁 + 防抖节流,绕过 DOM 也拦不住;
- 请求层:axios 拦截器 pendingMap 取消重复相同请求。
兜底优化(解决体验 bug)
- finally 强制释放 loading + 页面卸载清理锁 + 超时兜底解锁;
- 多操作独立 loading 变量,封装通用按钮组件减少重复代码。
后端一层终极拦截(安全层,必不可少)
幂等 ID + Redis 防重 + 数据库唯一索引 + 接口限流。
优化前后对比总结
| 原有缺点 | 优化方案 |
|---|---|
| 控制台解除 disabled 可重复点击 | 函数入口 loading 锁 + 后端幂等 |
| 极速连点并发多发请求 | 最顶部抢占 loading 锁 + axios 取消重复请求 |
| 报错 / 跳转按钮永久置灰 | finally 释放 + 页面卸载清理 + 超时兜底 |
| 多标签页可重复提交 | storage 同步锁 / 后端统一幂等拦截 |
| 多按钮共用 loading 互相干扰 | 每个操作独立 loading 变量 |
| 无按钮场景无法拦截 | axios 全局 pending 请求拦截 |
| 弱网卡住无反馈 | 加载文案 + 接口超时自动解锁 |
| 只能防普通用户,防不住爬虫 | 后端幂等、限流、唯一索引兜底 |