0%

JS中的防抖与节流

在做前端可视化的某些内容轮播时,使用点击事件会用到节流(立即执行),防止动画执行时被多次点击事件打断导致某些奇怪错误。总结记录下防抖(debounce)节流(throttle)及他们的不同效果的用法。

防抖和节流定义的区别

首先分析一下防抖和节流的区别:

  • 防抖(debounce)同样也是稀释高频事件的执行频率,但是防抖是设置了一个延迟时间来执行回调函数,如果在延迟期间内再次触发事件,会重新计算延迟时间,可分为立即执行和非立即执行两种版本。
  • 节流(throttle)比较容易理解,如果想稀释高频事件的回调函数的执行频率,就设置一个周期时间,在一个周期内回调函数只能执行一次,可分为有头有尾、有头无尾、无头有尾三种。

注意 1:箭头函数没有自身的 argumentsthis,需要去上下文中寻找。所以本文中 setTimeout 的回调均使用的箭头函数,argumentsthis 在它的上层作用域中。

注意 2:debouncethrottle 函数都是返回了一个 debouncedthrottled 作为真正的回调函数。debouncedthrottled 都利用了闭包在 debouncethrottle 存放一些“通用”变量。

防抖(debounce)

先放出防抖的代码,debounce 接受三个参数,分别是回调函数 func,延迟时间 wait 和是否立即执行 immediate,又给防抖函数的 return 添加了一个取消防抖 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
/**
* @description: 防抖函数
* @param {Function} func 回调函数
* @param {Number} wait 等待时间 ms
* @param {Boolean} immediate 是否立即执行
* @return: result func返回的结果
*/
const debounce = function(func, wait, immediate = false) {
let timeoutID, result;
let debounced = function() {
window.clearTimeout(timeoutID);
if (immediate === true) {
if (!timeoutID) result = func.apply(this, arguments);
timeoutID = window.setTimeout(() => {
timeoutID = null;
}, wait);
} else {
timeoutID = window.setTimeout(() => {
func.apply(this, arguments);
}, wait);
}
return result;
};
// 取消防抖
debounced.cancel = function() {
window.clearTimeout(timeoutID);
timeoutID = null;
};
return debounced;
};

逐步分析防抖函数的逻辑,按 immediate 参数可以分为立即执行非立即执行两种:

非立即执行

当默认情况,也就是 immediatefalse 的情况下,假设执行回调函数为 debounce(foo,10000,false),用一个类似伪代码形式模拟步骤,来分析执行逻辑。

由于非立即执行时 func.apply(this, arguments)返回结果是异步的,所以只能返回 undefined

事件第一次触发

1
2
3
4
5
6
7
8
// 最开始闭包拿到的timeoutID为undefined
window.clearTimeout(undefined);
// 给timeoutID分配一个随机ID randomID1
randomID1 = window.setTimeout(() => {
foo.apply(this, arguments);
}, 10000);
// 由于setTimeout是异步的,只能返回undefined
return undefined;

事件第一次触发后 10s 内的第二次触发

1
2
3
4
5
6
7
8
9
10
11
12
// 利用第一次触发时分配的timeoutID(randomID1),清除掉了上次定义的setTimeout
// 导致第一次触发中的foo.apply(this, arguments)没有被执行
window.clearTimeout(randomID1);
// 给timeoutID分配一个随机ID randomID2,又重新定义了10s延时
randomID2 = window.setTimeout(() => {
foo.apply(this, arguments);
}, 10000);
// 由于setTimeout是异步的,只能返回undefined
return undefined;
// ...
// 第二次触发后的10s时执行了func.apply(this, arguments)
// ...

事件第二次触发后 10s 外的第三次触发

1
2
3
4
5
6
7
8
9
// 利用第二次触发时分配的timeoutID(randomID2),清除掉了上次定义的setTimeout
// 但其实不清除的话也不会影响,因为上一次的func.apply(this, arguments)已经执行完了
window.clearTimeout(randomID2);
// 给timeoutID分配一个随机ID randomID3,又重新定义了10s延时
randomID3 = window.setTimeout(() => {
foo.apply(this, arguments);
}, 10000);
// 由于setTimeout是异步的,只能返回undefined
return undefined;

立即执行

immediatefalse 的情况下,假设执行回调函数为 debounce(foo,10000,true),继续使用伪代码形式模拟步骤。

立即执行的防抖函数中 func.apply(this, arguments)是同步执行的,可以拿到 result

事件第一次触发

1
2
3
4
5
6
7
8
9
// 最开始闭包拿到的timeoutID为undefined
window.clearTimeout(undefined);
// timeoutID为undefined,条件为真,立即执行result = func.apply(this, arguments)
// 并用闭包返回了结果executedResult1
executedResult1 = foo.apply(this, arguments);
// 给timeoutID分配一个随机ID randomID1
randomID1 = window.setTimeout(() => {
randomID1 = null;
}, 10000);

事件第一次触发后 10s 内的第二次触发

1
2
3
4
5
6
7
8
9
10
11
12
// 利用第一次触发时分配的timeoutID(randomID1),清除掉了上次定义的setTimeout
// 导致第一次触发中的timeoutID = null语句没有被执行
window.clearTimeout(randomID1);
// ...
// timeoutID为randomID1,条件为假,不执行result = func.apply(this, arguments)
// ...
// 给timeoutID分配一个随机ID randomID2,又重新定义了10s延时
randomID2 = window.setTimeout(() => {
randomID2 = null;
}, 10000);
// ...
// 第二次触发后的10s时执行了timeoutID = null,让randomID2 = null

事件第二次触发后 10s 外的第三次触发

1
2
3
4
5
6
7
8
9
10
// 利用第二次触发时分配的timeoutID(randomID2),清除掉了上次定义的setTimeout
// 但其实不清除的话也不会影响,因为上一次的timeoutID = null语句已经执行完了
window.clearTimeout(randomID2);
// timeoutID为null,条件为真,立即执行result = func.apply(this, arguments)
// 并用闭包返回了结果executedResult3
executedResult3 = foo.apply(this, arguments);
// 给timeoutID分配一个随机ID randomID3,又重新定义了10s延时
randomID3 = window.setTimeout(() => {
randomID3 = null;
}, 10000);

节流(throttle)

节流的代码如下,throttle 接受三个参数,分别是回调函数 func,延迟时间 wait 和是否开头或结尾执行的 options,同样给节流函数的 return 添加了一个取消防抖 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
34
35
36
37
38
/**
* @description: 节流函数
* @param {Function} func 回调函数
* @param {Number} wait 等待时间 ms
* @param {Object} options leading代表是否开头执行,tailing代表是否结尾执行
* @return: null
*/
const throttle = (func, wait, options = { leading: true, trailing: true }) => {
let timeoutID;
let previous = 0;

let throttled = function() {
let now = new Date().getTime();
if (!previous && options.leading === false) previous = now;
let remaining = wait - (now - previous);
if (remaining <= 0 || remaining > wait) {
if (timeoutID) {
clearTimeout(timeoutID);
timeoutID = null;
}
previous = now;
func.apply(this, arguments);
} else if (!timeoutID && options.trailing !== false) {
timeoutID = window.setTimeout(() => {
previous = options.leading === false ? 0 : new Date().getTime();
timeoutID = null;
func.apply(this, arguments);
}, remaining);
}
};
// 取消节流
throttled.cancel = function() {
clearTimeout(timeoutID);
previous = 0;
timeoutID = null;
};
return throttled;
};

同样逐步分析节流函数的逻辑,按 options 参数可以分为有头有尾有头无尾无头有尾三种(无头无尾会有 bug)。

有头有尾

假设执行回调函数为 throttle(foo,10000,{ leading: true, trailing: true }),继续使用伪代码形式模拟步骤。

一步步比较简洁,不再详细解释。

事件第一次触发

1
2
3
4
5
let nowStamp1 = new Date().getTime();
let remainingStamp1 = 10000 - (nowStamp1 - 0);
// remainingStamp1<0 ,为真
previousStamp1 = nowStamp1;
fool.apply(this, arguments);

事件第一次触发后 10s 内的第二次触发

1
2
3
4
5
6
7
8
let nowStamp2 = new Date().getTime();
let remainingStamp2 = 10000 - (nowStamp2 - nowStamp1);
// timeoutID为undefined,trailing为true,继续执行
timeoutID2 = window.setTimeout(() => {
previousStamp2 = new Date().getTime();
timeoutID2 = null;
foo.apply(this, arguments);
}, 10000);

事件第一次触发后 10s 外且第二次触发 10 秒内的第三次触发

1
2
3
4
5
6
7
let nowStamp3 = new Date().getTime();
let remainingStamp3 = 10000 - (nowStamp3 - previousStamp1);
// remainingStamp3<0 ,为真,继续执行
clearTimeout(timeoutID2);
timeoutID2 = null;
previousStamp3 = nowStamp3;
foo.apply(this, arguments);

有头无尾

其实在有头有尾中已经把有头无尾解释的差不多,有头无尾利用的是时间戳来判断,不涉及到异步 setTimeout,单纯的有头无尾可以写成:

1
2
3
4
5
6
7
8
9
10
function throttle(func, wait) {
let pre = 0;
return function() {
let now = parseInt(new Date().getTime());
if (now - pre > wait) {
func.apply(this, arguments);
pre = now;
}
};
}

无头有尾

同样,单纯的无头有尾利用的是纯异步 setTimeout,也可以简写成:

1
2
3
4
5
6
7
8
9
10
11
function throttle(func, wait) {
let timeoutID;
return function() {
if (!timeoutID) {
timeoutID = window.setTimeout(() => {
timeoutID = null;
func.apply(this, arguments);
}, wait);
}
};
}

效果对比

借鉴一下Polaris_tl博客中的展示方式,清晰的展示一下几种不同防抖节流效果的对比:

效果对比

不同防抖和节流的效果对比

展示代码如下:

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>防抖和节流效果对比</title>
<style>
body {
height: 100%;
font-family: 'Microsoft YaHei';
font-size: 1.2em;
}
div {
width: 100%;
background-color: lightcyan;
overflow: hidden;
margin-top: 4px;
margin-bottom: 8px;
}
p {
float: left;
width: 30px;
height: 30px;
background-color: pink;
margin: 2px;
}
</style>
</head>
<body>
防抖(非立即执行):
<div id="d1_1"></div>
防抖(立即执行):
<div id="d1_2"></div>
节流(有头有尾):
<div id="d2_1"></div>
节流(有头无尾):
<div id="d2_2"></div>
节流(无头有尾):
<div id="d2_3"></div>
</body>
<script>
/**
* @description: 防抖函数
* @param {Function} func 回调函数
* @param {Number} wait 等待时间 ms
* @param {Boolean} immediate 是否立即执行
* @return: result func返回的结果
*/
const debounce = function(func, wait, immediate = false) {
let timeoutID, result;
let debounced = function() {
window.clearTimeout(timeoutID);
if (immediate === true) {
if (!timeoutID) result = func.apply(this, arguments);
timeoutID = window.setTimeout(() => {
timeoutID = null;
}, wait);
} else {
timeoutID = window.setTimeout(() => {
func.apply(this, arguments);
}, wait);
}
return result;
};
// 取消防抖
debounced.cancel = function() {
window.clearTimeout(timeoutID);
timeoutID = null;
};
return debounced;
};
/**
* @description: 节流函数
* @param {Function} func 回调函数
* @param {Number} wait 等待时间 ms
* @param {Object} options leading代表是否开头执行,tailing代表是否结尾执行
* @return: null
*/
const throttle = (
func,
wait,
options = { leading: true, trailing: true }
) => {
let timeoutID;
let previous = 0;

let throttled = function() {
let now = new Date().getTime();
if (!previous && options.leading === false) previous = now;
let remaining = wait - (now - previous);
if (remaining <= 0 || remaining > wait) {
if (timeoutID) {
clearTimeout(timeoutID);
timeoutID = null;
}
previous = now;
func.apply(this, arguments);
} else if (!timeoutID && options.trailing !== false) {
timeoutID = window.setTimeout(() => {
previous = options.leading === false ? 0 : new Date().getTime();
timeoutID = null;
func.apply(this, arguments);
}, remaining);
}
};
// 取消节流
throttled.cancel = function() {
clearTimeout(timeoutID);
previous = 0;
timeoutID = null;
};
return throttled;
};
//辅助函数
const addElement = function(f) {
let node = document.createElement('p');
f.appendChild(node);
};

//定义事件函数
const debounce_fn1 = function() {
addElement(d1_1);
};
const debounce_fn2 = function() {
addElement(d1_2);
};
const throttle_fn1 = function() {
addElement(d2_1);
};
const throttle_fn2 = function() {
addElement(d2_2);
};
const throttle_fn3 = function() {
addElement(d2_3);
};

//注册事件
let body = document.getElementsByTagName('body')[0];
body.addEventListener('mousemove', debounce(debounce_fn1, 500, false));
body.addEventListener('mousemove', debounce(debounce_fn2, 500, true));
body.addEventListener(
'mousemove',
throttle(throttle_fn1, 500, { leading: true, trailing: true })
);
body.addEventListener(
'mousemove',
throttle(throttle_fn2, 500, { leading: true, trailing: false })
);
body.addEventListener(
'mousemove',
throttle(throttle_fn3, 500, { leading: false, trailing: true })
);
</script>
</html>
👆 全文结束,棒槌时间到 👇