一、JavaScript专题之防抖和节流

在项目中,我们经常会绑定一些持续触发的事件,比如 resize、scroll、mousemove 等等,如果事件调用无限制,会加重浏览器负担,导致用户体验差,我们可以使用 debounce(防抖)和 throttle(节流)的方式来减少频繁的调用,同时也不会影响实际的效果。

防抖(debounce)

防抖,顾名思义,防止抖动,指在一段时间内,不论触发多少次,都已最后一次为准。
换句话说,在触发事件后 n 秒后才执行函数,如果在 n 秒内再次触发了事件,则会重新计算函数执行时间。
总而言之,就是不管触发多少次,都要最后一次事件后 n 秒内不再触发事件,才会执行。
那么防抖有哪些应用场景呢?

  1. 登录、发短信等按钮避免用户点击太快,以致于发送了多次请求,需要防抖;
  2. 调整浏览器窗口大小时,resize 次数过于频繁,造成计算过多,此时需要一次到位,就用到了防抖
  3. 文本编辑器实时保存,当无任何更改操作一秒后进行保存

下面我们实现了一个简单的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function debounce(f, wait) {
var timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(function () {
f(...args);
}, wait);
};
}

// 测试代码
var count = 1;
var actionFun = () => {
console.log(count++);
};
window.addEventListener("resize", debounce(actionFun, 1000));

从上面代码可以看出防抖重在清零 clearTimeout(timer)
接下来处理 this 的指向和 event 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function debounce(f, wait) {
var timer;
return (...args) => {
var context = this;
clearTimeout(timer);
timer = setTimeout(function () {
f.call(context, ...args);
}, wait);
};
}

// 测试代码
var count = 1;
var actionFun = (e) => {
console.log(count++); // 1
console.log(this); // window
console.log(e); // Event
};
window.addEventListener("resize", debounce(actionFun, 1000));

防抖函数可以分为立即执行和非立即执行版本:

  • 立即执行函数:立即执行就是触发事件后马上先执行一次,之后在设定 wait 时间内触犯的事件无效,不会执行,直到用户停止执行事件等待 wait 秒后才可以重新触发执行
  • 非立即执行函数: 多次触发事件,只会在最后一次触发事件后等待设定的 wait 时间结束时执行一次。
    下面我们增加一个参数 immediate 来判断是否立即执行:
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
/**
* @desc 函数防抖
* @param fn 函数
* @param wait 延迟执行毫秒数
* @param immediate true 表立即执行,false 表示非立即执行
*/
function debounce(f, wait, immediate = false) {
var timer;
var dobounced = (...args) => {
var context = this;
if (timer) clearTimeout(timer);
if (immediate) {
// 如果已经执行过,则不再执行
var callNow = !timer;
timer = setTimeout(function () {
timer = null;
}, wait);
if (callNow) f.call(context, ...args);
} else {
timer = setTimeout(function () {
f.call(context, ...args);
}, wait);
}
};
}

// 测试代码
var count = 1;
var actionFun = (e) => {
console.log(count++); // 1
console.log(this); // window
console.log(e); // Event
};
window.addEventListener("resize", debounce(actionFun, 1000, true));

如过希望能取消 debounce 函数,比如说我 debounce 的时间间隔是 10 秒钟,immediate 为 true,这样的话,我只有等 10 秒后才能重新触发事件,现在我希望有一个按钮,点击后,取消防抖,这样我再去触发,就可以又立刻执行啦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function debounce(f, wait, immediate = false) {
var timer;
var dobounced = (...args) => {
var context = this;
if (timer) clearTimeout(timer);
if (immediate) {
// 如果已经执行过,则不再执行
var callNow = !timer;
timer = setTimeout(function () {
timer = null;
}, wait);
if (callNow) f.call(context, ...args);
} else {
timer = setTimeout(function () {
f.call(context, ...args);
}, wait);
}
};
dobounced.cancel = () => {
clearTimout(timer);
timer = null;
};
return dobounced;
}

节流 (throttle)

节流,顾名思义,控制水的流量。控制事件发生的频率,如控制为 1s 发生一次,甚至 1 分钟发生一次。与服务端(server)及网关(gateway)控制的限流 (Rate Limit) 类似。
换句话说,连续触发事件但在 n 秒内只执行一次函数。
那么节流又有哪些应用场景呢?

  1. scroll 事件,每隔一秒计算一次位置信息等
  2. 浏览器播放事件,每个一秒计算一次进度信息等
  3. input 框实时搜索并发送请求展示下拉列表,没隔一秒发送一次请求 (也可做防抖)

关于节流的实现,有两种主流的实现方式,一种是使用时间戳,一种是设置定时器。

使用时间戳

让我们来看第一种方法:使用时间戳,当触发事件的时候,我们取出当前的时间戳,然后减去之前的时间戳(最一开始值设为 0 ),如果大于设置的时间周期,就执行函数,然后更新时间戳为当前的时间戳,如果小于,就不执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function throttle(fn, wait) {
var prev = 0;
return (...args) => {
let now = Date.now();
let context = this;
if (now - prev > wait) {
fn.apply(context, args);
prev = now;
}
};
}

// 测试代码
var count = 1;
var actionFun = (e) => {
console.log(count++); // 1
console.log(this); // window
console.log(e); // Event
};
window.addEventListener("resize", throttle(actionFun, 5000));

使用定时器

接下来,我们讲讲第二种实现方式: 使用定时器,当触发事件的时候,我们设置一个定时器,再触发事件的时候,如果定时器存在,就不执行,直到定时器执行结束,然后执行函数,清空定时器,这样就可以设置下个定时器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function throttle(fn, wait) {
var timer;
return (...args) => {
let context = this;
if (!timer) {
timer = setTimeout(() => {
timer = null;
fn.apply(context, args);
}, wait);
}
};
}

// 测试代码
var count = 1;
var actionFun = (e) => {
console.log(count++); // 1
console.log(this); // window
console.log(e); // Event
};
window.addEventListener("resize", throttle(actionFun, 5000));

所以比较两个方法:

  • 第一种事件会立刻执行,第二种事件会在 n 秒后第一次执行
  • 第一种事件停止触发后没有办法再执行事件,第二种事件停止触发后依然会再执行一次事件

节流优化

现在我们将上面两种方法合并,既可以立刻执行,又可以停止触发的时候还能再执行一次。

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
function throttle(fn, wait) {
var timer, context, args, result;
var prev = 0;

var later = function () {
prev = Date.now();
timer = null;
fn.apply(context, args);
};
var throttled = function () {
let now = Date.now();

// 下次触发剩余时间
var remaining = wait - (now - prev);

context = this;
args = arguments;
// 如果没有剩余的时间或者修改了系统时间
if (remaining <= 0 || remaining > wait) {
if (timer) {
clearTimeout(timer);
timer = null;
}
prev = now;
fn.apply(context, args);
if (!timer) context = args = null;
} else if (!timer) {
timer = setTimeout(later, remaining);
}
};
return throttled;
}

// 测试代码
var count = 1;
var actionFun = (e) => {
console.log(count++); // 1
console.log(this); // window
console.log(e); // Event
};
window.addEventListener("resize", throttle(actionFun, 5000));

现在,我需要能够自定义是否立刻执行、和是否可以停止触发的时候还能再执行一次,现在增加第三个参数 options,然后根据传值判断如何执行:

  • leading: fasle,表示禁用立刻执行;
  • trailing: false,表示禁用停止触发的回调
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
function throttle(fn, wait, options) {
var timer, context, args, result;
var prev = 0;
if (!options) options = {};

var later = function () {
prev = options.leading === false ? 0 : Date.now();
timer = null;
fn.apply(context, args);
};
var throttled = function () {
let now = Date.now();

if (!prev && options.leading === false) prev = now;

// 下次触发剩余时间
var remaining = wait - (now - prev);

context = this;
args = arguments;
// 如果没有剩余的时间或者修改了系统时间
if (remaining <= 0 || remaining > wait) {
if (timer) {
clearTimeout(timer);
timer = null;
}
prev = now;
fn.apply(context, args);
if (!timer) context = args = null;
} else if (!timer && options.trailing !== false) {
timer = setTimeout(later, remaining);
}
};
return throttled;
}

// 测试代码
var count = 1;
var actionFun = (e) => {
console.log(count++); // 1
console.log(this); // window
console.log(e); // Event
};
window.addEventListener("resize", throttle(actionFun, 5000, {
leading: false
}));
window.addEventListener("resize", throttle(actionFun, 5000, {
trailing: false
}));

注意,这里leading和trailing不能同时设为false。
同样取消也是增加一个cancel函数:

1
2
3
4
5
throttled.cancel = function() {
clearTimeout(timer);
prev = 0;
timer = null;
}

参考文献

https://github.com/mqyqingfeng/Blog/issues/26