跳到主要内容

防抖和节流

这两个函数需要达到手写的要求

scroll 事件本身会触发页面的重新渲染,同时 scroll 事件的 handler 又会被高频度的触发, 因此事件的 handler 内部不应该有复杂操作,例如 DOM 操作就不应该放在事件处理中。 针对此类高频度触发事件问题(例如页面 scroll ,屏幕 resize,监听用户输入等),有两种常用的解决方法,防抖和节流。

防抖(Debouncing)

典型例子:限制 鼠标连击 触发。

一个比较好的解释是:

当一次事件发生后,事件处理器要等一定阈值的时间,如果这段时间过去后 再也没有 事件发生,就处理最后一次发生的事件。假设还差 0.01 秒就到达指定时间,这时又来了一个事件,那么之前的等待作废,需要重新再等待指定时间。

触发高频事件后 n 秒内函数只会执行一次,如果 n 秒内高频事件再次被触发,则重新计算时间

形像的比喻是橡皮球。如果手指按住橡皮球不放,它就一直受力,不能反弹起来,直到松手。

// 防抖动函数
// 每次触发事件时都取消之前的延时调用方法
function debounce(func, time = 50, immediate) {
let timer = null; // 创建一个标记用来存放定时器的返回值
return () => {
if (immediate) {
fn.apply(this, arguments);
}
if (timer) clearTimeout(timer); // 每当用户输入的时候把前一个
timer = setTimeout(() => {
// 然后又创建一个新的 setTimeout, 这样就能保证输入字符后的 interval 间隔内如果还有字符输入的话,就不会执行 fn 函数
func.apply(this, arguments);
}, time);
};
}

这个节流函数有点问题,可以做优化,第一次应该是立即执行,而不是 delay 500ms 后再执行

结合实例:滚动防抖

// 简单的防抖动函数
// 实际想绑定在 scroll 事件上的 handler
function realFunc() {
console.log("Success");
}

// 采用了防抖动
window.addEventListener("scroll", debounce(realFunc, 500));
// 没采用防抖动
window.addEventListener("scroll", realFunc);

节流(Throttling)

可以理解为事件在一个管道中传输,加上这个节流阀以后,事件的流速就会减慢。实际上这个函数的作用就是如此,它可以将一个函数的调用频率限制在一定阈值内,例如 1s,那么 1s 内这个函数一定不会被调用两次

s 高频事件触发,但在 n 秒内只会执行一次,所以节流会稀释函数的执行频率

形像的比喻是水龙头或机枪,你可以控制它的流量或频率。

每次触发事件时都判断当前是否有等待执行的延时函数

function throtte(func, time) {
let activeTime = 0;
return () => {
const current = Date.now();
if (current - activeTime > time) {
func.apply(this, arguments);
activeTime = Date.now();
}
};
}
function throttle(func, wait) {
var timeout;
var previous = 0;
return function () {
context = this;
args = arguments;
if (!timeout) {
timeout = setTimeout(function () {
timeout = null;
func.apply(context, args);
}, wait);
}
};
}
function throttle(fn, wait) {
let prev = new Date();
return function() {
const args = arguments;
const now = new Date();
if (now - prev > wait) {
fn.apply(this, args);
prev = new Date();
}
}
function throttle(fn, delay = 500) {
let canRun = true; // 通过闭包保存一个标记
return function () {
if (!canRun) return; // 在函数开头判断标记是否为true,不为true则return
canRun = false; // 立即设置为false
setTimeout(() => {
// 将外部传入的函数的执行放在setTimeout中
fn.apply(this, arguments);
// 最后在setTimeout执行完毕后再把标记设置为true(关键)表示可以执行下一次循环了。当定时器没有执行的时候标记永远是false,在开头被return掉
canRun = true;
}, delay);
};
}

组合实现

通过第三个参数来切换模式

const throttle = function (fn, delay, isDebounce) {
let timer;
let lastCall = 0;
return function (...args) {
if (isDebounce) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn(...args);
}, delay);
} else {
const now = new Date().getTime();
if (now - lastCall < delay) return;
lastCall = now;
fn(...args);
}
};
};

应用

只要牵涉到连续事件或频率控制相关的应用都可以考虑到这两个函数,比如:

  • 游戏射击,keydown 事件
  • 文本输入、自动完成,keyup 事件
  • 鼠标移动,mousemove 事件
  • DOM 元素动态定位,window 对象的 resize 和 scroll 事件

前两者 debounce 和 throttle 都可以按需使用;后两者肯定是用 throttle 了。

如果不做过滤处理,每秒种甚至会触发数十次相应的事件。尤其是 mousemove 事件,每移动一像素都可能触发一次事件。如果是在一个画布上做一个鼠标相关的应用,过滤事件处理是必须的,否则肯定会造成糟糕的体验。

实现中要注意的是 throttle 函数可以不使用定时器,这时关联的函数都同步执行,这样很不错,比如一个游戏射击应用,50ms 间隔,没什么影响。但是如果是一个固定元素定位应用,就有可能必须考虑补上最后一次触发事件了,这时就必须用到定时器。

同样的,使用中注意的有:

  1. 返回值。如果关联的函数有返回值的话,如果某次触发是异步执行的,返回值就获取不到了。可以考虑扩展这里使用的版本,添加回调函数参数或扩展成 throttle 对象来使用。

  2. 传入参数。我直接捕获了闭包中的 arguments 参数,异步执行时会使用最后一次触发的参数。

我在流行的 Rx、Ext 和 Underscore 中都看到过类似的函数。对比了一下, Underscore 中的函数是简化了的, debounce 只能在尾部执行, throttle 关联的函数全部是异步执行——首次触发时它甚至不会去执行关联函数,这是定时器本身延后执行的特性。

参考