理解防抖
本文最后编辑于:2025年5月31日 晚上
为什么需要防抖
防抖主要应用在一些高频率触发的事件中,比如:scroll
滚动、input
输入、resize
缩放等,能显著降低性能开销。
什么是防抖
防抖的核心思想是:高频率触发的事件中,仅保留最后一次操作,之前的操作都会被取消。
举例来说,当你在页面中不断滚动,防抖函数会等待设定的一段时间,如果这段时间内没有再次触发滚动,才会执行处理逻辑;否则会重新计时,直到用户 “安静下来” 再执行操作。
防抖函数的一般形式如下:
1
2
3
4
5
6
7
8
9
10
function debounce(fn, delay) { // fn为传入的函数名
let timer = null; // 用来保存定时器的 ID
return function(...args) { // 返回一个闭包函数
if (timer) clearTimeout(timer); // 若已有定时器则清除它
timer = setTimeout(() => { // 设置一个新的定时器
// 当延时结束时,执行传入的函数,并绑定当前的上下文和参数
fn.apply(this, args);
}, delay); // delay 是等待的时间,单位是毫秒 ms
};
}
使用示例
假设我们有一个函数 handleScroll
,它在页面滚动时处理一些操作,我们不希望它每次滚动都触发,因为这会增加不必要的性能消耗,我们希望它在滚动停止后执行,那么可以这样使用防抖函数
1
2
3
4
5
6
7
8
9
10
function handleScroll() {
console.log("处理滚动事件中...");
// 实际逻辑略
}
// 使用防抖包装滚动事件处理函数,等待300毫秒
const debouncedHandleScroll = debounce(handleScroll, 300);
// 当页面滚动时
window.addEventListener('scroll', debouncedHandleScroll);
这样处理后,滚动事件停止 300ms 后才会触发 handleScroll
,大大减少了触发频率。
理解防抖函数
每次触发 debounce
返回的函数时:
- 如果存在旧的定时器,则先清除;
- 然后设置一个新的定时器;
- 只有在用户停止触发事件一段时间后,才执行原始函数。
换句话说:
多次触发事件时,只有最后一次触发后的延迟时间结束后才会执行目标函数。
什么是 timer?
timer
是由 setTimeout
返回的定时器标识符,用来取消未完成的定时任务。
具体来说,setTimeout 的返回值是一个定时器的标识符 ID
。而定时器的 ID 的类型因环境不同也有所差异:在浏览器环境中,setTimeout 的返回是一个数值类型的 ID,例如第一次调用可能返回 1,第二次为 2,依次递增;在 Node.js 环境中,setTimeout 返回值可能是一个定时器对象,但仍然可以通过 clearTimeout 消除。当然也可以将 timer 写作别的名称,只要合法都行,但为了直观还是建议写成 timer
为什么要每次清除旧定时器?
如果不清除旧的定时器,每次事件触发都会设置一个新的定时器,导致多个定时器同时生效,函数可能会被多次调用,完全违背了 “只执行最后一次” 的目的。
现在,假设每次开始都清除定时器,那么当前的滑动事件会将上一次的滑动事件定时清除,并为自己这一次的滑动事件进行定时,而这一次若为最后一次,函数将不再会被调用,也就不再会清除定时器,这一次的事件将会被正确的以设置好的时间定时进行处理。
电梯门的防抖类比
这种事件可以类比生活中的乘电梯事件:将电梯门看作事件处理函数,进入电梯的这一动作看作不希望的高频次事件。当人按下电梯按钮,电梯抵达并开门,开门的同时开始计时,每有一个人被红外检测到,人经过电梯门进入电梯之后,电梯门的计时就重新开始(清除上一次的计时),直到没有人进入(高频次事件停止,进入电梯的最后一个人为最后的事件),电梯门最终关闭(定时器时间到了),电梯执行上升或下降等约定好的操作(执行回调函数)。
即:
- 每当一个人进入电梯,门会重新计时关门;
- 如果不断有人进来,门就不会关;
- 直到没人再进来,计时器才不会被清除,门会在设定时间后关闭。
这个直到没人再进来的延迟,就是防抖的关键。
为什么不将 timer 写在外部?为什么返回一个闭包函数?
timer
在功能上看,当然可以作为全局变量定义在整个函数外部,但是这样一来如果别处也要使用 timer
就会显得不太合适,timer
会被污染,不便于函数的封装复用,并且还存在安全问题,将其私有化是一个好的选择。
此外闭包可以记录外部变量的状态(这主要依靠作用域链),这样能保证多次触发事件访问到的是同一个timer
,保证多次事件触发能清除到前一个事件的定时,最终实现防抖。
这闭包到底是啥?
闭包(closure)是 JavaScript 中一个重要的概念,闭包是指一个函数可以访问它外部函数作用域中的变量,即使在外部函数已经执行结束后,它仍然可以通过引用这些变量。
什么意思呢?假设有这样一个函数
1
2
3
4
5
6
7
8
9
10
11
12
function createCounter() {
let count = 0;
return function() {
count += 1;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 输出 1
console.log(counter()); // 输出 2
console.log(counter()); // 输出 3
按理说一个函数 return 之后函数本身就已经 over 了,但是 counter 还是能够访问到 count 的值,并进行了累加。类似这样的一个返回的函数就是闭包。闭包一般是在函数嵌套的情况下形成的。当一个函数 A
返回另一个内部函数 B
,并且 B
可以访问 A
函数内部定义的变量时,闭包就形成了。
...args
是什么?为什么?
在 JavaScript 中,...args
是一种称为 剩余参数(Rest Parameters) 的语法,它用于将函数的不定数量的参数收集到一个数组中。这样可以让函数接受任意数量的参数,并将它们组合在一起,便于操作和处理。
说到为什么要使用它,还得说说它的好处。假设有这样一个函数
1
2
3
4
5
6
7
function logArguments(...args) {
console.log(args); // args 是一个数组,包含了所有传入的参数
}
logArguments(1, 2, 3); // 输出 [1, 2, 3]
logArguments("a", "b"); // 输出 ["a", "b"]
logArguments(); // 输出 []
不论传入多少参数,有还是没有,它都能够接收并存储在 args
数组中。
那为什么防抖中要用到呢?
一来是为了使得函数能够处理任意数量的参数,使其更通用;二来是为了能够正确地传递参数。
可别不以为意,有时候还真没多少人能正确地传递参数。
节流
⚠️防抖(debounce)和节流(throttle)经常被搞混:
- 防抖:N 秒内事件不断触发,只执行最后一次(停止触发 N 秒后执行)
- 节流:N 秒内只执行一次(无论触发多少次,都限制频率)
节流适用场景
- 无限滚动分页加载
- 页面滚动触发动画
- 鼠标移动实时反馈等
防抖变种:立即执行(immediate
)
上述讨论的是 “非立即执行的防抖”,即:等事件不再触发后才执行一次。
有时我们希望首次触发立即执行函数,后续触发不再响应,直到一段时间之后再允许触发,这就属于 “立即执行防抖”。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function debounce(fn, delay, immediate = false) {
let timer = null;
return function (...args) {
const context = this;
if (timer) clearTimeout(timer);
if (immediate && !timer) {
fn.apply(context, args); // 首次立即执行
}
timer = setTimeout(() => {
if (!immediate) fn.apply(context, args); // 非立即执行逻辑
timer = null;
}, delay);
};
}
这种变种适用场景例如:
- 用户首次输入时立即触发搜索建议
- 防止按钮被连续点击:首次有效,接下来点击无效
- 页面初始化后的 resize 初始化布局等
总结
类型 | 执行时机 | 典型场景 |
---|---|---|
防抖 Debounce | 停止触发后一段时间执行 | 输入框搜索、滚动监听、resize |
节流 Throttle | 固定频率执行 | 上拉加载、鼠标移动、FPS 控制等 |
立即执行防抖 | 首次触发立即执行 | 快速响应用户操作(防重复点击) |