防抖节流在真实项目中的应用与常见陷阱解析

南宫巧梅 优化 阅读 1,812
赞 26 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

防抖节流这玩意儿,我写了不下二十遍——不是因为爱写,是每次换项目、换框架、换交互场景,都得重调一遍。Vue 2 的 watch + debounce、Vue 3 的 onMounted + useRef、React 的 useEffect + useRef + useCallback……写到最后发现,最稳的不是封装得多炫,而是逻辑够直、边界够清、销毁够狠。

防抖节流在真实项目中的应用与常见陷阱解析

我现在统一用这个版本(TypeScript + 原生 JS 思路,兼容性拉满):

function debounce(fn, delay, { immediate = false, leading = false } = {}) {
  let timer = null;
  let lastCall = 0;

  return function (...args) {
    const now = Date.now();
    const isImmediate = immediate && !timer;
    const shouldInvokeNow = isImmediate || (leading && now - lastCall >= delay);

    if (timer) clearTimeout(timer);

    if (shouldInvokeNow) {
      fn.apply(this, args);
      lastCall = now;
    } else {
      timer = setTimeout(() => {
        fn.apply(this, args);
        lastCall = Date.now();
      }, delay);
    }
  };
}

function throttle(fn, delay, { trailing = true, leading = true } = {}) {
  let lastCall = 0;
  let timer = null;

  return function (...args) {
    const now = Date.now();
    const elapsed = now - lastCall;

    if (elapsed >= delay) {
      if (leading) fn.apply(this, args);
      lastCall = now;
      return;
    }

    if (trailing && !timer) {
      timer = setTimeout(() => {
        fn.apply(this, args);
        lastCall = Date.now();
        timer = null;
      }, delay - elapsed);
    }
  };
}

为什么这么写?先说重点:我坚持把 lastCall 和 timer 都放在闭包里,不依赖 this 或外部状态;所有定时器必须可 clearTimeout;回调执行时必须绑定 this 和 args,不能丢上下文。之前在某个 React 项目里图省事,把 debounce 写成箭头函数 + 外部变量,结果列表项复用时 this 指向错乱,用户搜“苹果”点进去,最后发的是“香蕉”的请求,查了俩小时才发现是闭包变量被共享了。

另外,immediate 和 leading 不是一个东西,但很多人混着用。immediate 是“立刻执行一次,然后等 delay 后再执行”,leading 是“只要间隔超了就执行,不管是不是第一次”。我一般只开 leading,关掉 immediate——因为搜索框输入这种场景,你不需要“一上来就发个空请求”,反而容易触发后端校验失败。

这几种错误写法,别再踩坑了

下面这些,全是我或同事线上翻过车的写法,列出来不是为了嘲讽,是真疼过:

  • 用 setInterval 做节流:有人觉得“每 100ms 执行一次”就是节流。错。setInterval 不感知事件触发时机,滚动中它照跑,可能刚触发完又来一个,完全没压住。更糟的是,组件卸载后忘记 clearInterval,内存泄漏+报错两开花。
  • 防抖函数返回值直接赋给 onClick:比如 onClick={debounce(handleClick, 300)}。问题在哪?每次 render 都新建一个防抖函数!React 会认为这是新函数,强制重渲染子组件。正确姿势是:useCallback 包一层,或者在 useEffect 里初始化一次。
  • 没做 clearTimeout,也没清 timer 变量:最常见于 class 组件。componentWillUnmount 里只 clearTimeout,但没把 timer 设为 null。下次触发时 if (timer) 还成立,结果 clearTimeout(null) 无事发生,新定时器继续跑,老的还在后台蹲着——最终多个请求一起发出去,后端日志炸了。
  • 节流里用 Date.now() 但没缓存 lastCall:比如 if (Date.now() - lastCall > delay) 却没更新 lastCall,那下次永远进不去。我第一次写的时候就这么干的,调试时打 log 发现 lastCall 一直是初始值,当场拍桌。

实际项目中的坑

真实业务里,防抖节流从来不是“套个函数就完事”。

搜索框输入:别只防抖,还得加个最小字符数限制(比如 ≥2),不然用户敲 a 就发请求,后端白忙活。我们之前没加,ES 查询直接扫了上万条文档,QPS 瞬间飙到 400+,运维半夜打电话问“你们在搞啥DDoS?”

窗口 resize:千万别在全局监听 window.resize 时直接 debounce。Chrome 下快速拖拽窗口大小,resize 事件能一口气冒 30+ 次,防抖 delay 设 100ms,结果用户拖完手松开,页面卡顿半秒才重排。现在我的做法是:resize 用 throttle(delay 50ms),真正要重绘 DOM 的逻辑再套一层 requestIdleCallback。

移动端 touchmove:这是个大雷区。iOS Safari 对 touchmove 默认 preventDefault,如果你在防抖函数里做了 DOM 更新(比如 scrollTop 变化),又没及时响应 touchmove,页面就卡住不动。解决方案?要么加 { passive: false },要么——更稳妥的——干脆不用防抖,改用 CSS scroll-behavior: smooth + transform 位移模拟滚动。

和 Promise 一起用?小心链断裂:比如 debounce(fetchData),返回的 promise 如果没被 await,后续 .then 就丢了。我在一个表单提交里踩过——防抖后点了两次提交按钮,第二次覆盖了第一次的 promise,结果 loading 状态没关,用户以为卡死了。现在统一包装成:debounce 返回一个函数,里面手动管理 pending 状态,UI 层靠这个控制 loading。

还有个细节很多人忽略:防抖/节流函数名最好带业务标识。比如不要叫 debouncedFn,而叫 debouncedSearchSubmitthrottledScrollHandler。DevTools 里 debug 时,call stack 一眼能看出来是哪块逻辑在跑,而不是一堆 anonymous 函数堆在一起。

最后碎碎念几句

防抖节流不是银弹。我见过团队为了“性能优化”强行给每个 input 加 debounce(200),结果用户连输“张三丰”,刚敲完“张三”就发了请求,后端返回一堆“张三李四王五”,体验比不加还差。后来我们改成:input 用 400ms,搜索按钮点击用 0ms(立即触发),既保体验又控流量。

也别迷信“一定要封装成 hook”。小项目、临时页、jQuery 时代的老系统,一个 const searchDebounced = debounce(api.search, 300) 足够干净。过度抽象只会增加维护成本——上周我重构一个 Vue2 项目,把 debounce 拆成 mixin → composables → pinia store action,最后发现就三个地方用,纯属自我感动。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如结合 IntersectionObserver 做懒加载防抖、和 Web Worker 配合做计算节流,后续会继续分享这类博客。有更好的方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论