最近是金三银四面试季,相信不少公司面试题都会涉及到防抖节流的问题,有的面试题甚至是手写实现,今天我们就来看下防抖节流的应用场景以及它内部实现的逻辑。

什么是防抖节流

  用户在页面上进行窗口大小的调整、滚动页面或者在输入框搜索联想词等一系列操作时,都会频繁的触发事件处理函数;如果这时候又需要在事件处理函数里去异步获取数据或者进行DOM的操作等耗性能的操作时,容易导致页面卡顿等影响用户的体验;这时就可以通过防抖(debounce)和节流(throttle)函数来限制事件处理函数的调用频率,提升用户的体验。

principle.png

  最上面正常执行每一条竖线代表了每一次事件处理函数的调用,中间是经过防抖函数处理后实际的调用情况,最下面是经过节流函数处理后的调用情况;发现比最上面密集调用的情况要少了很多。

实现防抖

  防抖,最开始是用在相机上,我们在拍照时(包括用手机拍),经常会发现由于手的抖动,拍摄出来的画面发生重影或者模糊的情况;而现在的相机或手机基本都会加入防抖技术,除非我们抖动特别的厉害,防抖技术的加入可以让我们拍摄更多清晰的照片。

  而在我们的JS中,防抖是指触发事件后n秒后才执行函数,如果在 n 秒内又触发了事件,则会重新计算函数执行时间;这段话比较绕口,我们以scroll函数为例:

1
2
3
4
function scrollHandler() {
console.log('handle')
}
window.addEventListener('scroll', scrollHandler)

  我们在页面滚动时会不断触发scrollHandler函数,但是我们不希望每次都触发,因此我们可以通过包装防抖函数来进行限制,当延迟时间超过n秒才真正执行scrollHandler函数。

debounce1.png

  而防抖函数实现的方式也很简单,在每次触发事件时,都设置一个定时器,延迟执行,并且取消之前的定时器。

1
2
3
4
5
6
7
8
9
10
11
12
13
function debounce(fn, wait) {
var timeout
return function () {
var context = this,
args = arguments
clearTimeout(timeout)

timeout = setTimeout(function () {
fn.apply(context, args)
}, wait)
}
}
window.addEventListener('scroll', debounce(scrollHandler, 500))

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  debounce主要的目的就是延迟执行传入的fn函数,我们发现它返回了一个函数,是典型的闭包结构;页面滚动将每次触发scrollHandler变成每次都会触发debounce中返回的闭包函数;由于闭包的存在,因此timeout定时器变量会一直存在,触发闭包函数时都会清除上次设置的定时器。

  这里调用fn时,很多fn函数都是滚动或者点击的回调函数,会提供Event对象进行处理,因此我们需要将原来的参数传入以及this进行绑定;因此分别赋值了变量contextargs,这里arguments是类数组对象,那么什么是类数组对象呢,我们在函数中console.log出来看一下:

arguments.png

  我们发现它的属性名是按照从0开始的index,第一个参数的属性是’0’,第二个属性名是’1’,并且它还有个length属性;但是它和数组不同的是它__proto__直接指向了Object,而数组的__proto__指向了Array;因此Array原型上的一些map、find等方法arguments也是没有的。

  我们回到防抖函数,上面的防抖函数是非立即执行的,也就是触发事件后不会马上执行,但是我们某些场景下需要立即执行;立即执行后当n秒内触发事件才能再次执行。

debounce2.png

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  因此我们来看下立即执行版本的防抖函数时如何来实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function debounce(fn, wait) {
let timeout, result;
return function () {
const context = this
const args = arguments
clearTimeout(timeout)
const callNow = !timeout
timeout = setTimeout(function() {
timeout = null
}, wait)
if (callNow) result = fn.apply(context, args)
return result
}
}

  在上面代码中我们还是通过闭包返回了一个匿名函数,但是在里面增加了一个变量callNow的判断,判断上一次的定时器是否已经被清除,如果没有定时器则立即执行fn函数。

  在开发过程中我们需要根据不同的场景来切换不同版本的防抖函数,因此将两个防抖函数结合起来,根据参数来进行判断:

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
/**
* fn:执行函数
* wait:延迟执行时间
* immediate:是否立即执行
**/
function debounce(fn, wait, immediate) {
var timeout, result;
return function () {
var context = this
var args = arguments
clearTimeout(timeout)
if (immediate) {
var callNow = !timeout
timeout = setTimeout(function () {
timeout = null
}, wait)
if (callNow) result = fn.apply(context, args)
} else {
timeout = setTimeout(function () {
fn.apply(context, args)
}, wait)
}
return result
}
}

  到这里我们的防抖函数已经接近完美了,但是最后如果我们希望能够取消这里的debounce函数,比如我们传入wait是10秒,immediate为true,刚开始是立即执行fn函数的,但是我们需要等待10秒才能重新去触发fn函数,中间做的所有操作都是无效的;我们希望能有一个按钮,点击后能够取消上一次的防抖,然后我们就能够再次触发了。

  这里改动也很简单,我们需要对返回闭包函数进行处理,但是由于是匿名函数,我们给他具名,同时赋值一个cancel函数用来清除闭包外的定时器timeout即可:

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
function debounce(fn, wait, immediate) {
var timeout, result;
var debounced = function () {
var context = this
var args = arguments
clearTimeout(timeout)
if (immediate) {
var callNow = !timeout
timeout = setTimeout(function () {
timeout = null
}, wait)
if (callNow) result = fn.apply(context, args)
} else {
timeout = setTimeout(function () {
fn.apply(context, args)
}, wait)
}
return result
}
debounced.cancel = function() {
clearTimeout(timeout)
timeout = null
}
return debounced
}

  那么如何来调用这个cancel函数呢?我们还是以scroll函数为例,通过给页面上的btn取消按钮增加点击事件进行触发:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
7
8
var cancelBtn = document.getElementById('btn')
var setDebounce = debounce(function () {
console.log('handle')
}, 10000, true)
cancelBtn.addEventListener('click', function () {
setDebounce.cancel()
})
window.addEventListener('scroll', setDebounce)

  到这里我们的防抖函数就很完美了。

实现节流

  节流函数是指当持续触发事件时,保证一定时间段内只调用一次事件处理函数;也就是会稀释处理函数的执行频率。我们通过时间轴来清晰的看下它的执行过程:

throttle1.png

  我们可以看出,节流函数不管在一个周期内触发了多少次scroll函数,也不管触发的时间间隔,最后只会执行周期内的最后一次(或者第一次);节流函数应用场景一般在窗口resize时进行布局的调整或者移动端监听touchmove事件时移动DOM元素等;节流函数同样也有时间戳和定时器两个版本,我们先来看定时器版的节流函数实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
function throttle(fn, wait) {
let timeout;
return function () {
let context = this
let args = arguments
if (!timeout) {
timeout = setTimeout(function() {
timeout = null
fn.apply(context, args)
}, wait)
}
}
}

  和防抖函数每次清除timeout不同,这里对timeout进行非空判断,只有它为空的时候才能设置定时器,这样保证了在一段时间内同时只有一个定时器,在时间到之后会释放定时器并且执行fn函数,重新设置定时器。

  我们再来看下时间戳版本的节流函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
function throttle(fn, wait) {
var context, args
var previous = 0
return function () {
var now = +new Date()
context = this
args = arguments
if (now - previous > wait) {
fn.apply(context, args)
previous = now
}
}
}

  时间戳版本的函数是在闭包函数的外部存储了一个previous变量,是上次执行的一个时间戳;每次触发内部闭包函数时与上次的时间戳进行对比判断,如果间隔时间大于我们设置的等待时间则执行fn函数,同时更新时间戳;同时由于我们初始化previous是0,而now当前的时间戳减去0肯定是会大于wait时间的,因此时间戳版本的节流函数fn一开始就会被触发。

  通过上面我们很容易就能发现,两个版本的节流函数最大的不同就是fn函数执行的时间点,定时器版本由于setTimeout延时的特性,在时间段结束的时候触发fn函数,而时间戳版本是在时间段开始的时候触发。

  同样的,我们可以将两种节流函数结合到一个函数,我们可以加上cancel取消方法:

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
/**
* fn:执行函数
* wait:延迟执行时间
* immediate:是否立即执行
**/
function throttle(fn, wait, immediate) {
let timeout
let previous = 0
var throttled = function () {
let context = this
let args = arguments
if (immediate) {
let now = Date.now()
if (now - previous > wait) {
fn.apply(context, args)
previous = now
}
} else {
if (!timeout) {
timeout = setTimeout(() => {
timeout = null
fn.apply(context, args)
}, wait)
}
}
}
throttled.cancel = function() {
clearTimeout(timeout)
previous = 0
timeout = null
}
return throttled
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

更多前端资料请关注作者公众号``【前端壹读】``。
PS:公众号接入了图灵机器人小壹,欢迎各位老铁来撩。

本文地址: http://xieyufei.com/2021/04/10/Debounce-Throttle.html