防抖

你是否在日常开发中遇到一个问题,在滚动事件中需要做个复杂计算或者实现一个按钮的防二次点击操作。

这些需求都可以通过函数防抖动来实现。尤其是第一个需求,如果在频繁的事件回调中做复杂计算,很有可能导致页面卡顿,不如将多次计算合并为一次计算,只在一个精确点做操作。

PS:防抖和节流的作用都是防止函数多次调用。区别在于,假设一个用户一直触发这个函数,且每次触发函数的间隔小于wait,防抖的情况下只会调用一次,而节流的 情况会每隔一定时间(参数wait)调用函数。

我们先来看一个袖珍版的防抖理解一下防抖的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// func是用户传入需要防抖的函数
// wait是等待时间
const debounce = (func, wait = 50) => {
// 缓存一个定时器id
let timer = 0
// 这里返回的函数是每次用户实际调用的防抖函数
// 如果已经设定过定时器了就清空上一次的定时器
// 开始一个新的定时器,延迟执行用户传入的方法
return function(...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
func.apply(this, args)
}, wait)
}
}
// 不难看出如果用户调用该函数的间隔小于wait的情况下,上一次的时间还未到就被清除了,并不会执行函数

这是一个简单版的防抖,但是有缺陷,这个防抖只能在最后调用,一般的防抖会有immediate选项,标识是否立即调用,这两者的区别,举个例子来说:

  • 例如在搜索引擎搜索问题的时候,我们当然是希望用户输入完最后一个字才调用查询接口,这个时候适用 延迟执行 的防抖函数,它总是在一连串(间隔小于wait的)函数出发之后调用。
  • 例如用户给 某个github项目点star的时候,我们希望用户点的第一下的时候就去调用接口,并且成功之后改变star按钮的样式,用户就可以立马得到反馈是否star成功了,这个情况适用 立即执行 的防抖函数,它总是第一次调用,并且下一次调用必须与前一次调用的时间间隔大于wait才会触发。

下面实现了一个带有立即执行选项的防抖函数

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
34
35
36
37
38
39
40
41
42
43
44
45
46
function now () {
return +new Date()
}
/**
* 防抖函数,返回函数连续调用时,空闲时间必须大于或等于wait,func才会执行
* @param {function} func 回调函数
* @param {number} wait 表示时间窗口的间隔
* @param {boolean} immediate 表示是否立即调用函数
* @return {function} 返回客户调用函数
*/
function debounce (func, wait = 50, immediate = true) {
let timer, context, args

// 延迟执行函数
const later = () => setTimeout(() => {
// 延迟执行函数执行完毕,清空缓存的定时器序号
timer = null
// 延迟执行的情况下,函数会在延迟函数中执行
// 使用到之前缓存的参数和上下文
if (!immediate) {
func.apply(context, args)
context = args = null
}
}, wait)

// 这里返回函数是每次实际调用的函数
return function (...params) {
// 如果没有创建延迟执行函数(later),就创建一个
if (!timer) {
timer = later()
// 如果是立即执行,调用函数
// 否则缓存参数和调用上下文
if (immediate) {
func.apply(this, params)
} else {
context = this
args = params
}
// 如果已有延迟执行函数(later),调用的时候清除原来的并重新设定一个
// 这样做延迟函数会重新计时
} else {
clearTimeout(timer)
timer = later()
}
}
}

整体函数的实现并不难,总结一下:

  • 对于按钮防点击来说的实现: 如果函数是立即执行的,就立即调用,如果函数是延迟执行的,就缓存上下文和参数,放到延迟函数中去执行,一旦我开始一个定时器,只要我定时器还在,你每次点击我都重新计时。一旦你点lei了,定时器时间到,定时器重置为null,就可以再次点击了。
  • 对于延迟执行的函数来说:清楚定时器,如果是延迟调用就调用函数

节流

防抖和节流本质是不一样的,防抖动是将多次执行变为最后一次执行,节流是将多次执行变成每隔一段时间执行。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
* underscore 节流函数,返回函数连续调用时,func执行频率限定为 次/wait
* @param {function} func 回调函数
* @param {number} wait 表示时间窗口的间隔
* @param {object} options 如果想忽略开始函数的调用,传入 {leading: false}
* 如果想忽略结尾函数的调用,传入{trailing: false}
* 两者不能共存,否则函数不能执行
*@return {funciton} 返回客户调用函数
*/
_.throttle = function (func, wait, options) {
var context, args, result
var timeout = null
// 之前的时间戳
var previous = 0
if (!options) options = {}
var later = function () {
// 如果设置了 leading 就将 previous 设为 0
// 用于下面函数的第一个 if 判断
previous = options.leading === false ? 0 : _.now()
// 置空一是为了防止内存泄露 二是为了下面的定时器判断
timeout = null
result = func.apply(context, args)
if (!timeout) context = args = null
}
return function () {
// 获取当前时间戳
var now = _.now()
// 首次进入前者肯定为 true
// 如果需要第一次不执行函数
// 就将上次时间戳设为当前的
// 这样在接下来计算 remaining 的值时会大于0
if (!previous && options.leading === false) previous = now
// 计算剩余时间
var remaining = wait - (now - previous)
context = this
args = arguments
// 如果当前调用已经大于上次调用时间 + wait
// 或者用户手动调了时间
// 如果设置了 trailing,只会进入这个条件
// 如果没有设置 leading,那么第一次会进入这个条件
// 还有一点,你可能会觉得开启了定时器那么应该不会进入这个 if 条件了
// 其实还是会进入的,因为定时器的延时
// 并不是准确的时间,很可能你设置了2秒
// 但是他需要2.2秒才触发,这时候就会进入这个条件
if (remaining <= 0 || remaining > wait) {
// 如果存在定时器就清理掉否则会调用二次回调
if (timeout) {
clearTimeout(timeout)
timeout = null
}
previous = now
result = func.apply(context, args)
if (!timeout) context = args = null
} else if (!timeout && options.trailing !== false) {
// 判断是否设置了定时器和 trailing
// 没有的话就开启一个定时器
// 并且不能不能同时设置 leading 和 trailing
timeout = setTimeout(later, remaining)
}
return result
}
}