背景

在处理诸如 resize、scroll、mousemove 和 keydown/keyup/keypress 等事件的时候,通常我们不希望这些事件太过频繁地触发,尤其是监听程序中涉及到大量的计算或者有非常耗费资源的操作。

为了解决这个问题,一般有两种解决方案:

  1. debounce 防抖
  2. throttle 节流

throttle 和 debounce 是解决请求和响应速度不匹配问题的两个方案。二者的差异在于选择不同的策略。

电梯超时

想象每天上班大厦底下的电梯。把电梯完成一次运送,类比为一次函数的执行和响应。假设电梯有两种运行策略 throttle 和 debounce ,超时设定为15秒,不考虑容量限制。

  • throttle 策略的电梯。保证如果电梯第一个人进来后,15秒后准时运送一次,不等待。如果没有人,则待机。
  • debounce 策略的电梯。如果电梯里有人进来,等待15秒。如果又人进来,15秒等待重新计时,直到15秒超时,开始运送。

防抖(debounce)

原理

在 debounce 函数没有再被调用的情况下经过 delay 毫秒后才执行回调函数。

  • 由 debounce 的功能可知防抖函数至少接收两个参数(流行类库中都是 3 个参数)
    • 回调函数fn
    • 延时时间delay
  • debounce 函数返回一个闭包,闭包被频繁的调用
    • debounce 函数只调用一次,之后调用的都是它返回的闭包函数
    • 在闭包内部限制了回调函数fn的执行,强制只有连续操作停止后执行一次
  • 使用闭包是为了使指向定时器的变量不被gc回收
    • 实现在延时时间delay内的连续触发都不执行回调函数fn,使用的是在闭包内设置定时器setTimeOut
    • 频繁调用这个闭包,在每次调用时都要将上次调用的定时器清除
    • 被闭包保存的变量就是指向上一次设置的定时器

      实现

  • 基本实现
1
2
3
4
5
6
7
function debounce(fn, wait) {
var timer
return function () {
clearTimeout(timer)
timer = setTimeout(fn, wait)
}
}

以上实现的缺点:

  1. this指向问题。debounce 函数在定时器中调用回调函数fn,所以fn执行的时候this指向全局对象(浏览器中window),需要在外层用变量将this保存下来,使用apply进行显式绑定
  2. event对象。JavaScript 的事件处理函数中会提供事件对象event,在闭包中调用时需要将这个事件对象传入
  • 完整实现
  1. 增加第三个参数, 是否立刻执行。
  2. 增加 debounce 函数的返回值。
  3. 取消 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
33
34
35
36
37
38
39
40
41
42
/**
* 空闲控制 返回函数连续调用时,空闲时间必须大于或等于 wait,func 才会执行
*
* @param {function} func 传入函数
* @param {number} wait 表示时间窗口的间隔
* @param {boolean} immediate 设置为ture时,调用触发于开始边界而不是结束边界
* @return {function} 返回客户调用函数
*/
function debounce(fn, wait, immediate) {
var timer, result

var debounced = function() {
var context = this
var args = arguments
//清除计时器,清除上次未到延迟时间的任务
if(timer) {clearTimeout(timer)}

if(immediate) {
//是否执行完成
let callNow = !timer
//设置timer,执行完成之后清除timer,以便下次还能执行
timer = setTimeout(function() {
timer = null;
}, wait);
//如果上次已经执行完成, 则可以立即执行
if(callNow) {result = fn.apply(context, args)}
}else {
//非立即执行的,设置wait时间之后执行
timer = setTimeout(function() {
fn.apply(context, args)
}, wait)
}
return result
}

debounced.cancel = function() {
clearTimeout(timer)
timer = null
}

return debounced
}

节流(throttle)

throttle 的概念就是固定函数执行的速率,即所谓的“节流”。正常情况下,mousemove 的监听函数可能会每 20ms(假设)执行一次,如果设置 200ms 的“节流”,那么它就会每 200ms 执行一次。比如在 1s 的时间段内,正常的监听函数可能会执行 50(1000/20) 次,“节流” 200ms 后则会执行 5(1000/200) 次。

原理

  • 有两种主流实现方式
    • 使用时间戳
    • 设置定时器
  • 节流函数 throttle 调用后返回一个闭包
    • 闭包用来保存之前的时间戳或者定时器变量(因为变量被返回的函数引用,所以无法被垃圾回收机制回收)
  • 时间戳方式
    • 当触发事件的时候,取出当前的时间戳,然后减去之前的时间戳(初始设置为 0)
    • 结果大于设置的时间周期,则执行函数,然后更新时间戳为当前时间戳
    • 结果小于设置的时间周期,则不执行函数
  • 定时器方式
    • 当触发事件的时候,设置一个定时器
    • 再次触发事件的时候,如果定时器存在,就不执行,知道定时器执行,然后执行函数,清空定时器
    • 设置下个定时器
  • 将两种方式结合,可以实现兼并立刻执行和停止触发后依然执行一次的效果

    实现

  • 时间戳实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function throttle(fn, wait) {
var args;
// 前一次执行的时间戳
var previous = 0;
return function() {
// 将时间转为时间戳
var now = +new Date();
args = arguments;
// 时间间隔大于延迟时间才执行
if (now - previous > wait) {
fn.apply(this, args);
previous = now;
}
};
}
  • 定时器实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function throttle(fn, wait) {
var timer, context, args;
return function() {
context = this;
args = arguments;
// 如果定时器存在,则不执行
if (!timer) {
timer = setTimeout(function() {
// 执行后释放定时器变量
timer = null;
fn.apply(context, args);
}, wait);
}
};
}
  • 完整实现
  1. 增加第三个参数,让用户可以自己选择模式
    • 忽略开始边界上的调用,传入{ leading: false }
    • 忽略结尾边界上的调用,传入{ trailing: false }
    • 增加返回值功能
  2. 增加取消功能
  3. leading: false 和 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
50
51
52
53
/**
* 频率控制 返回函数连续调用时,func 执行频率限定为 次 / wait
*
* @param {function} func 传入函数
* @param {number} wait 表示时间窗口的间隔
* @param {object} options 如果想忽略开始边界上的调用,传入{leading: false}。
* 如果想忽略结尾边界上的调用,传入{trailing: false}
* @return {function} 返回客户调用函数
*/
function throttle(fn, wait, options) {
var timer, result, args, context
var previous = 0
if(!options) {options = {}}

// 延迟执行函数
var later = function() {
// 更新上次执行时间
// 若上边界不执行,则上次执行时间始终为0
previous = options.leading === false ? 0 : +new Date()
result = fn.apply(context, args)
// 本次执行结束后 清除上下文环境和参数
if(!timer) context = args = null
}

var throttled = function() {
var now = +new Date()
// 首次执行时,如果设定了开始边界不执行选项,将上次执行时间设定为当前时间。
if(!previous && options.leading === false) {
previous = now
}
// 延迟执行时间间隔
var remaining = wait - (now - previous)
context = this
args = arguments

// 执行函数的两种情况
// remaining小于0,说明次执行至此所间隔时间已经超过一个时间窗口
// remaining大于时间窗口wait,表示客户端系统时间被调整过
if(remaining <= 0 || remaining < wait) {
if (timer) {
clearTimeout(timer);
timer = null;
}
previous = now
result = fn.apply(context, args)
//本次执行结束后 清除上下文环境和参数
if(!timer) context = args = null
}else if(!timer && options.trailing !== false) {
timer = setTimeout(later, remaining)
}
}
return throttled
}

demo

参考资料