来了来了,防抖节流!

老生常谈,防抖节流

  • 老样子,首先剖析概念
  • 先看防抖:英语 debounce。先看 bounce,反弹、弹跳的意思。那加个 de 前缀,学过点英语就知道,表否定。所以 debounce 是让它别反弹、弹跳,综合理解,即我们常说的去抖动,防抖。
  • 那防抖跟我们编程有啥关系?在网页中,有一些事件是会触发非常频繁的,比如鼠标移动(onmousemove),键盘输入(onkeypress 如果你打字速度够快的话),还有窗口大小调整时的 onresize 等等。
  • 发现了吗,有点联系了,频繁和弹跳,我们理解为同义。想象一个弹力球扔在地上,它一定会反弹起来数次,最终停在地上。就像上述的那些事件一样,触发太频繁,但最终会稳定。这里事件的稳定我们认为就是一段时间内没有触发。
  • 所以对于不稳定的事件,我们不需要也不太容易知道它如此频繁的每一次的不同状态。我们要做的是,等它稳定了,不抖动了,不弹跳了,再去做一些处理。这样能够有效节约资源,且不会丢失关键的信息和逻辑。类比一下更通俗的例子,电梯和公交车,门开了,都是不停的上人,门就不能关,必须等没人上了,准确说是一段时间内没人上了,才能关门,所以这就是防抖。
  • 具体到代码,怎么实现呢?这就用到了鼎鼎大名的闭包。一句话即函数外用到了函数内的变量。先来最简版:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const test1 = function (e) {
console.log("test1", e, this); // 普通测试函数,打印事件e和this,就别用箭头函数了
};
let timer; // 借助了全局变量
const test2 = function (e) {
// console.log(timer); // 可以看看每次的timer是什么
if (timer) {
clearTimeout(timer); // 如果没有到1000ms,就清掉计时器
}
timer = setTimeout(() => {
// 不管有没有清理计时器,都要开始计时,即延时执行函数
console.log("test2", e, this);
}, 1000);
};
const target = document.getElementsByTagName("body")[0];
target.addEventListener("keypress", test1);
target.addEventListener("keypress", test2);
  • 可以看到效果就是,test1 一直触发,不做任何限制,即我们所说的抖动。
  • 而 test2 是人为地去掉了所有抖动的触发,即忽略掉了抖动中,也就是限定时间内的频繁执行,我们清理了定时器,则自然回调不会被执行。而如果过了限定的时间,则定时器会自动触发一次之前缓存的函数。
  • 有个问题,这里例子不通用,还用到了全局变量,那如果想复用这个操作咋办?简单,闭包!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 封装一个包裹函数,返回去掉了抖动的函数
const debounce = (func, wait) => {
let timer; // 从原来的全局变量变成了闭包,是不是闭包理解又深刻了!
// 注意这里不能用箭头函数
return function () {
// console.log(timer); // 可以看看每次的timer是什么
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
func.apply(this, arguments); //apply,call,bind的区别是什么,哈哈哈引申狂魔
}, wait);
};
};
const test1 = function (e) {
console.log("test1", e, this);
};
const test2 = function (e) {
console.log("test2", e, this);
};
const target = document.getElementsByTagName("body")[0];
target.addEventListener("keypress", test1);
target.addEventListener("keypress", debounce(test2, 1000));
  • 上述例子实现了复用防抖逻辑,还专门处理了 this,但还有个问题,如果这个事件就是一直触发,不停咋办?那我们封装的防抖就永远不能执行,如果想让它至少先来执行一次,怎么改改?
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
// 封装一个包裹函数,返回去掉了抖动的函数
const debounce = (func, wait, immediate) => {
let timer; // 从原来的全局变量变成了闭包,是不是闭包理解又深刻了!
let callNow;
// 注意这里不能用箭头函数
const debounced = function () {
// console.log(timer); // 可以看看每次的timer是什么
if (timer) {
clearTimeout(timer);
}
if (immediate) {
// 若需要立即执行,记录一个状态
callNow = !timer;
timer = setTimeout(() => {
timer = null; // 改变了timer,下次再执行时就又开始一轮新的循环,会立即执行
}, wait);
if (callNow) func.apply(this, arguments);
} else {
timer = setTimeout(() => {
func.apply(this, arguments); //apply,call,bind的区别是什么,哈哈哈引申狂魔
}, wait);
}
};
};
const test1 = function (e) {
console.log("test1", e, this);
};
const test2 = function (e) {
console.log("test2", e, this);
};
const target = document.getElementsByTagName("body")[0];
target.addEventListener("keypress", test1);
target.addEventListener("keypress", debounce(test2, 1000, true));
  • 好了,防抖差不多了。那啥是节流呢?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
// 封装一个包裹函数,返回节流后的函数
const throttle = (func, wait) => {
let timer; // 从原来的全局变量变成了闭包,是不是闭包理解又深刻了!
// 注意这里不能用箭头函数
return function () {
// console.log(timer); // 可以看看每次的timer是什么
if (timer) {
return; //改动一:如果定时器还在,就啥也不干
}
timer = setTimeout(() => {
func.apply(this, arguments); //apply,call,bind的区别是什么,哈哈哈引申狂魔
timer = null; //改动二,每次真正执行完,得把计时器变量重置一下,好在下次判断时知道上次的定时已经执行了
}, wait);
};
};
const test1 = function (e) {
console.log("test1", e, this);
};
const test2 = function (e) {
console.log("test2", e, this);
};
const target = document.getElementsByTagName("body")[0];
target.addEventListener("keypress", test1);
target.addEventListener("keypress", throttle(test2, 1000));
  • 上述例子其实利用了一点,就是当触发时间小于设定时间时,就忽略,只有大于或等于设定时间时才会真正执行,即我们设定的定时器回调一定会执行。
  • 这其实又是延后执行的思路。那立即执行呢?当然要借助时间戳了。
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
// 封装一个包裹函数,返回节流后的函数
const throttle = (func, wait, immediate) => {
let timer; // 从原来的全局变量变成了闭包,是不是闭包理解又深刻了!
let previousStamp = 0;
// 注意这里不能用箭头函数
return function () {
if (immediate) {
// console.log(previousStamp); // 可以看看每次的previousStamp是什么
const now = Date.now();
if (now - previousStamp > wait) {
func.apply(this, arguments);
previousStamp = now;
}
} else {
// console.log(timer); // 可以看看每次的timer是什么
if (timer) {
return; //改动一:如果定时器还在,就啥也不干
}
timer = setTimeout(() => {
func.apply(this, arguments); //apply,call,bind的区别是什么,哈哈哈引申狂魔
timer = null; //改动二,每次真正执行完,得把计时器变量重置一下,好在下次判断时知道上次的定时已经执行了
}, wait);
}
};
};
const test1 = function (e) {
console.log("test1", e, this);
};
const test2 = function (e) {
console.log("test2", e, this);
};
const target = document.getElementsByTagName("body")[0];
target.addEventListener("keypress", test1);
target.addEventListener("keypress", throttle(test2, 1000, true));

应用场景

  • 对于防抖,适合多次事件只需一次响应的情况。如

    • 输入框连续输入需要远程校验
    • 判断滚动条是否滑到某一位置
    • 表单提交,连点多次
  • 对于节流,适合频繁事件可以按时间做减法归约来触发。

    • 元素拖拽事件
    • canvas 画画
    • 游戏中动画刷新率

防抖节流与事件循环

  • 有文章提到了可以用 requestAnimationFrame 做节流,用 requestIdleCallback 做防抖,这都是很好的思路。浏览器的原生 api,实现了类似于防抖节流的机制,我们不用费劲自己写了。但是要想直接用于生产,肯定要考虑兼容性,还有功能是否完备等,这块可以引出事件循环后再细细讨论

防抖节流与设计模式

  • 还有文章提到了装饰器模式和观察者模式。仔细想想,也都可以实现的。装饰器实际就是对函数的装饰(封装),观察者则是用防抖或节流函数充当观察者,满足一定条件后再去执行被观察的函数,可以专门写一篇来看看如何实现。
  • 写了这么多,其实都是理解了原理后,通过一点点推论得出来的具体实现。而 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
29
30
31
32
33
34
35
36
37
38
39
40
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;
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();
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;
}
  • 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
41
42
43
44
45
46
47
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) {
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;
}
  • lodash 的 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
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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
import isObject from "./isObject.js";
import root from "./.internal/root.js";

/**
* Creates a debounced function that delays invoking `func` until after `wait`
* milliseconds have elapsed since the last time the debounced function was
* invoked, or until the next browser frame is drawn. The debounced function
* comes with a `cancel` method to cancel delayed `func` invocations and a
* `flush` method to immediately invoke them. Provide `options` to indicate
* whether `func` should be invoked on the leading and/or trailing edge of the
* `wait` timeout. The `func` is invoked with the last arguments provided to the
* debounced function. Subsequent calls to the debounced function return the
* result of the last `func` invocation.
*
* **Note:** If `leading` and `trailing` options are `true`, `func` is
* invoked on the trailing edge of the timeout only if the debounced function
* is invoked more than once during the `wait` timeout.
*
* If `wait` is `0` and `leading` is `false`, `func` invocation is deferred
* until the next tick, similar to `setTimeout` with a timeout of `0`.
*
* If `wait` is omitted in an environment with `requestAnimationFrame`, `func`
* invocation will be deferred until the next frame is drawn (typically about
* 16ms).
*
* See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
* for details over the differences between `debounce` and `throttle`.
*
* @since 0.1.0
* @category Function
* @param {Function} func The function to debounce.
* @param {number} [wait=0]
* The number of milliseconds to delay; if omitted, `requestAnimationFrame` is
* used (if available).
* @param {Object} [options={}] The options object.
* @param {boolean} [options.leading=false]
* Specify invoking on the leading edge of the timeout.
* @param {number} [options.maxWait]
* The maximum time `func` is allowed to be delayed before it's invoked.
* @param {boolean} [options.trailing=true]
* Specify invoking on the trailing edge of the timeout.
* @returns {Function} Returns the new debounced function.
* @example
*
* // Avoid costly calculations while the window size is in flux.
* jQuery(window).on('resize', debounce(calculateLayout, 150))
*
* // Invoke `sendMail` when clicked, debouncing subsequent calls.
* jQuery(element).on('click', debounce(sendMail, 300, {
* 'leading': true,
* 'trailing': false
* }))
*
* // Ensure `batchLog` is invoked once after 1 second of debounced calls.
* const debounced = debounce(batchLog, 250, { 'maxWait': 1000 })
* const source = new EventSource('/stream')
* jQuery(source).on('message', debounced)
*
* // Cancel the trailing debounced invocation.
* jQuery(window).on('popstate', debounced.cancel)
*
* // Check for pending invocations.
* const status = debounced.pending() ? "Pending..." : "Ready"
*/
function debounce(func, wait, options) {
let lastArgs, lastThis, maxWait, result, timerId, lastCallTime;

let lastInvokeTime = 0;
let leading = false;
let maxing = false;
let trailing = true;

// Bypass `requestAnimationFrame` by explicitly setting `wait=0`.
const useRAF =
!wait && wait !== 0 && typeof root.requestAnimationFrame === "function";

if (typeof func !== "function") {
throw new TypeError("Expected a function");
}
wait = +wait || 0;
if (isObject(options)) {
leading = !!options.leading;
maxing = "maxWait" in options;
maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait;
trailing = "trailing" in options ? !!options.trailing : trailing;
}

function invokeFunc(time) {
const args = lastArgs;
const thisArg = lastThis;

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

function startTimer(pendingFunc, wait) {
if (useRAF) {
root.cancelAnimationFrame(timerId);
return root.requestAnimationFrame(pendingFunc);
}
return setTimeout(pendingFunc, wait);
}

function cancelTimer(id) {
if (useRAF) {
return root.cancelAnimationFrame(id);
}
clearTimeout(id);
}

function leadingEdge(time) {
// Reset any `maxWait` timer.
lastInvokeTime = time;
// Start the timer for the trailing edge.
timerId = startTimer(timerExpired, wait);
// Invoke the leading edge.
return leading ? invokeFunc(time) : result;
}

function remainingWait(time) {
const timeSinceLastCall = time - lastCallTime;
const timeSinceLastInvoke = time - lastInvokeTime;
const timeWaiting = wait - timeSinceLastCall;

return maxing
? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
: timeWaiting;
}

function shouldInvoke(time) {
const timeSinceLastCall = time - lastCallTime;
const 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 ||
timeSinceLastCall >= wait ||
timeSinceLastCall < 0 ||
(maxing && timeSinceLastInvoke >= maxWait)
);
}

function timerExpired() {
const time = Date.now();
if (shouldInvoke(time)) {
return trailingEdge(time);
}
// Restart the timer.
timerId = startTimer(timerExpired, remainingWait(time));
}

function trailingEdge(time) {
timerId = undefined;

// Only invoke if we have `lastArgs` which means `func` has been
// debounced at least once.
if (trailing && lastArgs) {
return invokeFunc(time);
}
lastArgs = lastThis = undefined;
return result;
}

function cancel() {
if (timerId !== undefined) {
cancelTimer(timerId);
}
lastInvokeTime = 0;
lastArgs = lastCallTime = lastThis = timerId = undefined;
}

function flush() {
return timerId === undefined ? result : trailingEdge(Date.now());
}

function pending() {
return timerId !== undefined;
}

function debounced(...args) {
const time = Date.now();
const isInvoking = shouldInvoke(time);

lastArgs = args;
lastThis = this;
lastCallTime = time;

if (isInvoking) {
if (timerId === undefined) {
return leadingEdge(lastCallTime);
}
if (maxing) {
// Handle invocations in a tight loop.
timerId = startTimer(timerExpired, wait);
return invokeFunc(lastCallTime);
}
}
if (timerId === undefined) {
timerId = startTimer(timerExpired, wait);
}
return result;
}
debounced.cancel = cancel;
debounced.flush = flush;
debounced.pending = pending;
return debounced;
}

export default debounce;
  • lodash 的 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
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
import debounce from "./debounce.js";
import isObject from "./isObject.js";

/**
* Creates a throttled function that only invokes `func` at most once per
* every `wait` milliseconds (or once per browser frame). The throttled function
* comes with a `cancel` method to cancel delayed `func` invocations and a
* `flush` method to immediately invoke them. Provide `options` to indicate
* whether `func` should be invoked on the leading and/or trailing edge of the
* `wait` timeout. The `func` is invoked with the last arguments provided to the
* throttled function. Subsequent calls to the throttled function return the
* result of the last `func` invocation.
*
* **Note:** If `leading` and `trailing` options are `true`, `func` is
* invoked on the trailing edge of the timeout only if the throttled function
* is invoked more than once during the `wait` timeout.
*
* If `wait` is `0` and `leading` is `false`, `func` invocation is deferred
* until the next tick, similar to `setTimeout` with a timeout of `0`.
*
* If `wait` is omitted in an environment with `requestAnimationFrame`, `func`
* invocation will be deferred until the next frame is drawn (typically about
* 16ms).
*
* See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
* for details over the differences between `throttle` and `debounce`.
*
* @since 0.1.0
* @category Function
* @param {Function} func The function to throttle.
* @param {number} [wait=0]
* The number of milliseconds to throttle invocations to; if omitted,
* `requestAnimationFrame` is used (if available).
* @param {Object} [options={}] The options object.
* @param {boolean} [options.leading=true]
* Specify invoking on the leading edge of the timeout.
* @param {boolean} [options.trailing=true]
* Specify invoking on the trailing edge of the timeout.
* @returns {Function} Returns the new throttled function.
* @example
*
* // Avoid excessively updating the position while scrolling.
* jQuery(window).on('scroll', throttle(updatePosition, 100))
*
* // Invoke `renewToken` when the click event is fired, but not more than once every 5 minutes.
* const throttled = throttle(renewToken, 300000, { 'trailing': false })
* jQuery(element).on('click', throttled)
*
* // Cancel the trailing throttled invocation.
* jQuery(window).on('popstate', throttled.cancel)
*/
function throttle(func, wait, options) {
let leading = true;
let trailing = true;

if (typeof func !== "function") {
throw new TypeError("Expected a function");
}
if (isObject(options)) {
leading = "leading" in options ? !!options.leading : leading;
trailing = "trailing" in options ? !!options.trailing : trailing;
}
return debounce(func, wait, {
leading,
trailing,
maxWait: wait,
});
}

export default throttle;