【技术总结】前端防止重复调用接口

88 阅读4分钟

引言

在 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. 取消前一个未完成的请求

每次新请求发出时,使用 AbortControllerrequestTask.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 + 遮罩

没有一种方案适用于所有场景。理解每种方案的原理和局限性,根据业务需求(读/写、用户期望、性能要求)做出合理选择,才能写出既健壮又友好的前端交互。