防抖与节流

1. 概念与区别

(1) 防抖

定义:在事件被触发 delay 毫秒后才执行。如果在该时间内再次触发,则重新计时

场景

  • 用户在输入框搜索(停止输入后再发请求)。
  • 自动保存(停止编辑后再保存)。
  • 窗口调整 resize (调整过程中不计算,停止后计算)。

核心思想:对于连续的事件,只执行最后一次

(2) 节流

定义:规定在单位时间内(如 1 秒)只能触发一次。如果该时间内被触发多次,只有一次生效。

场景

  • 滚动条 scroll 监听(可设置每 100ms 计算一次位置)。
  • 按钮防止重复点击。

核心思想:对于连续的事件,稀释执行频率(每隔多少时间执行一次)。

2. 手写防抖与节流

面试时,不仅需要说出区别,更需要现场手写代码。

(1) 手写防抖

/**
 * @param {Function} func 需要防抖的函数
 * @param {Number} delay 延迟时间 (ms)
 * @param {Boolean} immediate 是否立即执行一次
 */
function debounce(func, delay, immediate = false) {
  let timer = null;

  return function (...args) {
    if (timer) clearTimeout(timer);

    if (immediate && !timer) {
      func.apply(this, args);
    }

    timer = setTimeout(() => {
      if (!immediate) func.apply(this, args);
      timer = null; // 执行完置空,下次可再次触发 immediate
    }, delay);
  };
}

(2) 手写节流

这里提供两种方案:时间戳版(立即执行,但在最后一次后不会再执行)和 定时器版(延迟执行,但在最后一次后会补执行)。面试写出简单版即可。

组合版 (推荐)

/**
 * @param {Function} func 需要节流的函数
 * @param {Number} wait 间隔时间 (ms)
 */
function throttle(func, wait) {
  let timer = null;
  let lastTime = 0;

  return function (...args) {
    const now = Date.now();
    // 下次触发剩余时间
    const remaining = wait - (now - lastTime);

    // 如果时间间隔到了,或者系统时间被调回去了
    if (remaining <= 0 || remaining > wait) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      lastTime = now;
      func.apply(this, args);
    } else if (!timer) {
      // 还在时间内,设置定时器在剩余时间结束后执行(补刀)
      timer = setTimeout(() => {
        lastTime = Date.now(); // 注意这里更新时间
        timer = null;
        func.apply(this, args);
      }, remaining);
    }
  };
}

3. 防抖在 AI 对话框中的应用

场景:用户停止输入后再请求辅助能力

AI 对话框中,用户每输入一个字符都会触发 input 事件。如果每次输入都请求服务端,容易产生大量无效请求,并且会让旧请求和新请求同时返回,造成提示内容闪烁或覆盖。

防抖适合处理“用户停止输入后再执行”的任务,例如:

  • 生成 Prompt 补全或问题建议。
  • 根据输入内容匹配历史问题、知识库或指令模板。
  • 停止编辑后自动保存草稿。
  • 停止输入后计算 Token 数量、上下文长度和费用预估。

但“发送消息”通常不应该使用防抖。用户点击发送后应立即提交,重复发送应通过按钮禁用、请求状态或幂等键处理。

实现:防抖 + 取消旧请求

function createPromptSuggestion() {
  let timer = null;
  let controller = null;

  return function suggest(prompt, onResult, onError) {
    clearTimeout(timer);
    controller?.abort();

    // 输入为空时直接清空建议,不请求服务端
    if (!prompt.trim()) {
      onResult([]);
      return;
    }

    timer = setTimeout(async () => {
      controller = new AbortController();

      try {
        const response = await fetch("/api/prompt-suggestions", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ prompt }),
          signal: controller.signal
        });

        if (!response.ok) {
          throw new Error(`Request failed: ${response.status}`);
        }

        const data = await response.json();
        onResult(data.suggestions || []);
      } catch (error) {
        // 用户继续输入导致的取消,不需要提示错误
        if (error.name !== "AbortError") {
          onError?.(error);
        }
      }
    }, 400);
  };
}

const suggest = createPromptSuggestion();
const input = document.querySelector("#prompt-input");

input.addEventListener("input", (event) => {
  suggest(
    event.target.value,
    (suggestions) => renderSuggestions(suggestions),
    (error) => showSuggestionError(error)
  );
});

为什么要取消旧请求?

仅使用 setTimeout 只能减少请求次数,不能处理已经发出的请求。例如:

用户输入 A → 请求 A 发出
用户输入 AB → 请求 B 发出
请求 B 先返回 → 页面显示 B 的建议
请求 A 后返回 → 旧结果覆盖了 B

使用 AbortController 可以在新输入到来时取消旧请求。即使某些服务端已经处理了旧请求,前端也不会再使用被取消请求的结果。对于无法取消的请求,还应增加请求序列号,只接受最后一次请求的响应。

参数选择与注意事项

  • 防抖时间通常取 300~500ms:太短会产生较多请求,太长会让输入体验变差。
  • 处理输入法时,要关注 compositionstartcompositionend,避免中文输入过程中间状态触发请求。
  • 流式输出不适合用防抖控制每个 Token 的渲染,可以使用节流或批量刷新,保证界面流畅。
  • 草稿自动保存可以使用更长的防抖时间,例如 800~1500ms,并在页面离开前再主动保存一次。
  • 请求接口应设置超时、重试策略和鉴权;发送消息等非幂等操作不能仅靠防抖防重复。

4. 面试考点

  1. this 指向问题:回调函数必须使用 function 关键字或者箭头函数通过 apply 绑定正确的 this
  2. 参数透传:使用 ...args 并通过 apply 传递给原函数。
  3. 立即执行:防抖和节流都可以通过 immediate 选项控制首次是否立即触发。
  4. 取消功能:在返回的函数上挂载 .cancel() 方法清除定时器。

5. 总结

  • 防抖:最后一次说了算。(输入框搜索)
  • 节流:按节奏执行。(滚动监听)