优化代码之throttle & debounce

这几天翻了下以前写的代码,发现自己以前虽然有用 debounce 或者 throttle 的意识,但是确没有把代码封装的很好。比如没有用到闭包去封装 timer, 而是把 timer 放在了 vue data 的变量里。因此就出现了这篇文章,总结下 debounce 和 throttle 运用场景以及对应的自己的实现。并且重构了以前的代码。


运用场景

比如我曾经在一个项目里,就两种场景都用到了。

第一种比如当用户在搜索框里输入了数据的时候,我会去向后台请求,搜索出来的对应结果。 但是用户的输入可能是不断的进行的,如果我每次都去请求,那么其实是无效并且浪费的。所以我们可以在用户输入最后一个字的时候,再去发请求。 用到的就是 debounce 的原理,每次的请求都延迟一段时间去发出,当有新的请求来的时候,清空上次的请求,然后重新执行延迟一段时间去请求,直到用户没有再请求的时候,执行的就是最后一次延迟请求

这种只去执行最后一次的就是我们说的 debounce。可以把它理解为独占型的函数

第二种比如当用户滚动的时候,当滑到了这个字母开头的时候,提示给用户,你已经到了 H 开头的列表内容了。正常的情况下,我们的做法是一直监听 scroll 事件,然后计算当前的 li 是不是到了对应 H 字母开头了,到了则设置相应的提示。

但是要知道 scroll 事件,每次都会触发很多次,如果每次进行相应的计算,就会很卡顿,尤其在一些老的机器上。 那么这个时候我们就可以用 throttle 了,每一段事件,比如 500 ms 去执行一次计算,而不是每次 scroll 都执行。

可以把 throttle 理解为节制型的函数。

上面两个是我遇到过的两个场景,具体其他场景还可以参考:

1
2
3
4
5
6
7
8
9
10
11
// 当 onresize 的时候,我们只想知道最后一次的大小
window.onresize = debounce(caculate, 500);

// 当用户一段时间频繁点击时,只以最后一次为标准
button.onclick = debounce(sendMail, 300);

// onscroll 时定位元素
window.onscroll = throttle(caculatePosition, 100);

// 鼠标移动,mousemove 事件
window.onmousemove = throttle(getElement, 100)

实现代码

在 underscore lodash 里,都有相应的实现了。但是我感觉不太好理解额,所以按照自己的理解,写出了适应自己的 debounce 和 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
/**
* debounce
* @param fn
* @param wait
* @param leading
* @returns {Function}
*/
function debounce(fn, wait, { leading = false }) {
let context,
args = arguments,
timer = null,
firstInvoke = true

function excute() {
fn.apply(context, args)
}

return function () {
context = this

if (firstInvoke && leading) {
excute()
firstInvoke = false
}

if (timer) {
clearTimeout(timer)
timer = null
}

timer = setTimeout(excute, wait)
}
}

leading 参数用于控制第一次是否需要在最开始就触发一次。主要的思想就是只要重新有调用,就把原来的那个被延迟执行了的方法取消。 取消的方法是设置 timer 为 null。firstInvoke 用于标识方法知否被执行了一次了。

下面是 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
41
42
/**
* throttle
* @param fn
* @param wait
* @param leading
* @param trailing
* @returns {Function}
*/
function throttle(fn, wait, { leading = true, trailing = true }) {
let context, lastExec = 0, timer, args, firstInvoke = true

function excute() {
fn.apply(context, args)

lastExec = +new Date()
}

return function (...arg) {
let now = +new Date()

args = arg

context = this
clearTimeout(timer)

if (firstInvoke && leading) {
excute()
firstInvoke = false
}

if (!lastExec) {
lastExec = now
}

if (!!wait && (now - lastExec) >= wait) {
excute()
} else if (trailing) {
timer = setTimeout(excute, wait)
}

}
}

throttle 因为是按频率触发,所以每次的时间间隔是相同的。所以当没到该执行的时间点的时候,就把剩余的时间重新设置给 setTimeout 。wait 即为多少秒执行一次的时间。看了 underscroll 里的源码,它还设置了 trailing 这些参数。trailing 表示当最后一次没到执行时间时,你不想要延迟执行这个函数了。也就是最后一次调用将被忽略。

下面贴一下 underscroll 的实现。

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
// Returns a function, that, when invoked, will only be triggered at most once
// during a given window of time. Normally, the throttled function will run
// as much as it can, without ever going more than once per `wait` duration;
// but if you'd like to disable the execution on the leading edge, pass
// `{leading: false}`. To disable execution on the trailing edge, ditto.
_.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;
};

underscore 把 throttle 和 debounce 分开来实现了,但是有些别的封装的库则是用一个函数实现。下面是 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
29
30
31
32
// Returns a function, that, as long as it continues to be invoked, will not
// be triggered. The function will be called after it stops being called for
// N milliseconds. If `immediate` is passed, trigger the function on the
// leading edge, instead of the trailing.
_.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 已经有这么成熟的东西,实际上,我觉得可以不用重复的去做这个工作。用别人成熟的内容就好了。自己了解了原理,自己可以实现,有问题知道怎么去查就好。