JavaScript 防抖和节流怎么选?搜索输入与滚动监听实例详解

编程狮(w3cschool.cn) 2026-09-11 17:46:50 浏览数 (39)
反馈

搜索框希望“停下来再查”,用防抖;滚动监听希望“持续发生也定期处理”,用节流。你需要保留最后一次输入时,还要确认实现是否支持尾部执行,不能只看函数叫什么名字。

防抖与节流先看事件时间线

输入十个字触发十次接口,拖动窗口执行几十次布局计算,这些问题都与事件频率有关。但把所有事件套上同一个延迟函数,会引出新问题:按钮首次点击没有反馈,滚动停止后位置没有更新,搜索结果被较早请求覆盖。本文分别实现尾部防抖与首部节流,说明它们承诺的行为,再给出取消和异步结果控制方法。代码使用现代 JavaScript,可在浏览器或 Node.js 执行。编程狮用时间序列帮助你选择,而不是让你背两个相似的定义。

一、防抖和节流的区别,在连续事件中才能看清

设等待窗口为一百毫秒,事件分别在零、三十、六十、一百六十毫秒到来。尾部防抖会不断推迟执行,直到最后一次触发之后经过完整等待时间;节流则允许在一段持续交互中间歇执行,不要求用户先停下来。

防抖像输入完成后才提交搜索词,节流像运动中每隔一段时间记录位置。前者强调“合并一串变化”,后者强调“控制持续执行的频率”。两者都会减少调用,但丢弃的事件不同,因此不是互相替代的性能开关。

还要说明首部和尾部。首部表示窗口开始时立即执行,尾部表示等待结束后执行保存的最后一次参数。一个实现可能只支持其中一种,也可能两者都支持。本文提供最小的两种版本,让边界可以直接检查;需要完整首尾组合和最大等待时间时,再使用经过测试的成熟实现。

二、尾部防抖:保留最后一组参数,并支持取消

下面的 debounce 每次触发都重置定时器,实际执行时使用最后一次的参数和调用对象。cancel 用于组件卸载或关闭搜索面板时取消尚未发生的工作。

function debounce(fn, wait) {
  let timer = null;
  function wrapped(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      fn.apply(this, args);
    }, wait);
  }
  wrapped.cancel = () => {
    clearTimeout(timer);
    timer = null;
  };
  return wrapped;
}
const results = [];
const search = debounce(value => results.push(value), 30);
search("j");
search("ja");
search("javascript");
setTimeout(() => {
  console.assert(results.join(",") === "javascript");
  console.log(results);
}, 90);

三个同步触发只留下最后的搜索词。这里用三十毫秒让演示较快结束,真实输入框可以从两三百毫秒开始观察体验,再依据接口延迟调整。定时器并不保证准点执行;主线程忙或标签页在后台时,回调可能更晚运行。

绑定事件时只创建一次包装函数。如果在每次 input 回调里重新调用 debounce,每个函数各有一个计时器,就无法互相取消。事件处理、函数参数和 this 还不熟悉,可以先配合 JavaScript 教程 梳理作用域与闭包。

三、首部节流:立即响应,但明确不补最后一次

下面的 throttleLeading 只实现首部节流。第一次事件立即执行,等待窗口内的后续事件忽略,窗口结束后下一次事件才有机会执行。它不会自动补发窗口中最后一次参数。

function throttleLeading(fn, wait) {
  let last = -Infinity;
  return function (...args) {
    const now = performance.now();
    if (now - last < wait) return;
    last = now;
    return fn.apply(this, args);
  };
}
const seen = [];
const track = throttleLeading(value => seen.push(value), 50);
track(10);
track(20);
track(30);
console.assert(seen.join(",") === "10");
setTimeout(() => {
  track(40);
  console.assert(seen.join(",") === "10,40");
  console.log(seen);
}, 100);

这个版本适合接受中间状态丢失的采样任务。如果你要保存拖拽终点,仅靠它不够:最后一次事件可能落在窗口内部而被忽略。可以在交互结束事件中立即保存最终值,或选用有尾部执行的节流实现,并把“结束时是否补一次”列入测试。

滚动中更新视觉位置时,requestAnimationFrame 可以把绘制工作安排到下一次绘制前,但它本身不是固定毫秒间隔的节流器。连续提交多个回调也不会自动替你合并,需要用一个“已预约”标记限制待执行回调数量。先区分减少计算次数与配合渲染节奏,再决定是否组合使用。

四、搜索防抖还需要处理请求顺序和中文输入

防抖控制的是“何时发起工作”,并不保证已经发出的异步请求按顺序返回。旧关键词请求先发后到,仍可能覆盖新关键词结果。这是异步竞态,需要用请求编号或取消机制单独解决。

let requestId = 0;
async function loadLatest(query, loader, render) {
  const id = ++requestId;
  const data = await loader(query);
  if (id === requestId) render(data);
}
const output = [];
const loader = text => new Promise(resolve =>
  setTimeout(() => resolve(text), text === "旧词" ? 60 : 10)
);
loadLatest("旧词", loader, value => output.push(value));
loadLatest("新词", loader, value => output.push(value));
setTimeout(() => {
  console.assert(output.join(",") === "新词");
  console.log(output);
}, 100);

这段代码用延迟不同的模拟加载器制造乱序,验证只渲染最新请求。接入 fetch 时,还需捕获网络错误并设置加载状态;AbortController 可以减少不必要的请求工作,但仍应处理取消与返回接近同时发生的情况。清空输入也算一次新意图,应递增编号,避免旧请求把清空后的界面填回去。

中文输入法的组合过程还没有最终确定文字。输入事件中应观察 isComposing,并在 compositionend 后按最终值触发搜索。不要把每一次拼音组合变化都当作用户已经提交的新关键词。常见定时器和事件接口可在 JavaScript 参考手册 中对照。

交互 优先选择 另外检查
输入搜索词 尾部防抖 中文组合、旧请求覆盖
持续位置采样 节流 是否需要最终位置
按钮防重复提交 提交状态锁 服务端幂等,而非只加延迟
动画位置更新 帧调度 是否已有待执行回调

相同输入,执行时机不同(示意)

总结

防抖和节流需要从产品期望出发选择:停下来执行,还是持续交互中也执行;第一次是否立即反馈,最后一次是否必须保留。函数名称只是起点,首尾行为才是实际契约。

本文的防抖带取消,节流明确只有首部执行,搜索示例另用编号处理乱序。把这些职责分开,你才能判断问题到底发生在事件合并、定时器、网络返回还是页面销毁阶段。验收时不要只看请求数量变少,还要验证最后输入、最后位置和错误反馈是否正确,这才说明前端性能优化没有牺牲交互结果。

延伸学习

  1. 使用 JavaScript 速查手册 整理函数和事件用法。
  2. 通过 JavaScript 基础实战 继续练习事件与函数。
  3. 阅读 JavaScript 控制台入门指南,观察触发与执行的次数差异。

常见问题

Q:防抖能彻底避免重复提交吗?

A:不能。它只合并一段时间内的触发,跨窗口仍可能提交多次。交易或表单提交需要禁用重复操作、记录提交状态,并由服务端保证适当的幂等行为。

Q:延迟设置为零会立即执行吗?

A:不会同步执行。定时器回调仍需等待当前代码完成,并受事件循环调度影响。依赖执行顺序时,应区分同步调用、微任务与定时器。

Q:页面关闭前要不要清理防抖?

A:需要。组件卸载或监听取消时应调用 cancel,并移除同一个包装函数的事件监听。否则尚未执行的回调可能尝试更新已经不存在的界面。

0 人点赞