在前端开发中,我们经常会遇到一些高频率触发的事件,比如窗口的 resize
、页面的 scroll
、输入框的 input
、按钮的 click
等。如果不加以控制,这些事件会在极短的时间内被多次触发,导致性能问题,甚至引发页面卡顿。为了解决这个问题,前端开发中常用两种优化手段:防抖(Debounce) 和 节流(Throttle)
一、什么是防抖(Debounce)?
防抖 的核心思想是:在事件被触发 n 秒后再执行回调,如果在这 n 秒内事件又被触发,则重新计时。也就是说,只有最后一次事件触发后,经过指定时间才会执行回调。
就好比你在敲门,只有在你停止敲门一段时间后,屋里的人才会来开门。如果你一直敲,屋里的人就一直等,直到你停下来。
常用于:
- 搜索框实时联想(用户输入时,防止每输入一个字符就发送一次请求)
- 窗口大小调整(resize)结束后再执行计算
- 表单验证(用户停止输入后再校验)
实现方式
javascript
function debounce(fn, delay) {
// 用于保存定时器的引用
let timer = null;
// 返回一个新的函数,替代原有的事件回调
return function (...args) {
// 每次触发时,先清除上一次的定时器
clearTimeout(timer);
// 重新设置定时器,delay毫秒后执行fn
timer = setTimeout(() => {
fn.apply(this, args); // 保证this指向和参数不变
}, delay);
};
}
使用样例:
js
window.addEventListener('resize', debounce(() => {
console.log('窗口大小调整结束');
}, 500));
这里的意思是:只有用户停止调整窗口 500ms 后,才会执行回调。如果在 500ms 内又触发了 resize,计时会重新开始。
二、什么是节流(Throttle)?
节流 的核心思想是:规定一个单位时间,在这个单位时间内,只能有一次事件处理函数被执行。即使在这个时间段内事件被多次触发,也只会执行一次。
假设你在马路上拍照,每隔 1 秒钟只能拍一张,无论你多快按快门,1 秒内只能拍一次。
常用于:
- 页面滚动加载(scroll 事件,防止频繁触发)
- 按钮防止多次点击提交
- 游戏中的主循环(requestAnimationFrame)
实现方式
javascript
function throttle(fn, delay) {
// 记录上一次执行回调的时间
let last = 0;
// 返回一个新的函数,替代原有的事件回调
return function (...args) {
const now = Date.now();
// 如果距离上次执行的时间超过了delay,则执行回调
if (now - last > delay) {
last = now;
fn.apply(this, args); // 保证this指向和参数不变
}
};
}
使用样例:
javascript
window.addEventListener('scroll', throttle(() => {
console.log('页面正在滚动');
}, 200));
这里的意思是:无论 scroll 事件多频繁触发,回调函数最多每 200ms 执行一次。
三、防抖与节流的区别与选择
特性 | 防抖(Debounce) | 节流(Throttle) |
---|---|---|
执行时机 | 停止触发后执行一次 | 间隔一段时间执行一次 |
适用场景 | 输入框、表单、resize等 | scroll、mousemove等高频事件 |
目的 | 减少事件触发次数 | 限制事件触发频率 |
选取思路:
如果希望事件只在停止触发后执行一次,用防抖。
如果希望事件在一段时间内只执行一次,用节流。
四、防抖与节流的变种实现
在实际开发中,防抖和节流的需求往往比"只执行最后一次"或"每隔一段时间执行一次"更复杂。下面我结合自己的经验,深入讲讲常见的变种和实际应用细节。
4.1 防抖的立即执行与非立即执行
4.1.1 非立即执行(常规防抖)
这种方式就是前面讲的:事件触发后,只有在指定时间内不再触发,回调才会执行。适合"只关心用户停止操作后"的场景,比如输入框搜索。
4.1.2 立即执行版防抖
有时候,我希望用户第一次操作就立即响应,但后续在冷却期内不再触发。比如按钮防重复点击,第一次点击立刻响应,之后一段时间内无论怎么点都无效。
实现思路:
- 第一次触发时立即执行回调,并设置定时器;
- 冷却期内再次触发只会重置定时器,不会再次执行回调;
- 冷却期结束后,允许下次立即执行。
代码实现与注释:
javascript
function debounceImmediate(fn, delay) {
let timer = null;
return function (...args) {
// 如果没有定时器,说明是第一次触发,立即执行
if (!timer) {
fn.apply(this, args);
}
// 每次触发都重置定时器
clearTimeout(timer);
timer = setTimeout(() => {
timer = null; // 冷却期结束,允许下次立即执行
}, delay);
};
}
应用场景举例:
- 防止表单重复提交
- 按钮点击防抖
4.2 节流的两种实现方式
4.2.1 时间戳版节流
原理回顾:
每次事件触发时,判断距离上一次执行回调的时间是否超过了设定的间隔,如果超过就执行,否则不执行。
优点:
- 首次触发会立即执行
- 适合需要"立刻响应"的场景
4.2.2 定时器版节流
原理讲解:
- 第一次触发时设置定时器,定时器到点后执行回调并清空定时器;
- 在定时器期间内再次触发,不会重复设置定时器。
代码实现与注释:
javascript
function throttleTimer(fn, delay) {
let timer = null;
return function (...args) {
// 如果定时器不存在,说明可以执行
if (!timer) {
timer = setTimeout(() => {
fn.apply(this, args);
timer = null; // 执行完毕后清空定时器
}, delay);
}
};
}
优点:
- 最后一次触发一定会被执行
- 适合"只关心最后一次操作"的场景
4.2.3 时间戳+定时器混合版节流
有时候我希望既能立即执行一次,也能保证最后一次操作被执行,这时可以结合时间戳和定时器:
实现思路:
- 事件首次触发时立即执行
- 之后每隔 delay 执行一次
- 最后一次操作后,如果还有残留的事件,保证它也会被执行
代码实现与注释:
javascript
function throttleHybrid(fn, delay) {
let last = 0;
let timer = null;
return function (...args) {
const now = Date.now();
if (now - last > delay) {
// 距离上次执行已超过delay,立即执行
last = now;
fn.apply(this, args);
} else {
// 否则设置定时器,保证最后一次也能被执行
clearTimeout(timer);
timer = setTimeout(() => {
last = Date.now();
fn.apply(this, args);
}, delay - (now - last));
}
};
}
应用场景举例:
- 页面滚动监听,既要响应首次滚动,也要在用户停止滚动后再处理一次
4.3 防抖与节流的参数扩展
在实际项目中,我们可能还需要给防抖和节流函数加上一些参数,比如:
- 是否立即执行
- 是否在冷却期结束后再执行一次
- 最大等待时间(maxWait)
这样可以让函数更灵活,适应更多场景。
4.4 防抖与节流的注意事项
-
this指向和参数传递
在实现时要注意用
fn.apply(this, args)
保证回调里的this
和参数不变。 -
取消功能
有时候需要在某些场景下手动取消防抖/节流,可以在返回的函数上挂载一个
cancel
方法,清除定时器。 -
与Promise结合
如果回调是异步操作,可以结合 Promise 进行链式调用。