在前端开发与面试中,**防抖(Debounce)与节流(Throttle)**几乎是出镜率最高的一对“孪生兄弟”。
在处理高频触发的 DOM 事件(如 scroll、resize、input、mousemove 等)时,如果不加节制地执行绑定的业务逻辑或发送网络请求,极易导致浏览器重排重绘卡顿、事件循环堵塞,甚至击垮后端服务器。
两者的核心目标都是降低高频事件的执行频率,但其控制哲学和应用场景有着本质不同。本文从最直观的生活比喻出发,通过图解与循序渐进的代码实现,彻底吃透它们的底层原理与工程实践。
一、生活隐喻:秒懂两者的控制哲学
| 机制 | 经典生活比喻 | 核心行为准则 | 绝妙类比 |
|---|
| 防抖 (Debounce) | 电梯关门 / 回城被打断 | 无论触发多少次,只有最后一次安静下来等待一定时间后,才真正执行一次 | 只要有人进电梯,关门倒计时就重置;只要回城被打断,8秒倒计时就重新来过 |
| 节流 (Throttle) | 技能冷却 CD / 水龙头匀速滴水 | 不管按得多疯狂,在固定周期内只能执行一次 | 英雄释放技能后进入 5 秒 CD,CD 期间疯狂按键无效,只有冷却完毕才能触发下一次 |
二、防抖(Debounce):宁静方能致远
1. 运行机制与时序分析
防抖的核心是:“如果事件持续被触发,就不断重置定时器,只有在事件停止触发超过指定时间间隔(delay)后,才执行一次目标函数。”
1 2 3 4
| 高频事件: ---|-||-|||-------||---------|--------> 普通执行: ---X-XX-XXX-------XX---------X--------> (执行 8 次) 防抖执行: ----------------X-----------X--------X> (执行 3 次,每次都在平息后执行) |___delay___| |__delay__|
|
防抖常分为两种触发时机:
- 延迟执行(Trailing / 默认):等待高频操作全部结束后,在末尾执行一次(如百度搜索框自动补全输入);
- 立即执行(Leading / 前沿):第一次触发时立刻执行,随后的高频触发全部压制,直到完全平息指定时间后,才允许下一次立即执行(如防止表单提交按钮被手抖连续连击两次)。
2. 循序渐进手写 Debounce
基础版(延迟执行 + this与参数绑定)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
function debounce(fn, delay = 300) { let timer = null;
return function (...args) { if (timer) { clearTimeout(timer); }
timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); }; }
|
进阶完整版(支持立即执行 leading + 手动取消 cancel)
在实际业务组件销毁(如 Vue beforeUnmount / React useEffect cleanup)时,未执行的定时器需要被安全销毁:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
|
function debounce(fn, delay = 300, immediate = false) { let timer = null;
function debounced(...args) { if (timer) clearTimeout(timer);
if (immediate) { const callNow = !timer;
timer = setTimeout(() => { timer = null; }, delay);
if (callNow) { fn.apply(this, args); } } else { timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); } }
debounced.cancel = function () { if (timer) { clearTimeout(timer); timer = null; } };
return debounced; }
|
三、节流(Throttle):细水长流,匀速推进
1. 运行机制与时序分析
节流的核心是:“无论在指定时间周期(wait)内被高频触发了多少次,该周期内保证且仅保证执行一次。”
1 2 3 4
| 高频事件: ---||||||||||||||||||||||||||||||-----> 普通执行: ---XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX-----> (执行 30 次) 节流执行: ---X-------X-------X-------X----------> (固定间隔匀速执行 4 次) |--wait-|--wait-|--wait-|
|
节流的实现主流上有两种流派:时间戳法与定时器法。
2. 循序渐进手写 Throttle
方案 A:时间戳版(首发立即执行)
利用当前时间与上次执行时间的差值,如果大于设定的时间间隔则执行,并更新上次执行时间戳。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
function throttleByTimestamp(fn, wait = 300) { let previous = 0;
return function (...args) { const now = Date.now(); if (now - previous > wait) { fn.apply(this, args); previous = now; } }; }
|
方案 B:定时器版(末尾延后执行)
触发时如果没有正在跑的定时器,就设置一个定时器;定时器执行完毕后将标识置空。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
function throttleByTimer(fn, wait = 300) { let timer = null;
return function (...args) { if (!timer) { timer = setTimeout(() => { fn.apply(this, args); timer = null; }, wait); } }; }
|
方案 C:双剑合璧(首发立即执行 + 末尾补发最后一次执行)
在真实应用中,用户希望在刚开始滚动时立刻得到响应(保证即时流畅感),且在停止滚动后也能确保最后一次位置被准确更新。
将时间戳与定时器结合,便可实现一个生产级的完备节流器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
|
function throttle(fn, wait = 300) { let timer = null; let previous = 0;
function throttled(...args) { const now = Date.now(); const remaining = wait - (now - previous);
if (remaining <= 0 || remaining > wait) { if (timer) { clearTimeout(timer); timer = null; } fn.apply(this, args); previous = now; } else if (!timer) { timer = setTimeout(() => { fn.apply(this, args); previous = Date.now(); timer = null; }, remaining); } }
throttled.cancel = function () { if (timer) { clearTimeout(timer); timer = null; } previous = 0; };
return throttled; }
|
四、核心应用场景选型指南
在遇到高频触发事件时,应该选择防抖还是节流?牢记以下原则:
适合使用防抖(Debounce)的场景
- 输入框实时联想搜索(Input / Keyup):用户疯狂打字输入时不必每次按键都发请求,等打完字停顿 300ms 后才发一次;
- 窗口大小缩放(Window Resize):只需要在用户调整完窗口尺寸后,重绘一次图表或计算布局;
- 防止提交按钮快速连击(Click):前沿防抖,用户快速连点只生效第一次;
- 文本编辑器自动草稿保存:停下笔几秒后自动保存草稿。
适合使用节流(Throttle)的场景
- 页面滚动监听(Window Scroll):
- 计算吸顶导航栏样式切换;
- 监听滚动位置懒加载图片;
- 滚动到底部触发“加载更多数据”;
- 鼠标持续移动轨迹跟踪(Mousemove / Touchmove):拖拽元素时按固定帧率(如 16ms/60fps)计算坐标;
- 视频播放进度上报:每隔 1~2 秒采集一次播放时长,而不是每个 timeupdate 微小步长都上传。
五、深入 Lodash:工业级实现的多维度考量
在实际团队项目中,我们往往优先使用成熟的工具库 Lodash:
翻阅 Lodash 源码可以发现,_.throttle 底层其实就是基于 _.debounce 封装实现的!它通过给防抖增加了 maxWait(最大等待时长)参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| function throttle(func, wait, options) { let leading = true; let trailing = true;
if (typeof options === 'object') { leading = 'leading' in options ? !!options.leading : leading; trailing = 'trailing' in options ? !!options.trailing : trailing; }
return debounce(func, wait, { leading: leading, maxWait: wait, trailing: trailing }); }
|
这不仅体现了极高水平的代码抽象能力,也从底层印证了两者的血缘关系:节流是在防抖的“等待机制”上加了一道“绝不能无限期延后”的硬性时间红线。
六、总结
防抖和节流都是前端利用闭包与高阶函数实现时间流控制的典范。
- 防抖重在防抖动、求结果:关注的是高频事件平息后的最终状态;
- 节流重在保节奏、控速率:关注的是高频事件在持续运行中的平稳过程。
搞清楚两者的生活隐喻,掌握首发与延后的组合手写能力,无论是日常复杂业务的性能调优,还是面试现场白板写代码,都能信手拈来。