throttle函数与debounce函数
有时候,我们会对一些触发频率较高的事件进行监听,如果在回调里执行高性能消耗的操作,反复触发时会使得性能消耗提高,浏览器卡顿,用户使用体验差。或者我们需要对触发的事件延迟执行回调,此时可以借助throttle/debounce函数来实现需求。
throttle函数
throttle函数用于限制函数触发的频率,每个delay
时间间隔,最多只能执行函数一次。一个最常见的例子是在监听resize/scroll
事件时,为了性能考虑,需要限制回调执行的频率,此时便会使用throttle函数进行限制。
由throttle函数的定义可知,每个delay
时间间隔,最多只能执行函数一次,所以需要有一个变量来记录上一个执行函数的时刻,再结合延迟时间和当前触发函数的时刻来判断当前是否可以执行函数。在设定的时间间隔内,函数最多只能被执行一次。同时,第一次触发时立即执行函数。以下为throttle实现的简略代码:
function throttle(fn, delay) {
var timer;
return function() {
var last = timer;
var now = Date.now();
if(!last) {
timer = now;
fn.apply(this,arguments);
return;
}
if(last + delay > now) return;
timer = now;
fn.apply(this,arguments);
}
}
debounce函数
debounce函数同样可以减少函数触发的频率,但限制的方式有点不同。当函数触发时,使用一个定时器延迟执行操作。当函数被再次触发时,清除已设置的定时器,重新设置定时器。如果上一次的延迟操作还未执行,则会被清除。一个最常见的业务场景是监听onchange事件,根据用户输入进行搜索,获取远程数据。为避免多次ajax请求,使用debounce函数作为onchange的回调。
由debounce的用途可知,实现延迟回调需要用到setTimeout
设置定时器,每次重新触发时需要清除原来的定时器并重新设置,简单的代码实现如下:
function debounce(fn, delay){
var timer;
return function(){
if(timer) clearTimeout(timer)
timer = setTimeout(()=>{
timer = undefined
fn.apply(this, arguments);
}, delay||0)
}
}
小结
throttle函数与debounce函数的区别就是throttle函数在触发后会马上执行,而debounce函数会在一定延迟后才执行。从触发开始到延迟结束,只执行函数一次。上文中throttle函数实现并未使用定时器,开源类库提供的throttle方法大多使用定时器实现,而且开源通过参数配置项,区分throttle函数与debounce函数。
实现throttle和debounce的开源库
上文中实现的代码较为简单,未考虑参数类型的判断及配置、测试等。下面介绍部分实现throttle和debounce的开源的类库。
jQuery.throttle jQuery.debounce
$.throttle
指向函数jq_throttle
。jq_throttle
接收四个参数 delay, no_trailing, callback, debounce_mode
。参数二no_trailing
在throttle模式中指示。除了在文档上说明的三个参数外,第四个参数debounce_mode
用于指明是否是debounce模式,真即debounce模式,否则是throttle模式。
在jq_throttle
函数内,先声明需要使用的变量timeout_id
(定时器)和last_exec
(上一次执行操作的时间),进行了参数判断和交换,然后定义了内部函数wrapper
,作为返回的函数。
在wrapper
内,有用于更新上次执行操作的时刻并执行真正的操作的函数exec
,用于清除debounce模式中定时器的函数clear
,保存当前触发时刻和上一次执行操作时刻的时间间隔的变量elapsed
。
如果是debounce模式且timeout_id
空,执行exec
。如果定时器timeout_id
存在则清除定时器。
如果是throttle模式且elapsed
大于延迟时间delay
,执行exec
;否则,当no_trainling
非真时,更新timeout_id
,重新设置定时器,补充在上面清除的定时器:如果是debounce模式,执行timeout_id = setTimeout(clear, delay)
,如果是throttle模式,执行timeout_id = setTimeout(exec, delay - elapsed)
。
$.throttle = jq_throttle = function( delay, no_trailing, callback, debounce_mode ) {
// After wrapper has stopped being called, this timeout ensures that
// `callback` is executed at the proper times in `throttle` and `end`
// debounce modes.
var timeout_id,
// Keep track of the last time `callback` was executed.
last_exec = 0;
// `no_trailing` defaults to falsy.
if ( typeof no_trailing !== 'boolean' ) {
debounce_mode = callback;
callback = no_trailing;
no_trailing = undefined;
}
// The `wrapper` function encapsulates all of the throttling / debouncing
// functionality and when executed will limit the rate at which `callback`
// is executed.
function wrapper() {
var that = this,
elapsed = +new Date() - last_exec,
args = arguments;
// Execute `callback` and update the `last_exec` timestamp.
function exec() {
last_exec = +new Date();
callback.apply( that, args );
};
// If `debounce_mode` is true (at_begin) this is used to clear the flag
// to allow future `callback` executions.
function clear() {
timeout_id = undefined;
};
if ( debounce_mode && !timeout_id ) {
// Since `wrapper` is being called for the first time and
// `debounce_mode` is true (at_begin), execute `callback`.
exec();
}
// Clear any existing timeout.
timeout_id && clearTimeout( timeout_id );
if ( debounce_mode === undefined && elapsed > delay ) {
// In throttle mode, if `delay` time has been exceeded, execute
// `callback`.
exec();
} else if ( no_trailing !== true ) {
// In trailing throttle mode, since `delay` time has not been
// exceeded, schedule `callback` to execute `delay` ms after most
// recent execution.
//
// If `debounce_mode` is true (at_begin), schedule `clear` to execute
// after `delay` ms.
//
// If `debounce_mode` is false (at end), schedule `callback` to
// execute after `delay` ms.
timeout_id = setTimeout( debounce_mode ? clear : exec, debounce_mode === undefined ? delay - elapsed : delay );
}
};
// Set the guid of `wrapper` function to the same of original callback, so
// it can be removed in jQuery 1.4+ .unbind or .die by using the original
// callback as a reference.
if ( $.guid ) {
wrapper.guid = callback.guid = callback.guid || $.guid++;
}
// Return the wrapper function.
return wrapper;
};
debounce函数内部实际调用了throttle函数。
$.debounce = function( delay, at_begin, callback ) {
return callback === undefined
? jq_throttle( delay, at_begin, false )
: jq_throttle( delay, callback, at_begin !== false );
};
lodash的throttle与debounce
lodash中相比jQuery,提供了leading
和trailing
选项,表示在函数在等待开始时被执行和函数在等待结束时被执行。而对于debounce函数,还提供了maxWait
,当debounce函数重复触发时,有可能由于wait
过长,回调函数没机会执行,maxWait
字段确保了当函数重复触发时,每maxWait
毫秒执行函数一次。
由maxWait
的作用,我们可以联想到,提供maxWait
的debounce函数与throttle函数的作用是一样的;事实上,lodash的throttle函数就是指明maxWait
的debounce函数。
lodash重新设置计时器时,并没有调用clearTimeout
清除定时器,而是在执行回调前判断参数和执行上下文是否存在,存在时则执行回调,执行完之后将参数和上下文赋值为undefined
;重复触发时,参数和上下文为空,不执行函数。这也是与jQuery实现的不同之一
以下为debounce函数内的函数和变量:
- 局部变量
lastInvokeTime
记录上次执行时间,默认0
。 - 函数
invokeFunc
执行回调操作,并更新上一次执行时间lastInvokeTime
。 - 函数
leadingEdge
设置定时器,并根据传参配置决定是否在等待开始时执行函数。 - 函数
shouldInvoke
判断是否可以执行回调函数。 - 函数
timerExpired
判断是否可以立即执行函数,如果可以则执行,否则重新设置定时器,函数remainingWait
根据上次触发时间/执行时间和当前时间返回重新设置的定时器的时间间隔。 - 函数
trailingEdge
根据配置决定是否执行函数,并清空timerId
。 - 函数
cancel
取消定时器,并重置内部参数。函数debounced
是返回的内部函数。
debounced
内部先获取当前时间time
,判断是否能执行函数。如果可以执行,且timerId
空,表示可以马上执行函数(说明是第一次触发或已经执行过trailingEdge
),执行leadingEdge
,设置定时器。
如果timerId
非空且传参选项有maxWait
,说明是throttle函数,设置定时器延迟执行timerExpired
并立即执行invokeFunc
,此时在timerExpired
中设置的定时器的延迟执行时间是wait - timeSinceLastCall
与maxWait - timeSinceLastInvoke
的最小值,分别表示通过wait
设置的仍需等待执行函数的时间(下一次trailing的时间)和通过maxWait
设置的仍需等待执行函数的时间(下一次maxing的时间)。
function debounce(func, wait, options) {
var lastArgs,
lastThis,
maxWait,
result,
timerId,
lastCallTime,
lastInvokeTime = 0,
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;
trailing = 'trailing' in options ? !!options.trailing : trailing;
}
function invokeFunc(time) {
var args = lastArgs,
thisArg = lastThis;
lastArgs = lastThis = undefined;
lastInvokeTime = time;
result = func.apply(thisArg, args);
return result;
}
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;
}
function remainingWait(time) {
var timeSinceLastCall = time - lastCallTime,
timeSinceLastInvoke = time - lastInvokeTime,
timeWaiting = wait - timeSinceLastCall;
return maxing
? nativeMin(timeWaiting, maxWait - timeSinceLastInvoke)
: timeWaiting;
}
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 || (timeSinceLastCall >= wait) ||
(timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait));
}
function timerExpired() {
var time = now();
if (shouldInvoke(time)) {
return trailingEdge(time);
}
// Restart the timer.
timerId = setTimeout(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) {
clearTimeout(timerId);
}
lastInvokeTime = 0;
lastArgs = lastCallTime = lastThis = timerId = undefined;
}
function flush() {
return timerId === undefined ? result : trailingEdge(now());
}
function debounced() {
var time = now(),
isInvoking = shouldInvoke(time);
lastArgs = arguments;
lastThis = this;
lastCallTime = time;
if (isInvoking) {
if (timerId === undefined) {
return leadingEdge(lastCallTime);
}
if (maxing) {
// Handle invocations in a tight loop.
timerId = setTimeout(timerExpired, wait);
return invokeFunc(lastCallTime);
}
}
if (timerId === undefined) {
timerId = setTimeout(timerExpired, wait);
}
return result;
}
debounced.cancel = cancel;
debounced.flush = flush;
return debounced;
}
throttle函数则是设置了maxWait
选项且leading
为真的debounce函数。
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
});
}
参考
- Throttling and debouncing in JavaScript
- Debouncing and Throttling Explained Through Examples
- jquery-throttle-debounce源码
- _.debounce源码
- 聊聊lodash的debounce实现