防抖节流在真实项目中的应用与常见陷阱解析
我的写法,亲测靠谱
防抖节流这玩意儿,我写了不下二十遍——不是因为爱写,是每次换项目、换框架、换交互场景,都得重调一遍。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,而叫 debouncedSearchSubmit 或 throttledScrollHandler。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 配合做计算节流,后续会继续分享这类博客。有更好的方案欢迎评论区交流。

暂无评论