防抖和节流

参考文档

防抖

防抖(debounce)的英文释义是:

n. 防反跳,按键防反跳

为什么要去抖动呢?机械按键在按下时,并非按下就接触的很好,尤其是有簧片的机械开关,会在接触的瞬间反复的开合多次,直到开关状态完全改变

我们希望开关只捕获到那次最后的精准的状态切换。在 Javascript 中,针对那些频繁触发的事件,我们想在某个时间点上去执行我们的回调,而不是每次事件触发我们就执行回调。再简单一点的说,我们希望多次触发的相同事件的触发合并为一次触发(其实还是触发了好多次,只是我们只关注最后一次)。

简单实现

// 简单的防抖动函数
function debounce(func, wait) {
    // 定时器变量
    var timer;

    return function debounced() {
        // 保存函数调用时的上下文和参数,方便之后传给 func
        var context = this;
        var args = arguments;

        // 每次 debounced 函数被调用时,先清除定时器
        clearTimeout(timer);

        // 指定 wait 时间后触发 func 执行
        timer = setTimeout(function () {
            func.apply(context, args)
        }, wait);
    };
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

不足

  1. 回调函数func无法立即执行
  2. 若以小于wait的间隔持续调用debounced函数,func函数可能永远不会执行
  3. 无法取消定时器

underscore.debounce

underscore.debounce的实现:

  • 增加了第三个参数immediate,解决了回调函数func无法立即执行的问题
  • 返回的debounced函数上增加了cancel方法,解决了无法取消定时器的问题

immediate这个参数是用来配置回调函数是在一个时间区间wait的最开始执行(immediatetrue)还是最后执行(immediatefalse)。如果immediatetrue,则意味着这是一个同步的回调,可以使用debounced函数的返回值。

// v1.13.1
import restArguments from './restArguments.js';
import now from './now.js';

// When a sequence of calls of the returned function ends, the argument
// function is triggered. The end of a sequence is defined by the `wait`
// parameter. If `immediate` is passed, the argument function will be
// triggered at the beginning of the sequence instead of at the end.
export default function debounce(func, wait, immediate) {
    var timeout, previous, args, result, context;

    var later = function() {
        var passed = now() - previous;

        // 判断此时距离最后一次调用 debounced 函数是否超过了 wait 时间
        // - 若没超过,则继续倒计时剩余的时间
        // - 否则,调用回调
        if (wait > passed) {
            timeout = setTimeout(later, wait - passed);
        } else {
            timeout = null;
            if (!immediate) result = func.apply(context, args);
            // This check is needed because `func` can recursively invoke `debounced`.
            if (!timeout) args = context = null;
        }
    };

    var debounced = restArguments(function(_args) {
        context = this;
        args = _args;
        previous = now(); // 持续调用 debounced 函数时,previous 会一直更新为最新值
        if (!timeout) {
            timeout = setTimeout(later, wait);
            if (immediate) result = func.apply(context, args);
        }
        return result;
    });

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

    return debounced;
}
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

完整源码:github - underscore.debounceopen in new window

lodash.debounce

lodash.debounceopen in new window方法提供了更加丰富和强大的功能。

  • lodash.debounce里使用leading/trailing选项代替了immediate参数,leading表示在wait时间开始时执行回调,trailing表示在wait时间结束后执行回调,可选择其中之一,也可以全部选择。
  • 增加maxWait选项,表示func可以延迟执行的最大时间,若超过了maxWait,则必须要执行回调函数func
/**
 * 说明文档:https://lodash.com/docs/4.17.15#debounce
 */
function debounce(func, wait, options) {
    var lastArgs,               // 最后一次调用 debounced 函数的参数
        lastThis,               // 最后一次调用 debounced 函数的 this
        maxWait,                // 最大等待时间,超过该时间后要执行一次
        result,                 // 最终执行结果,只有设置了 leading: true 才能同步返回 func 调用的结果
        timerId,                // 定时器
        lastCallTime,           // 最后一次调用 debounced 函数的时间,此时该次对应的 func 还没执行
        lastInvokeTime = 0,     // 最后一次调用 func 函数的时间
        leading = false,
        maxing = false,
        trailing = true;

    if (typeof func != 'function') {
        throw new TypeError(FUNC_ERROR_TEXT);
    }
    wait = toNumber(wait) || 0; // 延迟执行的时间

    if (isObject(options)) {
        leading = !!options.leading;
        maxing = 'maxWait' in options;
        maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait; // maxWait 不能小于 wait
        trailing = 'trailing' in options ? !!options.trailing : trailing;
    }

    /**
     * 调用 func 函数。执行完会清理 lastArgs、lastThis,设置 lastInvokeTime
     */
    function invokeFunc(time) {
        var args = lastArgs,
            thisArg = lastThis;

        lastArgs = lastThis = undefined;
        lastInvokeTime = time;
        result = func.apply(thisArg, args);
        return result;
    }

    /**
     * 检查 leading 调用。
     *
     * 开启了 leading,则立即执行 func 函数。
     * 注意: 无论是否设置了 leading: true,都会设置个定时器,到期后再检查 trailing 调用
     */
    function leadingEdge(time) {
        // Reset any `maxWait` timer.
        lastInvokeTime = time;
        // Start the timer for the trailing edge.
        timerId = setTimeout(timerExpired, wait);
        // Invoke the leading edge.
        return leading ? invokeFunc(time) : result;
    }

    /**
     * 获取距离下一次能调用 func 的剩余时间
     */
    function remainingWait(time) {
        var timeSinceLastCall = time - lastCallTime,
            timeSinceLastInvoke = time - lastInvokeTime,
            timeWaiting = wait - timeSinceLastCall;

        return maxing
            ? nativeMin(timeWaiting, maxWait - timeSinceLastInvoke)
            : timeWaiting;
    }

    /**
     * 判断是否应该调用 func 函数(基于 wait 和 maxWait 进行判断)
     */
    function shouldInvoke(time) {
        var timeSinceLastCall = time - lastCallTime,
            timeSinceLastInvoke = time - lastInvokeTime;

        // Either this is the first call, activity has stopped and we're at the
        // trailing edge, the system time has gone backwards and we're treating
        // it as the trailing edge, or we've hit the `maxWait` limit.
        return (
            lastCallTime === undefined                       // 满足条件一:从未调用过 debounced 函数
            || (timeSinceLastCall >= wait)                   // 满足条件二:距离上一次调用 debounced 函数的时间超过了 wait
            || (timeSinceLastCall < 0)                       // 满足条件三:TODO: 这是什么场景?
            || (maxing && timeSinceLastInvoke >= maxWait)    // 满足条件四:距离上一次调用 func 函数已超过了 maxWait 时间
        );
    }

    /**
     * 定时 wait 时间后,检查能否执行 func 函数。
     *
     * - 若能,则调用 trailingEdge 执行
     * - 否则,则在 remainingTime (经过计算出的距离下次能调用 func 函数的最短时间)后再次检查
     */
    function timerExpired() {
        var time = now();
        if (shouldInvoke(time)) {
            return trailingEdge(time);
        }
        // Restart the timer.
        timerId = setTimeout(timerExpired, remainingWait(time));
    }

    /**
     * 检查 trailing 调用。
     */
    function trailingEdge(time) {
        timerId = undefined;

        // Only invoke if we have `lastArgs` which means `func` has been
        // debounced at least once.
        if (trailing && lastArgs) { // 若 lastArgs 不存在,则说明还没等到 trailing 调用,func 就已经调用了(比如超过了 maxWait 了)
            return invokeFunc(time);
        }
        lastArgs = lastThis = undefined;
        return result;
    }

    /**
     * 取消 func 函数的执行
     */
    function cancel() {
        if (timerId !== undefined) {
            clearTimeout(timerId);
        }
        lastInvokeTime = 0;
        lastArgs = lastCallTime = lastThis = timerId = undefined;
    }

    /**
     * 若存在定时器,则立即执行 func 函数
     */
    function flush() {
        return timerId === undefined ? result : trailingEdge(now());
    }

    /**
     * 最终返回的经过防抖动处理的函数
     *
     * 每次调用 debounced 函数,可能存在如下几种情况:
     * - 立即执行 func
     *   - 首次调用 debounced 函数
     *   - 超过了 maxWait 时间限制
     * - 不满足 wait 时间限制
     *   - 存在定时器,会被忽略
     *   - 不存在定时器,设置新定时器,延迟 wait 时间
     */
    function debounced() {
        var time = now(),
            isInvoking = shouldInvoke(time);

        lastArgs = arguments; // 即使调用 debounced 函数时不传入参数,arguments 也是个长度为 0 的伪数组
        lastThis = this;
        lastCallTime = time;

        // 满足执行回调的条件
        if (isInvoking) {
            // 若不存在定时器,则先进行 leading 检查(若 leading: true,则立即执行 func;否则进行 trailing 检查)
            // BTW: 什么场景下 isInvoking 为 true 但存在定时器 timerId?
            // - 满足条件一时:是首次调用 debounced 函数,不可能存在 timerId
            // - 满足条件二时:距离上一次调用 debounced 函数超过 wait 时间的话,无论上一次调用是哪种情况,超过 wait 时间后都不会存在 timerId
            // - 满足条件四的话,说明条件二不满足,即距离上一次调用 debounced 函数小于 wait 时间,肯定存在 timerId
            if (timerId === undefined) {
                return leadingEdge(lastCallTime);
            }


            // isInvoking && 存在定时器 && 设置了最大超时 maxWait
            // 这种情况是满足条件四,说明距离上一次调用 func 函数已经超过了 maxWait,则立即执行,且重新设置定时器
            if (maxing) {
                // Handle invocations in a tight loop.
                clearTimeout(timerId);
                timerId = setTimeout(timerExpired, wait);
                return invokeFunc(lastCallTime);
            }
        }

        // 不满足执行回调的条件 && 不存在定时器,则设置个定时器延迟执行回调
        if (timerId === undefined) {
            timerId = setTimeout(timerExpired, wait);
        }

        // 若不满足执行回调的条件 && 存在定时器,则不做任务操作

        return result;
    }
    debounced.cancel = cancel;
    debounced.flush = flush;
    return debounced;
}
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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188

以上源码里涉及到一些工具函数,完整源码地址详见: lodash.debounceopen in new window

需要注意的是,调用leadingEdge函数检查leading选项时,即使leading: true并立即调用func函数,也会设置一个定时器去延迟wait时间调用timerExpired,而在timerExpired里会调用trailingEdge,在trailingEdge里会检查lastArgs的值来判断是否需要再次调用func函数:

  • 若在该次定时器wait时间内,没有再次调用debounced函数,则lastArgsundefined,不再次调用func
  • 若在该次定时器wait时间内,再次调用了debounced函数,则lastArgs会有新值,trailingEdge内会再次调用functrailingtrue的情况下)

节流

节流(throttle),只允许回调函数在wait时间内最多执行一次。

与防抖相比,节流最主要的不同在于,它保证在wait时间内至少执行一次我们希望触发的回调函数func

简单实现

function throttle(func, wait) {
    var timeout,
        startTime = new Date();

    return function throttled() {
        var context = this,
            args = arguments,
            now = new Date();

        clearTimeout(timeout);
        // 如果达到了规定的触发时间间隔,触发 handler
        if (now - startTime >= wait) {
            func.apply(context,args);
            startTime = now;
        } else {
            // 没达到触发间隔,重新设定定时器
            timeout = setTimeout(func, wait);
        }
    };
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

underscore.throttle

import now from './now.js';

// 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.
export default function throttle(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) {
            // 分支一,只有 leading: true 才有可能走到这该分支
            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;
}
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

注意,若不显示指定leading: false,则leading默认为true

underscore.throttle的代码比较精简,其中包含了较多的逻辑,以下分为四种情况来描述:

情况一:不设置options,即默认{ leading: true, trailing: true }

  • 首次调用throttled函数时:会走到分支一,立即执行在func回调
  • 再次调用throttled函数时
    • 若距离上一次调用func未超过wait时间 && 不存在定时器:会走到分支二,设置定时器,等到满足wait时间后再执行func
    • 若距离上一次调用func未超过wait时间 && 存在定时器:既不会走分支一也不会走分支二,而是会被忽略
    • 若距离上一次调用func超过wait时间:会走到分支一,立即执行在func回调

情况二:设置options{ leading: false, trailing: true }

  • 首次调用throttled函数时:会走到分支二,设置定时器,等到满足wait时间后再执行func
  • 再次调用throttled函数时
    • 若距离上一次调用func未超过wait时间 && 存在定时器:会被忽略
    • 若距离上一次调用func超过wait时间:等同于首次调用throttled函数

情况三:设置options{ leading: true, trailing: false }

  • 首次调用throttled函数时:会走到分支一,立即执行在func回调
  • 再次调用throttled函数时
    • 若距离上一次调用func未超过wait时间 && 不存在定时器:会被忽略
    • 若距离上一次调用func超过wait时间:等同于首次调用throttled函数

因此,在情况三下,只会走分支一,不会走到分支二。

情况四:设置options{ leading: false, trailing: false },则永远不会触发回调。

lodash.throttle

lodash.throttle是基于lodash.debounce的简单封装。

/**
 * 说明文档:https://lodash.com/docs/4.17.15#throttle
 */
function throttle(func, wait, options) {
    var leading = true,
        trailing = true;

    if (typeof func != 'function') {
        throw new TypeError(FUNC_ERROR_TEXT);
    }
    if (isObject(options)) {
        leading = 'leading' in options ? !!options.leading : leading;
        trailing = 'trailing' in options ? !!options.trailing : trailing;
    }
    return debounce(func, wait, {
        'leading': leading,
        'maxWait': wait,
        'trailing': trailing
    });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

lodash.throttle的完整源码可见_.throttleopen in new window

requestAnimationFrame

上面介绍的抖动与节流实现的方式都是借助了定时器setTimeout

若回调函数是关于绘制页面/做动画,或者任何关于重新计算元素位置,都可以优先考虑使用requestAnimationFrame