防抖节流在真实项目中的应用细节与常见误区解析
项目初期的技术选型
去年年底接手一个老项目重构,是给某市政务大厅做的自助终端系统——对,就是那种立在服务台旁边、带10.1寸触摸屏、跑Chrome kiosk模式的设备。需求看着挺简单:查办事指南、预约窗口、扫码取号。但上线前压测发现,用户狂点“预约”按钮时,后端API被连发5条重复请求;滚动长列表时,页面卡顿明显,甚至偶尔白屏。
一开始我真没往防抖节流上想,以为是接口慢或者React渲染太重。结果用 Performance 面板录了一段 touchstart → scroll → click 的操作流,发现:scroll 事件每秒触发200+次,click 前后还夹杂着3~4次 input 输入监听回调。而这些回调里,全都在干同一件事:调用 fetch('https://jztheme.com/api/search') 去查模糊匹配的服务名称。
行吧,不是后端慢,是我自己写的监听器太莽了。
最大的坑:性能问题
第一个坑出在搜索框。用户每按一个键就发一次请求?不行。我加了个 debounce,写得挺顺手:
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const searchApi = debounce((query) => {
fetch(https://jztheme.com/api/search?q=${query})
.then(res => res.json())
.then(data => renderList(data));
}, 300);
看起来没问题,直到测试同学说:“我输‘社保’两个字,删掉重输,结果列表里还是显示旧数据。”
我懵了一下,console.log 了一把,发现:第一次输入触发了定时器,还没执行就被 clearTimeout 干掉了;第二次输入又建了个新定时器……但第三次快速删光再输入,定时器居然没被清干净,旧请求回包后覆盖了新结果。
折腾了半天发现,是没做请求 cancel。fetch 本身不支持 abort(老版本 Chrome 不支持 AbortController),于是临时切成了 axios,并补了 cancelToken:
let cancelSource;
const searchApi = debounce((query) => {
if (cancelSource) cancelSource.cancel();
cancelSource = axios.CancelToken.source();
axios.get('https://jztheme.com/api/search', {
params: { q: query },
cancelToken: cancelSource.token
})
.then(res => renderList(res.data))
.catch(err => {
if (axios.isCancel(err)) return;
console.error(err);
});
}, 300);
这里注意我踩过好几次坑:cancelToken 必须每次 new 一个 source,不能复用;debounce 函数内部必须能访问到 cancelSource,所以不能写成纯函数闭包形式——最后我把 cancelSource 提到了外层作用域,虽然不优雅,但稳定。
滚动加载也翻车了
列表页用了“滚动到底部自动加载更多”。一开始用 throttle 包了一层 onScroll:
const loadMore = throttle(() => {
if (isEnd || isLoading) return;
loadNextPage();
}, 500);
结果 QA 直接截图甩过来:“滑动一次,加载了三次。”
查了下,是因为 touchmove 在终端设备上非常敏感,手指稍微一抖就触发十几次 scroll 事件,throttle 500ms 只保证“500ms 内最多执行一次”,但用户滑动 2 秒,它照样会执行 4~5 次——根本没解决重复请求问题。
后来改成“只在滚动停止后触发”:
let scrollTimer;
window.addEventListener('scroll', () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
if (shouldLoadMore()) loadNextPage();
}, 150);
});
这个 150 是试出来的:小于 100,快速滚动时容易漏触发;大于 200,用户等得烦躁。亲测有效,且比 throttle 更贴合业务语义。
最终的解决方案
总结下来,我们项目里其实混用了三种策略:
- 搜索输入:debounce + axios CancelToken(防抖+请求取消)
- 窗口大小变化(适配横竖屏):throttle(200),因为 resize 事件频率高,但不需要精确到毫秒级响应
- 滚动加载:setTimeout 滚动停止检测(非标准 throttle/debounce,但更准)
没有强行统一成一个工具函数,因为真实场景里,“什么时候该防抖,什么时候该节流,什么时候该停稳再动”,得看人怎么操作、系统怎么反馈。硬套概念反而添乱。
另外提一句:我们没上 Lodash。不是鄙视它,而是这个项目打包体积限制死在 800KB 以内,引入整个 lodash-es 就占 70KB+,最后手写了上面那几个函数,加起来不到 1KB。
回顾与反思
上线后监控数据显示,搜索类接口 QPS 从峰值 120 降到均值 8,滚动卡顿率从 17% 降到 0.3%。效果是明显的。
但也不是全完美。比如有个小问题到现在没彻底解决:当用户在搜索框里疯狂输入+删除(类似打字机节奏),偶尔还会出现“输入‘公积金’却返回‘公’的搜索结果”。查来查去,发现是后端缓存没设 Vary: X-Requested-With 导致 CDN 返回了旧缓存。这个问题和前端防抖无关,但我们当时花了一下午排查,最后发现是运维配置问题……
还有个妥协点:滚动加载的“停止检测”在某些老旧 Chrome 版本(v76)上存在 scrollY 跳变 bug,导致误判“已停止”,多加载一页。权衡之后决定不兼容——毕竟政务终端都是统一定制镜像,IT 部门承诺三个月内升级到 v90+。
总的来说,防抖节流不是银弹,它只是把“人操作的不确定性”和“机器执行的确定性”之间,垫了一层缓冲胶。关键不是代码多酷,而是你得知道胶垫该垫多厚、垫在哪、会不会老化。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如结合 IntersectionObserver 做图片懒加载、用 requestIdleCallback 做低优先级渲染……后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

暂无评论