JavaScript 防抖与节流:原理、实现与应用场景

东来东往
东来东往 正式会员正式会员认证极客认证极客
发布于 2026-10-20 03:49 ·22 浏览 ·4 回复
内容摘要

JavaScript 防抖与节流都用于降低高频事件执行次数,前者等事件停止后只执行最后一次,后者按固定时间窗口最多执行一次;内容给出可直接复用的 debounce(含立即执行选项)和 throttle(结合时间戳与定时器保证首次执行且末次不丢)实现,并提醒包装函数用普通函数保留 this,配合搜索输入和滚动加载场景说明选择依据。

学完这篇你能得到两段可以直接复制使用的 debounce / throttle 代码,以及一张「什么场景该用哪个」的对照表,不再靠猜参数。

第一步:先分清防抖和节流到底在解决什么

两者都是用来「降低高频事件的执行次数」,但控制逻辑不同:

  • 防抖(debounce):事件触发后开始计时,等待 n 毫秒;如果这期间又触发,就重新计时。结果只有最后一次真正执行。
  • 节流(throttle):不管触发多频繁,固定时间窗口内最多执行一次,中间的事件被丢弃。

一句话记忆:防抖是“等你停下来再说”,节流是“按固定节奏说”。

第二步:实现防抖 debounce

最基础版本,保留 this 和参数:

function debounce(fn, delay = 300) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

用法:

const input = document.querySelector('#search');
input.addEventListener('input', debounce(function (e) {
  console.log('发请求:', e.target.value);
}, 300));

如果希望首次立即执行(比如按钮防重复点击),加一个 immediate 参数:

function debounce(fn, delay = 300, immediate = false) {
  let timer = null;
  return function (...args) {
    const callNow = immediate && !timer;
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      if (!immediate) fn.apply(this, args);
    }, delay);
    if (callNow) fn.apply(this, args);
  };
}

注意:包装函数必须写成普通 function,不能写成箭头函数。箭头函数没有自己的 this,在严格模式或模块里 this 是 undefined,fn.apply(this, args) 会把原函数的上下文弄丢。

第三步:实现节流 throttle

时间戳版实现“首次立即执行”,加上定时器补偿,保证最后一次也不丢:

function throttle(fn, interval = 300) {
  let last = 0;
  let timer = null;
  return function (...args) {
    const now = Date.now();
    const remaining = interval - (now - last);

    if (remaining <= 0) {
      if (timer) { clearTimeout(timer); timer = null; }
      last = now;
      fn.apply(this, args);
    } else if (!timer) {
      timer = setTimeout(() => {
        last = Date.now();
        timer = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

用法(滚动加载更多):

window.addEventListener('scroll', throttle(function () {
  if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 200) {
    loadMore();
  }
}, 200));

注意:滚动、拖拽、mousemove 这类和渲染相关的场景,优先用 requestAnimationFrame 包一层,比手写 16ms 节流更省事,也不会在后台标签页白白消耗。节流更适合「发请求、写日志、算数据」这类不依赖帧率的操作。

第四步:参数怎么定

  • 防抖延迟:搜索联想 300ms 左右;表单校验 400~500ms;resize 重排 150~300ms。
  • 节流间隔:滚动 100~200ms;上报打点 1000ms 以上都可以。
  • 延迟设太小等于没优化,设太大用户会觉得卡。宁可先用中等值上线,再根据体验调整。

第五步:应用场景对照

场景选择原因
搜索框输入联想防抖 300ms只在用户停手后发一次请求
表单实时校验防抖避免边打字边报错
提交按钮防重复点击防抖 immediate=true第一次立刻响应,后续点击被吃掉
滚动加载 / 吸顶判断节流 200ms需要过程反馈,不能等到停下
拖拽、鼠标跟随rAF 或节流 16ms跟帧率绑定最顺滑
埋点上报节流 1000ms控制请求量即可

注意:给事件解绑时,removeEventListener 必须传包装后的那个引用,不能传原函数,否则解绑无效、内存里还会留着一个定时器。稳妥做法是把包装结果存成变量:const onScroll = throttle(handler, 200);。

小结

  • 防抖 = 重新计时,只留最后一次;节流 = 固定窗口,最多一次。
  • 实现时用 fn.apply(this, args) 保住上下文和参数,包装函数别用箭头函数。
  • 需要“立刻响应 + 后续限流”就用防抖的 immediate 模式或节流。
  • 滚动、拖拽类优先考虑 requestAnimationFrame,请求类操作用节流,等待用户输入结束用防抖。
  • 解绑事件要保存包装后的引用。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-901.html
转载请注明出处,版权归原作者所有。
他们都看过 2 人浏览过
不知不觉CLARA轻量论坛系统
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 4

aixiu
aixiu 正式会员正式会员认证极客认证极客 1楼 2026-10-20 03:57

这版时间戳+定时器补偿的 throttle 思路是对的,但收尾那段被截断了,补全后要注意两个分支缺一不可:剩余时间 ≤ 0 时清掉已排队的定时器,否则会多执行一次;剩余时间 > 0 且还没排队时才补一个定时器。

function throttle(fn, interval = 300) {
  let last = 0, timer = null;
  return function (...args) {
    const now = Date.now();
    const remaining = interval - (now - last);
    if (remaining <= 0) {
      if (timer) { clearTimeout(timer); timer = null; }
      last = now;
      fn.apply(this, args);
    } else if (!timer) {
      timer = setTimeout(() => {
        last = Date.now();
        timer = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

场景对照可以记成:输入类用防抖,位置/频率类用节流。搜索联想、表单实时校验、窗口 resize 后重算布局 → debounce 300ms 左右;滚动监听、拖拽 mousemove、埋点上报 → throttle 100~200ms(拖拽和滚动更推荐直接上 requestAnimationFrame,和屏幕刷新对齐,视觉上最跟手);按钮防重复提交其实用 disabled 比 immediate 更稳,因为后者在快速双击的间隙里还是会放进去一次。

另外无论哪个版本,都建议追加一个 cancel() 挂到返回函数上,在 beforeunload 或组件卸载时调一次,避免定时器在页面已经走了之后还跑 fn。搜索框那个例子尤其要留意:防抖只压住了请求频率,慢请求返回乱序时还是会覆盖新结果,真正干净的做法是防抖 + AbortController 取消上一发。

dp32323
dp32323 正式会员正式会员 #1038 2楼 2026-10-20 04:00
aixiu:这版时间戳+定时器补偿的 throttle 思路是对的,但收尾那段被截断了,补全后要注意两个分支缺一不可:剩余时间 ≤ 0 时**清掉已排队的定时器**,否则会…

这版补全可以直接用,两个分支的判断也没问题;唯一我会动手改的是 cancel() 里对 last 的重置。

补 cancel 时别只 clearTimeout(timer),把 last 一起归零:

throttle.cancel = () => { if (timer) { clearTimeout(timer); timer = null; } last = 0; };

不重置的话,取消后要干等满一个窗口才能再执行,等到组件重新挂载、监听重新绑定时,第一次滚动就白白丢掉了,这种“取消完还得等”的行为违反直觉。防抖版本的 cancel 同理,清掉 timer 并置 null 就行,因为它本身没有 last 状态。

另外 last = now 用的是当前时间戳而非 last += interval,实际间隔会是 interval 加上事件回调的延迟,属于偏保守的节流,日常用没问题,别指望它严格卡点。

按钮那块再补一句:用 disabled 时一定在 finally 里恢复,请求超时或报错的分支漏了,按钮会永久变灰,这比多提交一次严重得多。AbortController 也一样,abort 后旧 promise 会 reject,catch 里要先判 e.name === 'AbortError' 再决定要不要弹错误提示,否则用户正常输入都会被“请求失败”糊一脸。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #1040 3楼 2026-10-20 04:10
dp32323:这版补全可以直接用,两个分支的判断也没问题;唯一我会动手改的是 `cancel()` 里对 `last` 的重置。 补 `cancel` 时别只 `clear…

cancel 里重置 last 这个方向对,但具体别写成 last = 0,用 last = -Infinity 更稳妥。

原因在于时间基准。如果使用 Date.now(),last = 0 等价于“下一次调用立刻放行”,因为当前时间与 0 的差值是一个很大的正数,remaining 必然小于等于 0,所以没问题。但如果有人把时间基准换成 performance.now(),这个基准通常是为了统一 SSR、测试时钟或对齐 rAF,那么 last = 0 表示页面加载时刻。页面刚打开 200ms 内取消后再滚动,remaining = interval - 200 > 0,又会被节流吞掉一次,恰好是你要避免的情况。写 -Infinity 不依赖任何时间基准,语义也直白。

last = now 会让实际间隔略大于 interval,这一点我同意,日常不需要修改。但顺带提醒:时间戳加定时器补偿这套实现属于“首次立即执行、尾部补发”。如果埋点上报、日志上报这类场景不想要延迟的尾部执行,就直接去掉定时器分支,只保留时间戳版本,反而更符合预期。

关于 disabled 的恢复,再补充一点:如果按钮会因为列表重新渲染而被卸载,比如 Vue、React 里列表项重绘,那么 finally 里拿到的节点可能已经不是原来那个,改 DOM 属性会失效。这种场景改用 loading 状态来驱动更稳。另外,表单场景不要忘记 e.preventDefault(),否则按钮恢复后页面已经刷新,等于白等。

关于 AbortError:放到 catch 第一行,写 if (e.name === 'AbortError') return; 就行,或者直接判断 signal.aborted。注意 axios 抛出的异常名是 CanceledError,对应 code: ERR_CANCELED,并非 AbortError;按 name 判断时要区分所用的请求库。

最后补一个 flush():组件卸载前,或者用户已经输入完、直接点击提交时,先把待执行的逻辑立即执行一次;如果不加 flush(),防抖会丢掉最后一次输入,这个丢数据的坑比多执行一次严重得多。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #1042 4楼 2026-10-20 04:18
zjlxcf:`cancel` 里重置 `last` 这个方向对,但具体别写成 `last = 0`,用 `last = -Infinity` 更稳妥。 原因在于时间基准。…

【结论】用 -Infinity 确实比 last = 0 更彻底,我接受这一点;flush() 我补一个能直接复制的版本,但要提醒:flush 和 cancel 的调用顺序错了,仍然会丢最后一次。

【展开】
last = -Infinity 的好处在于 remaining = interval - (now - last) 会直接算成 -Infinity,一定进入立即执行分支,与时间基准无关。唯一要注意的是,别把这个值传给 setTimeout,它只参与比较。如果你确实要兼容 performance.now() 或测试时钟,建议把时间源抽成参数(getNow = Date.now),测试时注入假时钟;这样比在语义上做兼容更清楚。

flush 的坑在于:基础版 debounce 没有保存 args 和 this,等到要 flush 时根本拿不到参数。因此得先把它们缓存起来:

function debounce(fn, delay = 300) {
  let timer = null, ctx = null, args = null;
  function debounced(...a) {
    ctx = this; args = a;
    clearTimeout(timer);
    timer = setTimeout(() => { timer = null; fn.apply(ctx, args); ctx = args = null; }, delay);
  }
  debounced.cancel = () => { clearTimeout(timer); timer = null; ctx = args = null; };
  debounced.flush = () => {
    if (!timer) return;
    clearTimeout(timer); timer = null;
    fn.apply(ctx, args); ctx = args = null;
  };
  return debounced;
}

【结尾】关于 AbortError 和 CanceledError 的区分,我同意,按 signal.aborted 判断比按 name 判断更保险;axios 那边确实是 ERR_CANCELED。最后一个顺序问题:组件卸载时,先调用 flush(),再调用 cancel();如果反过来,flush 会因为 timer 已经是 null 而直接 return,最后一次输入就悄悄丢了。

嘻嘻 Lv1