引言
在 Web 开发中,用户快速点击按钮或频繁输入时,可能会导致同一接口被重复调用,引发数据错乱、服务器压力增大等问题。本文总结几种常见的前端解决方案,分析其原理、差异及适用场景。
一、问题场景
- 用户快速点击提交/支付按钮
- 实时搜索输入框不断触发请求
- 滚动加载更多时快速滚动
这些场景下,如果不加控制,同一个请求可能在短时间内被多次发送,造成不必要的资源浪费和潜在的数据错误。
二、解决方案概览
| 方案 | 原理 | 请求执行次数 | 是否阻断UI | 适用场景 |
|---|---|---|---|---|
| 请求锁 | 标志位控制,请求中则忽略新点击 | 只执行第一次 | 否 | 表单提交、支付 |
| 禁用按钮 | 请求时按钮置灰,完成后恢复 | 只执行第一次 | 是(仅按钮) | 同上,体验更直观 |
| 防抖 | 延迟执行,只响应最后一次触发 | 只执行最后一次 | 否 | 搜索输入、窗口resize |
| 节流 | 固定时间间隔内最多执行一次 | 执行多次(有上限) | 否 | 滚动加载、游戏连点 |
| 取消前一个请求 | 新请求发出时取消上一个未完成的请求 | 每次触发都执行,但取消旧请求 | 否 | 实时搜索(保证最新结果) |
| showLoading+遮罩 | 通过蒙层物理阻断点击 | 只执行第一次 | 是(全屏遮罩) | 需要强阻塞提示的场景 |
三、详细说明与代码示例
1. 请求锁
在请求发出时设置一个“进行中”标志,请求完成(成功或失败)后再清除。期间再次点击直接忽略。
let isRequesting = false;
async function submit() {
if (isRequesting) return;
isRequesting = true;
try {
await api.post();
} finally {
isRequesting = false;
}
}
2. 禁用按钮
请求期间将按钮置灰,请求结束后恢复。用户体验更直观。
const btn = document.getElementById('submit');
async function submit() {
btn.disabled = true;
try {
await api.post();
} finally {
btn.disabled = false;
}
}
3. 防抖
用户停止快速点击后一段时间(如300ms)才执行最后一次点击。适合搜索输入、保存草稿等场景。
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const searchDebounced = debounce(searchApi, 300);
input.addEventListener('input', (e) => searchDebounced(e.target.value));
4. 节流
固定时间内只执行一次请求(例如每500ms最多一次),忽略该时间段内的多余点击。
function throttle(fn, interval) {
let lastTime = 0;
return function(...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
const loadMoreThrottled = throttle(loadMore, 500);
scrollContainer.addEventListener('scroll', loadMoreThrottled);
5. 取消前一个未完成的请求
每次新请求发出时,使用 AbortController或requestTask.abort 取消上一个仍在pending的请求。
浏览器原生 AbortController
let controller = null;
function search(keyword) {
if (controller) controller.abort();
controller = new AbortController();
fetch(`/api/search?q=${keyword}`, { signal: controller.signal })
.then(res => res.json())
.then(render)
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
}
uni-app 中使用 requestTask.abort()
let requestTask = null;
function search(keyword) {
if (requestTask) requestTask.abort();
requestTask = uni.request({
url: '/api/search',
data: { q: keyword },
success: render
});
}
6. uni-app 中 showLoading 方案
使用 uni.showLoading 并设置 mask: true 防止快速点击导致的重复请求。
async function handleClick() {
uni.showLoading({ mask: true }); // 带遮罩,阻止后续点击
try {
await uni.request({ url: '/api/endpoint' });
} finally {
uni.hideLoading();
}
}
四、关键差异与选型建议
请求锁 vs 取消前一个请求
- 请求锁:只执行第一次请求,后续忽略 → 适合一次性写操作(支付、下单)
- 取消前一个请求:每次触发都发起新请求,但取消旧的未完成请求 → 保证最后一次结果生效 → 适合实时搜索等读操作
防抖 vs 取消前一个请求
- 防抖:减少请求频率,但不能解决“旧请求返回晚于新请求”导致的覆盖问题
- 取消请求:保证最终展示最新结果,但不能减少请求频率(每次输入仍会发请求)
- 最佳组合:防抖 + 取消请求 → 既减少次数,又保证最新结果
五、实时搜索的最佳实践
let requestTask = null;
let debounceTimer = null;
function onInput(keyword) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (requestTask) requestTask.abort(); // 取消上一个
requestTask = uni.request({ // 发起新请求
url: '/api/search',
data: { q: keyword },
success: render
});
}, 300);
}
六、总结
| 需求场景 | 推荐方案 |
|---|---|
| 表单提交 / 支付 | 请求锁 + 禁用按钮 |
| 实时搜索 | 防抖 + 取消前一个请求 |
| 滚动加载更多 | 节流 + 请求锁 |
| 需要全局阻塞提示 | showLoading + 遮罩 |
没有一种方案适用于所有场景。理解每种方案的原理和局限性,根据业务需求(读/写、用户期望、性能要求)做出合理选择,才能写出既健壮又友好的前端交互。