Fork me on GitHub

防抖和节流

作为一名前端开发者,我们经常会处理各种事件,比如常见的 click、scroll、 resize 等等。仔细一想,会发现像 scroll、输入框 onchange 这类事件会频繁触发,如果我们在回调中计算元素位置、做一些跟 DOM 相关的操作,引起浏览器回流和重绘,频繁触发回调,很可能会造成浏览器掉帧,甚至会使浏览器崩溃,影响用户体验。针对这种现象,目前有两种常用的解决方案:防抖和节流。

1. 防抖(debounce)

所谓防抖,就是指触发事件后,就是把触发非常频繁的事件合并成一次去执行。即在指定时间内只执行一次回调函数,如果在指定的时间内又触发了该事件,则回调函数的执行时间会基于此刻重新开始计算。

fd

下面使用 setTimeout 方法尝试实现这个功能:

1
2
3
4
5
6
7
8
9
10
const debounce = (fn, delayTime) => {
let timeId;
return () => {
const context = this, args = arguments;
timeId && clearTimeout(timeout);
timeId = setTimeout(() => {
fn.apply(context, args);
}, delayTime)
}
}

思路解析:

执行 debounce 函数之后会返回一个新的函数,通过闭包的形式,维护一个变量 timeId ,每次执行该函数的时候会结束之前的延迟操作,重新执行 setTimeout 方法,也就实现了上面所说的指定的时间内多次触发同一个事件,会合并执行一次。

温馨提示:

  1. 上述代码中 arguments 只会保存事件回调函数中的参数,譬如:事件对象等,并不会保存 fn 、delayTime
  2. 使用 apply 改变传入的 fn 方法中的 this 指向,指向绑定事件的 DOM 元素。

2. 节流(throttle)

所谓节流,是指频繁触发事件时,只会在指定的时间段内执行事件回调,即触发事件间隔大于等于指定的时间才会执行回调函数。

fd

我们使用两种方法来简单实现这种功能:时间戳和 setTimeout 定时器。

  • 时间戳
1
2
3
4
5
6
7
8
9
10
11
12
const throttle = (fn, delayTime) => {
let _start = Date.now();

return () => {
const _now = Date.now(), context = this, args = arguments;

if(_now - _start >= delayTime) {
fn.apply(context, args);
_start = Date.now();
}
}
}

通过比较两次时间戳的间隔是否大于等于我们事先指定的时间来决定是否执行事件回调。

  • 定时器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const throttle = (fn, delayTime) => {
let flag

return () => {
const context = this, args = arguments;

if(!flag) {
flag = setTimeout(() => {
fn.apply(context, args);
flag = false;
}, delayTime);
}
}
}

在上述实现过程中,我们设置了一个标志变量 flag ,当 delayTime 之后执行事件回调,便会把这个变量重置,表示一次回调已经执行结束。 对比上述两种实现,我们会发现一个有趣的现象:

1、使用时间戳方式,页面加载的时候就会开始计时,如果页面加载时间大于我们设定的 delayTime ,第一次触发事件回调的时候便会立即 fn ,并不会延迟。如果最后一次触发回调与前一次触发回调的时间差小于 delayTime ,则最后一次触发事件并不会执行 fn ;

2、使用定时器方式,我们第一次触发回调的时候才会开始计时,如果最后一次触发回调事件与前一次时间间隔小于 delayTime ,delayTime 之后仍会执行 fn 。

这两种方式有点优势互补的意思。

我们考虑把这两种方式结合起来,便会在第一次触发事件时执行 fn,最后一次与前一次间隔比较短,delayTime 之后再次执行 fn。

想法简单实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const throttle = (fn, delayTime) => {
let flag, _start = Data.now();
return () => {
const context = this,
args = arguments,
_now = Date.now(),
remainTime = delayTime - (_now - _start);
if (remainTime <= 0) {
fn.apply(this, args);
} else {
setTimeout(() => {
fn.apply(this, args);
}, remainTime);
}
}
}

通过上面的分析,可以很明显的看出函数防抖和函数节流的区别:

频繁触发事件时,函数防抖只会在最后一次触发事件只会才会执行回调内容,其他情况下会重新计算延迟事件,而函数节流便会很有规律的每隔一定时间执行一次回调函数。

3. requestAnimationFrame

之前,我们使用 setTimeout 简单实现了防抖和节流功能,如果我们不考虑兼容性,追求精度比较高的页面效果,可以考虑试试 html5 提供的 API – requestAnimationFrame 。

与 setTimeout 相比,requestAnimationFrame 的时间间隔是有系统来决定,保证屏幕刷新一次,回调函数只会执行一次,比如屏幕的刷新频率是 60HZ,即间隔 1000ms / 60 会执行一次回调。

1
2
3
4
5
6
7
8
9
10
11
12
const throttle = (fn, delayTime) => {
let flag;
return () => {
if (!flag) {
requestAnimationFrame(() => {
fn();
flag = false;
});
flag = true;
}
}
}

上述代码的基本功能就是保证在屏幕刷新的时候(对于大多数的屏幕来说,大约 16.67ms ),可以执行一次回调函数 fn。使用这种方式也存在一种比较明显的缺点,时间间隔只能跟随系统变化,我们无法修改,但是准确性会比 setTimeout 高一些。

注意:

  1. 防抖和节流只是减少了事件回调函数的执行次数,并不会减少事件的触发频率。
  2. 防抖和节流并没有从本质上解决性能问题,我们还应该注意优化我们事件回调函数的逻辑功能,避免在回调中执行比较复杂的 DOM 操作,减少浏览器 reflow 和 repaint 。

4. 开源库的实现

上面的示例代码比较简单,只是说明了基本的思路。目前已经有工具库实现了这些功能,比如 underscore 、lodash ,考虑的情况也会比较多,大家可以去查看源码,学习作者的思路,加深理解。

underscore 的 debounce 方法源码:

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
_.debounce = function(func, wait, immediate) {
var timeout, result;

var later = function(context, args) {
timeout = null;
if (args) result = func.apply(context, args);
};

var debounced = restArguments(function(args) {
if (timeout) clearTimeout(timeout);
if (immediate) {
var callNow = !timeout;
timeout = setTimeout(later, wait);
if (callNow) result = func.apply(this, args);
} else {
timeout = _.delay(later, wait, this, args);
}

return result;
});

debounced.cancel = function() {
clearTimeout(timeout);
timeout = null;
};

return debounced;
};

underscore 的 throttle 源码:

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
_.throttle = function(func, wait, options) {
var timeout, context, args, result;
var previous = 0;
if (!options) options = {};

var later = function() {
previous = options.leading === false ? 0 : _.now();
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};

var throttled = function() {
var now = _.now();
if (!previous && options.leading === false) previous = now;
var remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
return result;
};

throttled.cancel = function() {
clearTimeout(timeout);
previous = 0;
timeout = context = args = null;
};

return throttled;
};

学习链接:

-------------本文结束感谢您的阅读-------------