上一篇文章讲了去抖函数,然后这一篇讲同样为了优化性能,降低事件处理频率的节流函数。

一、什么是节流?

节流函数(throttle)就是让事件处理函数(handler)在大于等于执行周期时才能执行,周期之内不执行,即事件一直被触发,那么事件将会按每小段固定时间一次的频率执行。

打个比方:王者荣耀、英雄联盟、植物大战僵尸游戏中,技能的冷却时间,技能的冷却过程中,是无法使用技能的,只能等冷却时间到之后才能执行。

那什么样的场景能用到节流函数呢?
比如:

  1. 页面滚动和改变大小时需要进行业务处理,比如判断是否滑到底部,然后进行懒加载数据。
  2. 按钮被高频率地点击时,比如游戏和抢购网站。

我们通过一个简单的示意来理解:

节流函数可以用时间戳和定时器两种方式进行处理。

二、时间戳方式实现

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
<div class="container" id="container">正在滑动:0</div>

<script>
window.onload = function() {
var bodyEl = document.getElementsByTagName("body")[0];
};

var count = 0;
window.onmousemove = throttle(eventHandler, 1000);

function eventHandler(e) {
var containerEl = document.getElementById("container");
containerEl.innerHTML = "正在滑动: " + count;
count++;
}

function throttle(func, delay) {
var delay = delay || 1000;
var previousDate = new Date();
var previous = previousDate.getTime(); // 初始化一个时间,也作为高频率事件判断事件间隔的变量,通过闭包进行保存。

return function(args) {
var context = this;
var nowDate = new Date();
var now = nowDate.getTime();
if (now - previous >= delay) {
// 如果本次触发和上次触发的时间间隔超过设定的时间
func.call(context, args); // 就执行事件处理函数 (eventHandler)
previous = now; // 然后将本次的触发时间,作为下次触发事件的参考时间。
}
};
}
</script>

看时间戳实现版本的效果:

三、定时器方式实现

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
<div class="container" id="container">正在滑动: 0</div>

<script>
window.onload = function() {
var bodyEl = document.getElementsByTagName("body")[0];
};

var count = 0;
window.onmousemove = throttle(eventHandler, 1000);

function eventHandler(e) {
var containerEl = document.getElementById("container");
containerEl.innerHTML = "正在滑动: " + count;
count++;
}

function throttle(func, delay) {
var delay = delay || 1000;
var timer = null;
return function(args) {
var context = this;
var nowDate = new Date();
var now = nowDate.getTime();
if (!timer) {
timer = setTimeout(function() {
func.call(context, args);
timer = null;
}, delay);
}
};
}
</script>

看看定时器版实现版本的效果:

三、时间戳和定时器的对比分析

对比时间戳和定时器两种方式,效果上的区别主要在于:

事件戳方式会立即执行,定时器会在事件触发后延迟执行,而且事件停止触发后还会再延迟执行一次。

具体选择哪种方式取决于使用场景。underscore 把这两类场景用 leading 和 trailing 进行了表示。

四、underscore 源码实现

underscore 的源码中就同时实现了时间戳和定时器实现方式,在调用时可以自由选择要不要在间隔时间开始时(leading)执行,或是间隔时间结束后(trailing)执行。

具体看伪代码和示意图:

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
<div class="container" id="container">正在滑动: 0</div>
<div class="height"></div>
<script>
window.onload = function() {
var bodyEl = document.getElementsByTagName("body")[0];
};

var count = 0;

// 事件处理函数
function eventHandler(e) {
var containerEl = document.getElementById("container");
containerEl.innerHTML = "正在滑动: " + count;
count++;
}

var _throttle = function(func, wait, options) {
var context, args, result;

// 定时器变量默认为 null, 是为了如果想要触发了一次后再延迟执行一次。
var timeout = null;

// 上一次触发事件回调的时间戳。 默认为 0 是为了方便第一次触发默认立即执行
var previous = 0;

// 如果没有传入 options 参数
// 则将 options 参数置为空对象
if (!options) options = {};

var later = function() {
// 如果 options.leading === false
// 则每次触发回调后将 previous 置为 0, 表示下次事件触发会立即执行事件处理函数
// 否则置为当前时间戳
previous = options.leading === false ? 0 : +new Date();

// 剩余时间跑完,执行事件,并把定时器变量置为空,如果不为空,那么剩余时间内是不会执行事件处理函数的,见 else if 那。
timeout = null;

result = func.apply(context, args);

// 剩余时间结束,并执行完事件后,清理闭包中自由变量的内存垃圾,因为不再需要了。
if (!timeout) context = args = null;
};

// 返回的事件回调函数
return function() {
// 记录当前时间戳
var now = +new Date();

// 第一次执行回调(此时 previous 为 0,之后 previous 值为上一次时间戳)
// 并且如果程序设定第一个回调不是立即执行的(options.leading === false)
// 则将 previous 值(表示上次执行的时间戳)设为 now 的时间戳(第一次触发时)
// 表示刚执行过,这次就不用执行了
if (!previous && options.leading === false) previous = now;

// 间隔时间 和 上一次到本次事件触发回调的持续时间的时间差
var remaining = wait - (now - previous);

context = this;
args = arguments;

// 如果间隔时间还没跑完,则不会执行任何事件处理函数。
// 如果超过间隔时间,就可以触发方法(remaining <= 0)

// remaining > wait,表示客户端系统时间被调整过
// 也会立即执行 func 函数

if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
// 解除引用,防止内存泄露
timeout = null;
}

// 重置前一次触发的时间戳
previous = now;

// result 为事件处理函数(handler)的返回值
// 采用 apply 传递类数组对象 arguments
result = func.apply(context, args);

// 引用置为空,防止内存泄露
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
// 如果 remaining > 0, 表示在间隔时间内,又触发了一次事件

// 如果 trailing 为真,则会在间隔时间结束时执行一次事件处理函数(handler)
// 在从触发到剩余时间跑完,会利用一个定时器执行事件处理函数,并在定时器结束时把 定时器变量置为空

// 如果剩余事件内已经存在一个定时器,则不会进入本 else if 分支, 表示剩余时间已经有一个定时器在运行,该定时器会在剩余时间跑完后执行。
// 如果 trailing = false,即不需要在剩余时间跑完执行事件处理函数。
// 间隔 remaining milliseconds 后触发 later 方法
timeout = setTimeout(later, remaining);
}

// 回调返回值
return result;
};
};

window.onmousemove = _throttle(eventHandler, 1000);
</script>

下面是我画的示意图:

大致总结一下代码对事件处理逻辑的影响:

  1. 如果 leading 为真,那么绿色意味着间隔时间的开始会立即执行,第一次触发也会立即执行。
  2. 如果 trailing 为真,那么从蓝紫色的竖细线后的剩余事件,会跑一个定时器,定时器在时间间隔结束时再执行一次事件处理函数。
  3. 如果 leading 不为真,那么第一次事件触发不会立即执行。
  4. 如果 trailing 不为真,最后一次事件触发后,不然再执行一次事件处理函数。

节流和去抖的常见场景

  1. 输入框打字输入完后才开始异步请求数据校验内容:去抖
  2. 下拉滚动条判断是否到底部,进行懒加载数据:去抖和节流都可以,判断是否到底的方式不同
  3. 活动网站、游戏网站,按钮被疯狂点击:节流

五、总结

去抖和节流函数都是为了降低高频率事件触发的事件处理频率,从而优化网页中大量重绘重排带来的性能问题。

其区别在于去抖会在高频率事件触发时,只执行一次,节流会在满足间隔时间后执行一次。去抖的 immediate,节流中的 leading, trailing 都是为了尽可能满足这类工具函数的不同使用场景。