本文讨论前端开发中 函数防抖 和 函数节流,它们的应用、区别以及简单实现。

在前端开发中我们可能经常需要给(页面)标签绑定一些持续触发的事件,如 resizescrollinputmousemovekeyupkeydown 等,但有些时候我们并不希望在事件持续触发的过程中那么频繁地去执行函数。

譬如,如果用户有频繁的resizescroll 行为,那么会导致页面不断的被重新渲染,如果在绑定的回调函数中存在大量的 DOM 操作,那么还会出现页面的卡顿,针对这种情况,常用的解决方式就是利用节流( throttle )防抖( debounce )来优化高频事件,降低代码的执行频率。

若想对比默认情况、函数节流和函数防抖的情况,可以参考演示效果-点击我来直观感受它们的区别。

函数节流
1
2
3
4
5
6
7
<!-- 原先: 1秒 执行 100次 -->
<!-- 调节: 1秒 执行 1次-->
<!-- 10秒钟执行1000次任务调整后10秒钟最多执行10次 -->
<!-- 换个例子 -->
<!-- 原先: 1秒中从池塘中流水100L -->
<!-- 调节: 1秒钟从池塘中流水1L -->
<!-- 10秒钟流出1000L水调整后10秒钟最多流出10L 水,这就是节流的操作。 -->

函数节流 可以通过时间戳来实现。

下面,我们试着以代码的方式来探究函数节流的细节和具体实现。
我们在页面中提供一个按钮,给按钮绑定点击事件,那么正常情况是每当按钮点击一次的时候,对应的事件处理函数就会被触发执行一次。

1
2
3
4
5
/* 页面标签: <button>按钮</button>  */
let task = (e) => console.log("click button", e);

let oBtn = document.querySelector("button");
oBtn.addEventListener("click", task);

如果用户在短时间内快速连续多次的点击按钮,那么事件处理函数也会随之触发很多次。函数节流规定在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效。换言之,函数节流控制在固定的时间单位内,事件任务只会执行(生效)一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* 事件处理函数 */
let task = (e) => console.log("click button", e);

/* 函数节流 */
function throttle(func, wait) {
let previous = 0;

return function() {
context = this;
args = arguments;

let now = Date.now();
if (now - previous > wait) {
func.apply(context, args);
previous = now;
}
}
}

/* 绑定事件 */
let oBtn = document.querySelector("button");
oBtn.addEventListener("click", throttle(task, 1000));

上面给出了函数节流的简单实现方式,代码中封装了throttle 函数,该函数接收任何(事件)函数和间隔时间两个参数,并返回一个新在函数中。throttle 函数的核心是,在返回的函数中通过获取当前时间戳并和间隔时间进行比较的方式来控制是否应该执行任务函数。

当事件处理函数第一次执行后,后续点击事件被触发的时候,如果now - previous > wait 成立(距离上次事件触发的时间已经超过了指定间隔时间),那么则执行任务函数,否则就忽略这次点击事件。注意func.apply(context, args)这行代码的作用是,把具体的标签绑定给事件处理函数中的this, 此外在事件处理函数中可能还会存在事件对象等参数的传递,需要考虑到这种情况。

关于函数节流的代码实现,我们还可以阅读和参考下知名框架 underscore 的写法,该框架对函数节流提供了更精确的控制,譬如可以通过传递参数的方式来控制 第一次点击事件是否生效,以及最后一次的点击是否要触发等,下面给出其函数节流代码的核心实现。
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
function throttle(func, wait, options) {
let timeout,args, context, previous = 0,

let throttled = function() {
context = this;
args = arguments;

let now = Date.now();

/* 该行代码设置第一次点击不生效 */
if (!previous && options.leading === false) previous = now;

let remaning = wait - (now - previous);

/* 如果:是第一次触发事件 */
/* 那么:执行事件处理函数,并更新previous值,如果有定时器,那么就进行清理操作 */
if (remaning <= 0) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
func.apply(context, args);
previous = now;

} else if (!timeout && options.trailing !== false) {
/* 如果:不是第一次触发事件 && 定时器为空 && trailing == true */
/* 那么:总是执行最后一次的事件触发的处理函数 */
timeout = setTimeout(() =>{
previous = options.leading === false ? 0 : Date.now();
func.apply(context, args);
args = context = null
}, remaning);
}
}

return throttled;
}

/* 任何处理函数 */
function task(e) {
console.log('click', e);
}

oBtn.addEventListener('click', throttle(task, 1000, {
leading: false,/* 设置为 false的时候,第一次点击不生效 */
trailing: true /* 设置最终一次点击总是触发 */
}));
函数防抖

函数防抖(debounce)就是指触发事件后在 n 秒内函数只能执行一次,如果在 n 秒内又触发了事件,则会从头重新计算函数执行时间

函数防抖 可以通过定时器来实现。

我们假设,当点击页面按钮的时候,在1秒的时间内事件处理函数只能执行一次,如果下次点击按钮的时候还没有超过1秒这个时间间隔,那么就重新开始计时。下面给出一份简单的代码实现供大家参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* <button></button> */
/* 任务执行函数 */
let task = (e) => console.log("task", e);
/* 防抖函数 */
function debounce(func, wait) {
let timer;
return function() {
clearTimeout(timer); /* 先清理以前的定时器(延迟函数) */
let context = this;
let args = arguments;
/* 开启定时器,指定时间后执行任务函数 task */
timer = setTimeout(() => {
func.apply(context, args);
timer = null;
}, wait);
}
}
/* 获取标签 */
let oBtn = document.querySelector("button");
/* 注册事件 */
oBtn.addEventListener("click", debounce(task, 1000));

稍微调整下上面的代码,假设我们想要通过一个参数来控制是否要在第一次触发事件的时候,执行任务函数,那么可以参考下面的写法:

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
/* <button></button> */
/* 任务执行函数 */
let task = (e) => console.log("task", e);

/*
防抖函数
func:具体的事件处理函数(任务函数)
wait:规定的时间(单位毫秒)
immediate:布尔型参数,开始的时候是否先执行一次
*/
function debounce(func, wait, immediate) {
let timer;
return function() {

clearTimeout(timer); /* 清理以前的定时器(延迟函数) */
let context = this;
let args = arguments;

/* 是否要在最开始的时候,先执行一次 */
if (immediate) {
let callNow = !timer;
if (callNow) func.apply(context, args);
}

/* 开启定时器,指定时间后执行任务函数 task */
timer = setTimeout(() => {
func.apply(context, args);
timer = null;
}, wait);
}
}

/* 获取标签 */
let oBtn = document.querySelector("button");
/* 注册事件 */
oBtn.addEventListener("click", debounce(task, 1000, true));

总结下,函数防抖函数节流都是防止某一事件的频繁触发,但原理却不一样:函数防抖是某一段时间内只执行一次,而函数节流是间隔时间执行

源码赏析

最后,简单贴出著名框架 lodash 中关于函数防抖和函数节流的部分演示代码,并附上 Github开源地址

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
/* debounce.js 文件实现 */
function debounce(func, wait, opts = {}) {

let maxWait;
if ('maxWait' in opts) {
maxWait = opts.maxWait;
}

let leading = true; // 第一次点击时触发
let trailing = true; // 最后一次也要触发

let lastCallTime; // 最后调用的时间 previous
let timeout;
let lastThis; // 返回函数的this
let lastArgs; // 返回函数的参数

// shouldInvoke 表示是否应该调用
let lastInvokeTime;
let shouldInvoke = function(now) {
let sinceLastTime = now - lastCallTime;
let sinceLastInvoke = now - lastInvokeTime;
// 第一次
return lastCallTime === undefined
|| sinceLastTime > wait
|| sinceLastInvoke >= maxWait;
}

// leadingEdge 是否第一次执行
let invokeFunc = function(time) {
// 最终的调用函数的时间
lastInvokeTime = time;
func.apply(lastThis, lastArgs);
}

// startTimer就是开启了一个定时器
let startTimer = function(timerExpired, wait) {
timeout = setTimeout(timerExpired, wait);
}

let remainingWait = function(now) {
return wait - (now - lastCallTime);
}

let trailingEdge = function(time) {
timeout = undefined;
if (trailing) {
invokeFunc(time);
}
}

let timerExpired = function() {
let now = Date.now(); // 当前定时器到时间了 看看是否需要执行这个函数
if (shouldInvoke(now)) { // 如果需要调用,那么就触发结束的方法
return trailingEdge(now);
}
startTimer(timerExpired, remainingWait(now));
}

let leadingEdge = function(time) {
lastInvokeTime = time;

// 如果需要执行那么就调用函数
if (leading) {
invokeFunc(time)
}
}

// 开启一个定时器 看下一次定时器到了 是否需要执行func
startTimer(timerExpired, wait);

let debounced = function(...args) {
lastThis = this;
lastArgs = args;
let now = Date.now();

// 判断当前的debounce时是否需要执行
let isInvoking = shouldInvoke(now);
lastCallTime = now;
if (isInvoking) {
if (timeout === undefined) {
leadingEdge(now);
}
}
}
return debounced;
}

/* throttle.js 文件实现 */
function throttle(func, wait) {
return debounce(func, wait, {
// maxWait最大的点击时间
maxWait: wait
});
}