前端基础:彻底搞懂防抖与节流(从生活隐喻到手写实现)

在前端开发与面试中,**防抖(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
/**
* 基础版防抖函数
* @param {Function} fn 目标业务函数
* @param {number} delay 延迟等待时间(毫秒)
* @returns {Function} 防抖包装后的函数
*/
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
/**
* 完整版防抖函数(支持立即执行与手动取消)
* @param {Function} fn 目标函数
* @param {number} delay 等待时间
* @param {boolean} immediate 是否前沿立即执行
*/
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
/**
* 生产级节流函数:兼顾首发立即执行与末尾精准补发
* @param {Function} fn 业务函数
* @param {number} wait 节流周期
*/
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
// Lodash throttle 的本质结构
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;
}

// 核心机密:节流就是带有最大等待期限(maxWait)的防抖!
return debounce(func, wait, {
leading: leading,
maxWait: wait,
trailing: trailing
});
}

这不仅体现了极高水平的代码抽象能力,也从底层印证了两者的血缘关系:节流是在防抖的“等待机制”上加了一道“绝不能无限期延后”的硬性时间红线。


六、总结

防抖和节流都是前端利用闭包与高阶函数实现时间流控制的典范。

  • 防抖重在防抖动、求结果:关注的是高频事件平息后的最终状态;
  • 节流重在保节奏、控速率:关注的是高频事件在持续运行中的平稳过程。

搞清楚两者的生活隐喻,掌握首发与延后的组合手写能力,无论是日常复杂业务的性能调优,还是面试现场白板写代码,都能信手拈来。


前端基础:彻底搞懂防抖与节流(从生活隐喻到手写实现)
https://bubao.github.io/posts/193371a7.html
作者
一念
发布于
2021年12月17日
许可协议