Google 搜 debounce
的第一条搜索结果,就介绍了 debounce
在前端中应用最广的一个场景:
在准备面试题的过程中大家应该也经常看到手写 debounce
函数的文章,所以关于 debounce
是什么就不做说明了,先写一个简单的实现:
function debounce(fn, wait) {
let timeout = null;
return function () {
// I never like this.
let context = this;
let args = arguments;
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(function () {
timeout = null;
fn.apply(context, args)
}, wait)
}
}
在我参与过的项目中,类似的实现已经能够满足需求了,如果➡️函数满天飞的话,this
可能甚至都不需要考虑。这也是我一直避免在公司项目中使用 lodash
之类的库的原因——当你可以花5分钟的时间手写一个实现的时候,为什么还要引入一个超过 50KB 的库来拖慢你的页面加载速度呢?
我的需求虽然满足了,但是大家可能还有其它需求。俗话说三人行,必有我师焉;遇到困难,先找找有没有类似的实现。
underscore.js
underscore.js
是绝对的老牌类库了,09年就发布了 0.1.0 版本,函数数量从最初的 40+,现在也增长到了 100+。但其实 debounce
实现一直没有过多的变化。
先来看 v1.2.0,这也是最初加入 debounce
函数的版本
// Internal function used to implement `_.throttle` and `_.debounce`.
var limit = function(func, wait, debounce) {
var timeout;
return function() {
var context = this, args = arguments;
var throttler = function() {
timeout = null;
func.apply(context, args);
};
if (debounce) clearTimeout(timeout);
if (debounce || !timeout) timeout = setTimeout(throttler, wait);
};
};
_.throttle = function(func, wait) {
return limit(func, wait, false);
};
_.debounce = function(func, wait) {
return limit(func, wait, true);
};
这个版本使用了一个 limit
函数,我的理解是:既然加入了 debounce
,不如一起加入 throttle
,这两个函数只有细微的区别,所以使用了同一个函数,通过不同参数控制。
而现在的版本 v1.11.0 里的 debounce
实现是这样的:
import restArguments from './restArguments.js';
import delay from './delay.js';
export default function debounce(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;
}
这个版本与最初版本有三个区别,分别是:
- 加入了
immediate
参数,控制第一次事件是否调用 - 支持
cancel
- 支持返回值
新增加的前两个特性很好理解,比如页面输入搜索,第一次要尽快返回结果,immediate
参数就很有用;而用户输入完立马离开页面,cancel
可以取消多余的调用,节省资源。倒是支持返回值这一点让我觉得很诧异,因为在常规操作里,debounce
函数几乎不需要关注返回值,但是 underscore.js
实现了这点必定是因为有人有这个需求。
lodash
lodash
现在应该是前端使用最广泛的库之一了,你可以在各种打包工具、框架、CLI 工具中搜索到它的身影。
lodash
在第一个版本 0.1.0 中就加入了 debounce
的支持,同时支持 immediate
参数,cancel
的支持在 3.0.0 版本中加入。
而在 lodash
最新版本 4.17.9 中,debounce
可以说是另一个函数也不为过,在修改了具体实现的同时,支持的特性也非常多,与此相对的是整个函数实现已经超过了 120 行。
先贴一下代码:
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) {
lastInvokeTime = time;
timerId = setTimeout(timerExpired, wait);
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;
return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
(timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait));
}
function timerExpired() {
var time = now();
if (shouldInvoke(time)) {
return trailingEdge(time);
}
timerId = setTimeout(timerExpired, remainingWait(time));
}
function trailingEdge(time) {
timerId = undefined;
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) {
timerId = setTimeout(timerExpired, wait);
return invokeFunc(lastCallTime);
}
}
if (timerId === undefined) {
timerId = setTimeout(timerExpired, wait);
}
return result;
}
debounced.cancel = cancel;
debounced.flush = flush;
return debounced;
}
看到这么一大堆的时候,我第一反应是:???。简简单单的 debounce
函数为什么变得那么陌生了?遇到这种情况不慌张,先来看注释:
/**
* Creates a debounced function that delays invoking `func` until after `wait`
* milliseconds have elapsed since the last time the debounced function was
* invoked. 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 to the next tick, similar to `setTimeout` with a timeout of `0`.
*/
从注释里可以看到,lodash
的 debounce
函数除了支持 cancel
外,还支持:
flush
立即执行在等待的函数;- 支持配置项
options
;
再来看实现,debounced
函数定义的变量列表中 lastArgs
、lastThis
、result
很好猜,肯定与函数调用和函数返回结果相关;leading
参数类似 immediate
,控制第一次是否调用函数;而 lastCallTime
、lastInvokeTime
、maxWait
、trailing
是什么?
debounce
函数除了变量定义、参数解析和 debounced
函数外,其余都是辅助函数定义,所以我们直接分析 debounced
函数的运行流程,做一个模拟,并用 chromeDevTools
的 performance
来记录一下函数执行顺序:
上图是一个延时为 1s 的点击事件,其中 1、2、3 为连续点击,之后常理来说应该只有一次函数调用,但是实际上会发现,lodash
的 debounce
中,点击结束之后有两次 scripting
过程 4 和 5,而 4 恰好在 1 处的点击 1s 之后。把 4 处调用的函数展开之后:
这里执行了一次 timerExpired
之后又通过 remainingWait
设置了一个定时器,而这个定时器只用了 800ms 就触发了,再结合 debounced
函数里的 lastCallTime = time;
和 remainingWait
里的判断,其实不难看出:
debounced
函数在第一次调用的时候就设置了定时器调用 timeExpired
,而在后续的调用中,不断更新 lastCallTime
,timeExpired
函数在被触发的时候通过 lastCallTime
判断当前时间是否满足执行条件:如果不满足条件,再通过 remainingWait
获取到下一次调用的时间,同时设置一个新的定时器继续调用 timeExpired
,循环往复,直到满足调用条件调用真正的函数。
这样 lastCallTime
就可以理解了,再来看 lastInvokeTime
、maxing
、maxWait
和 trailing
。
trailing
trailing
参数只有 trailingEdge
用到了,而 trailingEdge
不难看出,是调用实际的函数。关于这句:
if (trailing && lastArgs) {
return invokeFunc(time);
}
这里我的理解是:判断满足常规条件后是不是要调用函数。至于为什么要这一个参数我其实不太理解,因为如果这个参数为 false
的话,那么整个 debounce
函数就失去了它原本的意义。实际测试也发现,如果 trailing
设置为 false
,那么函数自始至终都不会被触发执行。
lastInvokeTime
、maxing
、maxWait
从参数定义可以发现,maxing
依赖 maxWait
,而 lastInvokeTime
在 remainingWait
和 shouldInvoke
判断中使用,而且跟 maxWait
成双成对出现,所以这三个参数很有可能是在一起提供另外一个功能。看一下 shouldInvoke
中的判断:
var timeSinceLastCall = time - lastCallTime,
timeSinceLastInvoke = time - lastInvokeTime;
return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
(timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait));
这里最终返回的判断条件中,maxing && timeSinceLastInvoke >= maxWait
与其它条件是或的关系,所以它其实可以单独决定 shouldInvoke
的结果,maxing
的定义在最上层:
maxing = false
maxing = 'maxWait' in options;
如果 maxing && timeSinceLastInvoke >= maxWait
为 true,shouldInvoke
就会返回 true
,此时函数就会进行一次调用。这么看来,maxWait
应该是表示函数一定时间内,至少应该调用一次。而这不正是 throttle
函数的调用规则么?再继续看 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
});
}
果然,lodash
正是用 leading
和 maxWait
参数实现了 throttle
。
总结
其实看下来,我觉得只有 leading
和 result
返回结果是比较关键的两个特性,lodash
和 underscore.js
很早都已经做了实现,但实际的应用中,至少在我所参与的项目中,还没有使用到这两个特性的地方,所以我自己的实现也没有考虑到。lodash
当中的 trailing
和 maxWait
,更像是为 throttle
专门做的处理,而在 underscore.js
中,throttle
有专门的实现。