函数防抖和节流

函数防抖和节流

关于防抖和节流,是一个老生常提的话题了,随便在网上搜一搜,都可以找到现成可以使用的函数,但是现成的函数的功能实现的都是已经比较完善的了,我们并不清楚它如何或者为什么要这样设计,所以我们今天就从头开始的来深入的了解一下这两个方法,主要参考的是 Lodash 当中的 _.throttle_.debounce 两个方法

什么是防抖和节流

我们可以使用一个现实中常见的例子来进行举例,比如使用电梯运送策略来说明这两个方法,比如每天上班大厦底下的电梯,把电梯完成一次运送,类比为一次函数的执行和响应,假设电梯有两种运行策略 throttledebounce ,超时设定为 15 秒,不考虑容量限制

  • throttle 策略的电梯,保证如果电梯第一个人进来后,15 秒后准时运送一次,不等待,如果没有人,则待机
  • debounce 策略的电梯,如果电梯里有人进来,等待 15 秒,如果有人进来,15 秒等待重新计时,直到 15 秒超时,开始运送

但是有一点需要注意,当然你也可以采用箭头函数等一些 ES6 当中的方式来进行实现,但是本文大部分都是采用 ES5 的方式来进行介绍,主要是以介绍原理为主

我们先从防抖开始看起

debounce

防抖即 debounce,根据我们之前的思路,我们可以迅速的实现我们的第一版代码

1
2
3
4
5
6
7
8
// 第一版
function debounce(fn, wait) {
var timer
return function () {
clearTimeout(timer)
timer = setTimeout(fn, wait)
}
}

运行以后可以发现,是存在问题的,函数当中的 this 是指向 window 对象的,所以我们需要将 this 指向正确的对象,所以针对于这种情况,我们可以使用一个变量来保存当前的 this

注意,这里如果使用了箭头函数则直接传递 this 即可,因为箭头函数会从自己的作用域链的上一层继承 this

1
2
3
4
5
6
7
8
9
10
11
// 第二版
function debounce(fn, wait) {
var timer
return function () {
var that = this
clearTimeout(timer)
timer = setTimeout(function() {
fn.apply(that)
}, wait)
}
}

但是现在依然是存在一定问题的,因为我们传入的 fn 是有可能携带参数的,所以我们需要将其补上,也就有了下面的第三版

1
2
3
4
5
6
7
8
9
10
11
12
// 第三版
function debounce(fn, wait) {
var timer
return function () {
// 注意保存 arguments,防止 setTimeout 当中获取不到参数
var that = this, args = arguments
clearTimeout(timer)
timer = setTimeout(function() {
fn.apply(that, args)
}, wait)
}
}

目前为止,我们已经完成了一个较为完善的方法,但是现在的需求有所变化,我们不希望非要等到事件停止触发后才执行我们的函数,而是希望可以在开始的时候立刻执行函数,然后等到停止触发 wait 秒后,才可以重新触发执行,所以针对这种情况,我们添加一个 immediate 参数判断是否是立刻执行,如果 immediate 参数传递为 true,则每隔 wait 秒后才会再次执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 第四版
function debounce(fn, wait, immediate) {
var timer
return function () {
var that = this, args = arguments
if (timer) clearTimeout(timer)
if (immediate) {
// 如果已经执行过,便不再执行
var callNow = !timer // ==> 第一点
timer = setTimeout(function () { // ==> 第二点
timer = null
}, wait)
if (callNow) fn.apply(that, args)
} else {
timer = setTimeout(function () {
fn.apply(that, args)
}, wait)
}
}
}

关于文中标注的两点需要注意的地方,第一点

  • timer 是闭包变量,初始化时是 undefinedsetTimeout 返回的是定时器的 id ,一个大于 0 的数字,而 clearTimeout 不会改变 timer 的值
  • timer 经历过赋值,即执行过 setTimeout,则 !timer 为假,所以也就不再执行

关于第二点,在官方源码当中有提到,主要作用就是防止重复点击用的

Pass true for the immediate argument to cause debounce to trigger the function on the leading instead of the trailing edge of the wait interval. Useful in circumstances like preventing accidental double-clicks on a “submit” button from firing a second time.

我们在之前考虑到了我们传入的函数 fn 是可能带有参数的,但是同样的,它也有可能会有返回值的,但是当 immediatefalse 的时候,因为使用了 setTimeout,我们将 fn.apply(that, arguments) 的返回值 return 的时候,值将会一直是 undefined,所以我们只在 immediatetrue 的时候返回函数的执行结果,所以直接定义一个 result 进行接收以后在返回即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 第五版
function debounce(fn, wait, immediate) {
var timer, result
return function () {
var that = this, args = arguments
if (timer) clearTimeout(timer)
if (immediate) {
var callNow = !timer
timer = setTimeout(function () {
timer = null
}, wait)
if (callNow) result = fn.apply(that, args)
} else {
timer = setTimeout(function () {
fn.apply(that, args)
}, wait)
}
return result
}
}

最后,我们再来考虑一点小需求,即我们希望可以取消 debounce 函数,比如我们设定了 debounce 的时间间隔是 10 秒钟,immediatetrue,这样一来,只有等待 10 秒后才能重新触发事件,所以我们希望有一个按钮,点击后可以取消防抖,这样再去触发,就可以又立刻执行,所以我们可以使用一个函数将 return 的部分包裹起来,然后给它添加一个取消的句柄即可

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
// 第六版
function debounce(fn, wait, immediate) {
var timer, result
var debounced = function () {
var that = this, args = arguments
if (timer) clearTimeout(timer)
if (immediate) {
var callNow = !timer
timer = setTimeout(function () {
timer = null
}, wait)
if (callNow) result = fn.apply(that, args)
} else {
timer = setTimeout(function () {
fn.apply(that, args)
}, wait)
}
return result
}

debounced.cancel = function () {
clearTimeout(timer)
timer = null
}

return debounced
}

可以使用下面的代码来进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var mousemove = debounce(function (e) {
console.log(e)
}, 300)

// 绑定监听
document.addEventListener('mousemove', mousemove)

// 关于取消按钮的用法,因为我们的 debounce 返回的是一个函数
var hanle = debounce(log, 10000, true)

// 如果想取消的话,直接调用 cancel() 方法即可
btn.addEventListener('click', function () {
hanle.cancel()
})

至此我们就已经完整实现了一个 debounce 函数,防抖看完之后,我们再来看看节流

throttle

同样的逻辑,我们这次采用 throttle 策略来进行实现,基本逻辑如下,我们使用时间戳来进行记录,当触发事件的时候,我们取出当前的时间戳,然后减去之前的时间戳,如果大于设置的时间周期,就执行函数,然后更新时间戳为当前的时间戳,如果小于就不执行

1
2
3
4
5
6
7
8
9
10
11
12
// 第一版
function throttle(fn, wait) {
var prev = 0
return function () {
var that = this
var now = +new Date()
if (now - prev >= wait) {
fn.apply(that, arguments)
prev = now
}
}
}

上面是使用记录时间戳的方式来实现的,下面我们就来看看使用定时器的版本,当触发事件的时候,我们设置一个定时器,再触发事件的时候,如果定时器存在,就不执行,直到定时器执行,然后执行函数,清空定时器,这样就可以设置下个定时器

1
2
3
4
5
6
7
8
9
10
11
12
13
// 第二版
function throttle(fn, wait) {
var timer
return function () {
var that = this
if (!timer) {
timer = setTimeout(function () {
timer = null
fn.apply(that, arguments)
}, wait)
}
}
}

可以发现,虽然在实现上很类似我们上面第三版的 debounce,但是它们的原理是有所不同的,先抛开这些,我们来对比一下上面两种实现方式

  • 第一种事件会立刻执行,第二种事件会在 n 秒后第一次执行
  • 第一种事件停止触发后没有办法再执行事件,第二种事件停止触发后依然会再执行一次事件

所以我们可以将两者进行结合,即在一开始的时候可以立刻执行,然后在停止触发的时候还能再执行一次

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function throttle(fn, wait) {
var timer
var prev = 0
return function () {
var that = this, now = new Date(), args = arguments
clearTimeout(timer)
if (now - prev >= wait) {
fn.apply(that, args)
prev = now
} else {
// 让方法在脱离事件后也能执行一次
timer = setTimeout(function () {
fn.apply(that, args)
}, wait)
}
}
}

可以使用下面这段代码来进行测试

1
2
3
4
5
6
var mousemove = throttle(function (e) {
console.log(e)
}, 300)

// 绑定监听
document.addEventListener('mousemove', mousemove)

可以发现在鼠标刚移入的时候就会立即执行,并且在鼠标停止移动以后会在执行一次

总结

总结一下大致的使用场景

  • debounce,特点是它在用户不触发事件的时候,才触发动作,并且抑制了本来在事件中要执行的动作
    • 如果不添加 immediate 参数,一开始是不会立即运行的,一般用于比如 input 输入框的格式验或者提交按钮的点击事件等
  • throttle,类似于水坝,不能让水流动不了,只能让水流慢些,换言之就是不能让用户的方法都不执行,它会强制函数以固定的速率执行
    • 一般用在比 inputkeyup 更频繁触发的事件中,如 resizetouchmovemousemovescroll,另外还有动画相关的场景

如果不考虑额外功能,使用下面两种方式即可

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
function debounce(fn, wait) {
var timer
return function () {
var that = this, args = arguments
clearTimeout(timer)
timer = setTimeout(function() {
fn.apply(that, args)
}, wait)
}
}

function throttle(fn, wait) {
var timer
var prev = 0
return function () {
var that = this, now = new Date(), args = arguments
clearTimeout(timer)
if (now - prev >= wait) {
fn.apply(that, args)
prev = now
} else {
timer = setTimeout(function () {
fn.apply(that, args)
}, wait)
}
}
}

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×