1. 高频事件里那些让人抓狂的瞬间做前端这些年有个场景我闭着眼都能想起来搜索框里刚敲下第三个字符页面就开始疯狂发请求Network 面板像瀑布一样刷屏窗口拖拽改变大小时resize 回调一秒触发几十次页面直接卡成幻灯片还有滚动加载、按钮连点、鼠标移动画轨迹……这些高频事件就像没拧紧的水龙头滴滴答答漏个不停最后把浏览器主线程淹得喘不过气。节流Throttle和防抖Debounce就是拧紧这个水龙头的两把扳手。它们解决的是同一类问题——高频触发的事件回调需要被限流但两者的思路完全不同防抖是等你彻底停下来我再动手节流是不管你多急我按固定节奏来。这两个概念听起来简单真到项目里用起来坑一点都不少定时器没清干净导致内存泄漏、this指向跑偏、参数丢失、首次触发要不要立即执行、组件卸载后回调还在跑……我见过太多代码里防抖写了个寂寞事件照样打满。这篇内容适合谁看如果你写过搜索联想、滚动加载、窗口自适应、表单校验、按钮防重复提交这类功能但总觉得加了防抖好像也没生效或者面试被问到手写一个防抖只能背模板那这篇就是给你准备的。我会从两者的本质差异讲起把定时器、闭包、this、arguments这些底层细节掰开揉碎再给出可以直接抄进项目的实现最后聊聊那些只有真正踩过才知道的坑。全程不堆术语用生活里的例子把原理讲透新手能看懂有经验的也能捡到几个细节。2. 防抖与节流的本质差异一个等安静一个卡节拍2.1 用电梯门和地铁闸机理解两者先给一个我常用的类比。防抖像电梯门有人不断进出门就一直不关直到最后一个人进来后静止一段时间门才关上。也就是说事件持续触发时回调一次都不执行只有触发停止超过设定时间才执行最后一次。节流像地铁闸机不管外面排了多少人闸机按固定频率放行比如每 500 毫秒过一个人。事件持续触发时回调按固定间隔执行不会因为触发太密就无限执行。这个差异决定了它们的适用场景完全不同。防抖关心的是最终状态——用户输入完了、窗口调整完了、滚动停下来了这时候才需要处理结果。节流关心的是过程节奏——滚动过程中要持续更新进度条、拖拽过程中要持续计算位置这些场景不能等结束但也不能太频繁。2.2 一张表看清核心区别维度防抖 Debounce节流 Throttle触发时机停止触发后延迟执行固定时间间隔执行持续触发时回调不执行直到停止按间隔周期性执行关注点最终结果过程节奏典型场景搜索联想、表单校验、窗口 resize 结束滚动加载、拖拽、进度更新首次是否执行默认不执行可配置默认立即执行可配置实现核心每次触发重置定时器记录上次执行时间或开关锁很多人把这两个混着用结果就是搜索框用了节流用户还在打字就发了一堆请求滚动加载用了防抖用户滚到底部半天没反应。选错工具比不优化还难受所以第一步永远是判断这个场景要的是最终结果还是过程节奏。2.3 为什么高频事件必须优化浏览器的主线程是单线程的事件回调、渲染、脚本执行都挤在一条道上。一个scroll事件在快速滚动时每秒能触发 60 次以上如果回调里有 DOM 读取、布局计算、甚至网络请求主线程就会被占满页面掉帧、输入延迟、动画卡顿全来了。防抖和节流的价值不只是少发几个请求更是把主线程从无意义的重复劳动里解放出来。我做过一个粗略统计一个没优化的搜索联想用户输入五个字可能触发十几次请求加了防抖后通常只剩一次服务端压力和前端渲染压力都是数量级的下降。3. 手写防抖定时器、闭包与 this 的三重考验3.1 最简版本先跑通再谈完善防抖的核心逻辑就一句话每次触发都清掉上一个定时器重新计时。最朴素的实现长这样function debounce(fn, delay) { let timer null; return function (...args) { clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, delay); }; }这里有几个细节值得说。timer定义在返回函数外部靠闭包保存状态这样每次调用返回的函数都能访问到同一个timer实现清掉上一次的效果。clearTimeout(timer)在timer为null时也不会报错所以首次调用是安全的。fn.apply(this, args)是为了保住调用时的this和参数——如果你直接写fn()在事件处理里this会丢参数也传不进去。3.2 为什么 this 和 arguments 必须显式传递这是新手最容易翻车的地方。假设你给一个对象方法加防抖const obj { name: demo, greet() { console.log(this.name); } }; const debounced debounce(obj.greet, 300); debounced(); // 如果内部写 fn()这里 this 是 undefined 或 window因为debounced()是独立调用this不再指向obj。用fn.apply(this, args)把外层函数的this透传进去才能保住上下文。参数同理事件对象event如果不通过args传回调里就拿不到。我见过有人图省事写fn(...arguments)在箭头函数里arguments根本不存在直接报错。所以用剩余参数...args是最稳的写法兼容性和可读性都好。3.3 immediate 选项首次要不要立刻执行有些场景等不起延迟。比如按钮防重复提交用户点下去希望立刻有反馈而不是等 300 毫秒。这时候需要immediate选项首次触发立即执行之后的触发在延迟期内被忽略。function debounce(fn, delay, 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); }; }关键在callNow的判断只有immediate为真且当前没有定时器说明是新一轮触发的第一次才立即执行。定时器回调里要把timer置回null否则下一轮callNow永远是 false。这个timer null很多人会漏导致 immediate 只在页面加载后第一次生效后面全失效。3.4 cancel 方法组件卸载时的救命稻草实际项目里防抖函数经常绑定在组件上组件销毁了定时器还在跑回调里访问已卸载的 DOM 或已销毁的实例轻则报错重则内存泄漏。所以一个生产级的防抖应该带cancelfunction debounce(fn, delay, immediate false) { let timer null; function debounced(...args) { const callNow immediate !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; }在组件卸载的生命周期里调一下debounced.cancel()定时器就干净了。这个细节在单页应用里尤其重要路由切换频繁不清理就是慢性泄漏。4. 手写节流时间戳与定时器两种流派4.1 时间戳版首次立即执行末次可能丢失节流的第一种实现靠记录上次执行时间function throttle(fn, interval) { let last 0; return function (...args) { const now Date.now(); if (now - last interval) { last now; fn.apply(this, args); } }; }逻辑很直白当前时间减去上次执行时间超过间隔就执行并更新last。这个版本的优点是首次触发立即执行适合需要即时反馈的场景。缺点也明显如果事件在间隔快结束时停止触发最后一次可能永远不执行。比如滚动加载用户滚到底部刚好卡在间隔中间停下最后那次触发被丢掉数据就加载不出来。4.2 定时器版首次延迟末次保证执行第二种靠开关锁 定时器function throttle(fn, interval) { let timer null; return function (...args) { if (timer) return; timer setTimeout(() { timer null; fn.apply(this, args); }, interval); }; }触发时如果锁着timer存在就直接返回否则上锁并安排一次执行。这个版本首次是延迟执行的但能保证间隔结束后一定执行一次末次不丢。适合那些结束状态也要处理的场景。4.3 合并两版首次立即 末次补执行生产环境里我更倾向把两者结合做到首次立即执行、末次也补一次function throttle(fn, interval) { 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); } }; }这段代码的思路是如果距离上次执行已经超过间隔立即执行并清掉待执行的定时器否则安排一个定时器在剩余时间后补执行。remaining的计算是整个逻辑的核心它保证了节奏的精确性。这个版本兼顾了两头的需求代价是代码稍复杂但换来的是行为可预期。4.4 两种流派怎么选实现方式首次执行末次执行适用场景时间戳版立即可能丢失即时反馈优先如按钮点击定时器版延迟保证执行结束状态重要如滚动加载合并版立即保证执行要求严格的生产场景我的经验是不确定就用合并版行为最符合直觉。如果对性能极致敏感、能接受末次丢失时间戳版最轻量。5. 真实场景里怎么选、怎么配参数5.1 搜索联想防抖是唯一正解搜索框输入联想用户每敲一个字都发请求是灾难。这里必须用防抖延迟设300 到 500 毫秒比较合适。太短比如 100 毫秒用户打字快时还是会发多次太长比如 1 秒用户会觉得卡顿。我一般取 300 毫秒配合immediate: false等用户停顿后再请求。如果输入框有回车立即搜索的需求回车事件单独处理不走防抖。有个细节防抖延迟期间用户可能已经清空输入回调里要判断当前输入值是否为空避免发出空查询。另外请求本身要做竞态处理后发的请求可能先返回用请求序号或取消令牌保证只采用最新结果。5.2 滚动加载节流配阈值判断无限滚动加载用节流间隔设200 到 300 毫秒。滚动事件本身很密节流后每 200 毫秒检查一次是否接近底部即可。这里不能只用节流还要在回调里判断距离底部小于多少像素以及当前是否正在加载否则会重复触发。我通常写一个isLoading标志位请求发出前置 true返回后置 false。5.3 窗口 resize防抖收尾节流过程resize 是个特殊场景。如果只是重新计算布局用防抖延迟 200 毫秒等用户拖完再算最省事。但如果需要在拖拽过程中实时预览就得用节流。我的做法是两者结合节流负责过程中的轻量更新比如更新一个指示器防抖负责结束后的重计算比如重新渲染图表。这样既不会卡也不会在结束时漏掉最终尺寸。5.4 按钮防重复提交immediate 防抖提交按钮用immediate: true的防抖延迟设 500 到 1000 毫秒。用户第一次点击立即执行之后的连点在延迟期内被忽略。这比点击后禁用按钮更优雅因为不需要手动管理按钮状态也不怕请求失败后忘记恢复。注意延迟要覆盖住请求的最短耗时否则请求还没回来按钮又活了。5.5 参数配置的经验值场景方案延迟/间隔备注搜索联想防抖300ms配合竞态处理表单实时校验防抖300-500ms输入停止后校验滚动加载节流200-300ms加 isLoading 标志窗口 resize节流防抖100ms / 200ms过程轻量、结束重算按钮提交防抖 immediate500-1000ms覆盖请求耗时拖拽跟随节流16-50ms接近帧率这些数值不是死的要根据实际设备性能和用户操作习惯调。移动端可以适当放大延迟因为触摸事件更密集、设备性能更弱。6. 那些只有踩过才知道的坑6.1 定时器没清导致的内存泄漏最典型的坑组件里创建了防抖函数组件销毁时没调cancel定时器还在跑回调里引用了组件实例整个实例无法被回收。单页应用里反复进出同一个页面内存曲线一路向上。解决办法就是前面说的给防抖加cancel在卸载钩子里调用。如果是 React把防抖函数用useRef存起来useEffect的清理函数里 cancel。6.2 每次渲染都新建防抖函数React 函数组件里直接在函数体里写const debounced debounce(fn, 300)每次渲染都会创建一个新的防抖函数闭包里的timer也是新的防抖完全失效——因为每次调用的都是不同实例。正确做法是用useMemo或useRef缓存依赖项写清楚。这个坑我见过至少三次每次都是明明加了防抖为什么没用。6.3 箭头函数让 this 彻底丢失防抖内部用fn.apply(this, args)透传this但如果外层返回的是箭头函数this在定义时就绑定了透传的是外层作用域的this可能根本不是你想要的对象。所以防抖返回的函数用普通函数声明别用箭头函数除非你明确知道this该指向哪。6.4 防抖延迟和请求耗时叠加搜索联想里防抖 300 毫秒 请求 500 毫秒用户从停止输入到看到结果要 800 毫秒体验就差了。这时候要么缩短防抖延迟要么做请求缓存要么用本地过滤兜底。优化是个系统工程不能只盯着一个点。6.5 节流末次丢失导致数据不全前面提过时间戳版节流末次可能不执行。滚动加载场景下用户滚到底部停下最后一次触发被丢掉加载不出来。这种 bug 很隐蔽因为大部分时候能加载偶尔卡住。用合并版节流或者定时器版就能规避。6.6 防抖函数返回值问题防抖包装后的函数默认返回undefined因为真正的执行是异步的。如果原函数有返回值比如校验结果调用方拿不到。需要返回值的场景要么改成 Promise 化要么用 immediate 模式同步返回首次结果。这个限制很多人没意识到直到业务代码里if (debouncedValidate())永远为假才发现。7. 从手写到直接用现成方案与选型建议7.1 主流工具库的取舍手写是为了理解原理实际项目里用成熟库更省心。常见的工具库都提供这两个函数参数设计大同小异核心选项就是wait、immediate有的叫leading/trailing、cancel、flush。选型时看三点是否支持取消、是否支持首次/末次配置、包体积。如果项目已经引入了某个工具库直接用它的版本别为了两个函数再引一个包。7.2 leading 和 trailing 的语义成熟库通常用leading和trailing两个布尔值控制首末执行。leading: true表示首次立即执行trailing: true表示末次补执行。两者可以组合出四种行为比单一的immediate更灵活。理解这两个词的含义看文档就不会懵。7.3 什么时候不该用防抖节流不是所有高频事件都适合限流。比如输入框的字符计数用户每敲一个字都要更新用防抖会导致计数滞后体验反而差。这种场景直接同步更新成本极低。再比如动画帧更新应该用requestAnimationFrame而不是节流因为 rAF 和浏览器渲染节奏对齐比固定间隔更平滑。工具要用对地方别为了优化而优化。7.4 一个组合封装的思路项目里我习惯把两者封装成统一入口按配置切换模式function limit(fn, { mode debounce, wait 300, immediate false } {}) { return mode debounce ? debounce(fn, wait, immediate) : throttle(fn, wait); }这样业务代码只关心我要限流具体用哪种由配置决定后期调整也方便。封装时记得把cancel也透传出去。8. 我个人的几条实操心得第一先测量再优化。别一上来就给所有事件加防抖先用 Performance 面板看看哪个回调真的在拖后腿。我见过给一个每秒只触发两次的事件加节流纯属增加复杂度。第二防抖节流不是银弹。它们只减少回调执行次数不减少事件本身的触发。如果事件监听器本身开销大还得考虑事件委托、被动监听passive: true等手段配合。第三测试要覆盖边界。防抖的 immediate 首次、节流的末次补执行、cancel 后再次触发这些边界最容易出 bug。写单测时用假定时器fake timers控制时间流逝比真实等待靠谱得多。第四注释写清楚为什么选这个延迟。半年后回来看代码你会感谢当时写下300ms 是因为用户平均输入间隔 250ms的自己。参数背后的业务假设比参数本身更重要。最后分享一个小技巧调试防抖节流时在回调里打一条带时间戳的日志配合浏览器控制台的时间显示能直观看到执行节奏是否符合预期。比盯着代码空想强一百倍。这套东西不难难的是在正确的场景用正确的工具并且把边界情况处理干净。把上面这些细节吃透高频事件优化这块基本就不会再翻车了。
阅读完成 · 觉得有帮助?