防抖与节流:高频事件的正确打开方式

陈先生
陈先生 正式会员认证极客
发布于 2026-09-23 04:20 ·3 浏览 ·2 回复

学完这篇你能搞清防抖和节流到底解决什么问题、怎么写、什么时候用哪个,并拿到几段可以直接复制进项目的代码。

前端有一类事件天生"话多":input 每敲一个字符触发一次、scroll 每滑动一像素触发一次、resize 拖动窗口时每秒能触发几十次、按钮被连点五下就是五次提交。这些高频事件不做处理,轻则卡顿,重则把接口打崩。防抖(debounce)和节流(throttle)就是两把最常用的"限流阀"。

第一步:先分清两者到底在干什么

一句话记住区别:

  • 防抖:你不停地触发,我就一直等,直到你停下来 N 毫秒后才执行一次。适合"等用户操作完再干活"。
  • 节流:不管你触发多快,我固定每 N 毫秒最多执行一次。适合"过程中要持续有反馈"。

典型场景对照:搜索框输入联想、表单校验、窗口 resize 结束时重算布局 → 用防抖;滚动加载更多、拖拽元素跟随、鼠标移动绘制 → 用节流。

第二步:写一个能用的 debounce

function debounce(fn, delay = 300, immediate = false) {
  let timer = null;

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

  // 需要时手动取消(比如组件卸载、切换页面)
  debounced.cancel = function () {
    clearTimeout(timer);
    timer = null;
  };

  return debounced;
}

// 用法
const onInput = debounce((e) => {
  console.log('搜索:', e.target.value);
}, 300);

input.addEventListener('input', onInput);

`immediate = true` 时首次触发立刻执行一次(比如按钮防止连点),之后必须等安静下来才能再触发。

注意:`fn.apply(this, args)` 不能省成 `fn()`。否则在类或 Vue 组件里 `this` 会丢,`e` 也传不进去,报错信息还特别难看懂。

第三步:写一个 throttle

先看最简的"时间戳版":

function throttle(fn, interval = 300) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= interval) {
      last = now;
      fn.apply(this, args);
    }
  };
}

它第一次立即执行,优点是干脆;缺点是最后一次触发如果没跨过时间点,会被丢掉——滚动到底部时最后那一下最容易丢。

于是有"定时器版":

function throttle(fn, interval = 300) {
  let timer = null;
  return function (...args) {
    if (timer) return;
    timer = setTimeout(() => {
      timer = null;
      fn.apply(this, args);
    }, interval);
  };
}

它保证最后一次一定执行,但第一次会延迟。想要"首次立即执行 + 最后一次也执行",用下面这版:

function throttle(fn, interval = 300, { leading = true, trailing = true } = {}) {
  let last = 0, timer = null;
  return function (...args) {
    const now = Date.now();
    if (!last && !leading) last = now;
    const remaining = interval - (now - last);

    if (remaining <= 0 || remaining > interval) {
      if (timer) { clearTimeout(timer); timer = null; }
      last = now;
      fn.apply(this, args);
    } else if (!timer && trailing) {
      timer = setTimeout(() => {
        last = leading ? Date.now() : 0;
        timer = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

注意:如果只是业务里用,直接装 lodash 用 `_.debounce` / `_.throttle` 完全没问题,它们支持 `leading`、`trailing`、`cancel`、`flush`,别为了"不用库"把上面这段抄错一个变量。

第四步:三个高频坑

坑一:搜索接口的竞态。 防抖只是减少了请求数,慢请求仍然可能后发先至。正确做法是配合 `AbortController`:

let controller = null;
const search = debounce(async (kw) => {
  controller?.abort();
  controller = new AbortController();
  const res = await fetch(`/api/search?q=${encodeURIComponent(kw)}`, {
    signal: controller.signal,
  });
  render(await res.json());
}, 300);

坑二:把该实时更新的状态也防抖了。 输入框里被防抖的应该是"发请求"这个动作,输入框自身的值必须实时变化,否则用户会觉得打字卡顿。在 Vue/React 里就是:数据照常绑定,只对回调做防抖。

坑三:防抖/节流函数在组件里重复创建。 每次渲染都 `debounce(...)` 会生成一个全新的函数,`clearTimeout` 也就清不掉旧的,等于没生效。要用 `useMemo`、`setup` 顶层变量或组件外定义把它固定下来。

额外一招:滚动和拖拽优先用 `requestAnimationFrame`。 屏幕刷新率一般 60Hz,用 rAF 节流天然对齐帧,比手写 16ms 更顺滑:

let ticking = false;
window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    doSomething();
    ticking = false;
  });
});

小结

  • 防抖 = 等停止后执行一次,管"最终结果";节流 = 固定频率执行,管"过程反馈"。
  • 写的时候别丢 `this` 和参数,用 `fn.apply(this, args)`。
  • 防抖记得留 `cancel`,节流按需选 leading/trailing。
  • 搜索框防抖 + AbortController 是标配,光防抖不取消请求仍会串数据。
  • 滚动、动画类回调优先 rAF;防抖/节流函数不要在每次渲染时重建。
  • 拿不准就上 lodash,把精力留给业务逻辑。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-567.html
转载请注明出处,版权归原作者所有。

全部回复 2

yipeng
yipeng 正式会员认证极客 1楼 2026-09-23 04:29

这套代码基本可以直接进项目了,唯一要补的是时间戳

wbcm
wbcm 见习用户 #103 2楼 2026-09-23 04:36
yipeng:这套代码基本可以直接进项目了,唯一要补的是时间戳

对,时间戳版最大的坑就是「尾调用丢失」——滚动到底部那一次如果没跨过 interval,`fn` 就永远不会执行,「加载更多」直接失灵。补法不是加时间戳,而是时间戳 + `setTimeout` 双轨:到点立即执行,没到点就挂一个定时器兜底最后一次。

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

用法上:滚动加载、输入联想保留 `leading + trailing`;拖拽跟随、鼠标绘制建议 `trailing: false`,否则松手后会「回弹」跳一下。组件卸载记得 `cancel()`,不然定时器可能引用已销毁的 DOM。

一个小注意点:`last` 用 `Date.now()` 就别和 `performance.now()` 混用,两者起点不同会算出负值;另外 `