防抖debounce理解

「我正在参与掘金会员专属活动-源码共读第一期,点击参与

前言

防抖相信大家都不陌生,面试中会经常会被问题或提起。比如会问一些前端优化、手写防抖节流函数等等,这里就跟着underscore 源码来学习一下。

定义

在规定时间后才执行,如果触发则重新计时
也就是说,防抖函数在n秒内,无论触发了多少次函数回调,我都只只在n秒后执行一次。比如我们设置一个等待时间为5秒的防抖函数,如果5秒内有触发,就需要重新计时,直到5秒内没有触发就调用执行。

使用场景

最近项目中有一个表单搜索场景,在输入文字的过程中会持续触发oninput事件,而搜索接口只是在用户输入搜索文字后进行调用。如果是用户输入一个文字就搜索一次,不仅会频繁调用后台接口,前端显示效果也不好。

使用防抖的话,可以将接口调用设定在500ms内没有触发oninput事件后再调用接口,这样就可以解决问题。

还会在其他场景使用

  • 一些频繁点击操作的按钮,比如登录、短信验证,避免用户短时间多次发送
  • 调整浏览器窗口大小时,resize 次数过于频繁,造成计算过多,此时需要一次到位,就用到了防抖
  • 鼠标移动mousedown计算等场景

实现原理

实现原理其实很简单,就是利用定时器,函数在最开始执行的时候就设定一个定时器,如果在n秒内有执行就吧定时器清空,重新设定一个新的定时器,当n秒内没有再调用后,定时器计时结束后就会触发回调。

第一版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* debounce防抖
* @param { function } fn 回调
* @param { number } wait 等待时间
*/
function debounce(fn, wait = 300) {
// 利用闭包生成唯一的一个定时器
let timer = null;

// 返回一个函数,当作触发事件执行
return function (...args) {
if (timer) {
// 上一次存在定时器,需要清空
clearTimeout(timer);
}
// 设定定时器,定时器结束后执行回调函数 fn 如果多次触发就重新设定
timer = setTimeout(() => {
fn.apply(this, args);
}, wait);
};
}

我们再写一个输入框事件来测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

<input type="text" oninput="oninputHandler(event)" />

<script>
const testFn = debounce((event) => {
console.log('执行防抖', event.target.value);
}, 1000);

// 执行防抖 停止 scroll 事件后 1 秒执行回调
function oninputHandler(event) {
testFn(event);
}

// 不执行防抖
function oninputHandler(event) {
console.log('input change value: ' + event.target.value);
}
</script>

这是没有执行防抖
Kapture 2022-12-04 at 21.56.21.gif

开启防抖后
Kapture 2022-12-04 at 21.57.44.gif

效果还是很明显的,从原来的输入一个值就触发,到现在1秒内没有输入才触发,至此,简单版防抖就已经实现了。

第二版

接下来再来对防抖做一下改造,在首次调用的时候立即执行函数,等到n秒内没有触发,才可以重新触发执行。

听起来有点绕,也就是说在oninput事件第一次触发的时候就执行,后续的触发都不执行。等到1秒内没有执行后,再触发oninput时又会执行第一次。

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
/**
* debounce防抖
* @param { function } fn 回调
* @param { number } wait 等待时间
* @param { boolean } immediate 是否立即执行
*/
function debounce(fn, wait = 300, immediate = false) {
// 利用闭包生成唯一的一个定时器
let timer = null;

// 返回一个函数,当作触发事件执行
return function (...args) {
if (timer) {
// 上一次存在定时器,需要清空
clearTimeout(timer);
}

// immediate: true 时,首次触发后立即执行
if (immediate) {
// 是否首次执行过
const isExecute = !timer;

// 赋值定时器 避免重复执行
timer = setTimeout(() => {
timer = null;
}, wait);

// 首次执行
isExecute && fn.apply(this, args);
} else {
// 设定定时器,定时器结束后执行回调函数 fn 如果多次触发就重新设定
timer = setTimeout(() => {
fn.apply(this, args);
}, wait);
}
};
}

underscore 源码

来看一下underscore里是如何实现的,先将核心代码复制出来,用上面的oninput事件来调试,看一下它的一个具体步骤。

debounced方法内部打上一个断点,然后在输入框输入数据触发防抖

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
function debounce(func, wait, immediate) {
var timeout, previous, args, result, context;

var later = function () {
// now获取的是当前时间 previous 会在第一次进入的时候记录 对比两个时间差是否小于 wait 等待时间
var passed = now() - previous;

if (wait > passed) {
// 小于等待时间 说明在 wait时间内有触发 重新设定定时器
timeout = setTimeout(later, wait - passed);
} else {
// 超过等待时间 执行回调
// 清空 timeout 避免影响到下次使用
timeout = null;

// 判断是否立即执行
if (!immediate) result = func.apply(context, args);

// This check is needed because `func` can recursively invoke `debounced`.
// 清空上下文、arguments 参数 在回调里面嵌套使用
if (!timeout) args = context = null;
}
};
// 先执行这里 通过 restArguments 将处理结果当作函数进行返回 回调时传递 arguments 参数
var debounced = restArguments(function (_args) {
context = this;
args = _args;
// 触发一次记录时间 用来和等待时间对比
previous = now();
if (!timeout) {
// 第一次进入时执行
// 执行 later 函数
timeout = setTimeout(later, wait);

// 立即执行
if (immediate) result = func.apply(context, args);
}
return result;
});

// 取消执行 清空定时器等参数
debounced.cancel = function () {
clearTimeout(timeout);
timeout = args = context = null;
};

return debounced;
}

源码还是有很多亮点的

  • 增加了cancel方法,可以随时取消。
  • 在执行回调的时候,吧函数结果当作返回值return出去,是为了避免回调中有返回数据。
  • 通过记录每次执行时间差,来判断是否需要执行回调。