传送门:
源码阅读计划——每周学习一个lodash方法(difference)
前言
这周阅读的代码是大家经常用到的防抖(debounce
)与节流函数(throttle
)。 这两个函数可以说是大家会经常用到了,lodash中的实现也是非常的完善,接下来我们就一起看看 debounce
与 throttle
这两个函数吧。
debounce 与 throttle
在正式分析源代码之前,还是容我先啰嗦几句介绍一下 debounce
与 throttle
函数之间的区别。
debounce
_.debounce(func, [wait=0], [options=])
创建一个 debounced(防抖动)函数,该函数会从上一次被调用后,延迟 wait 毫秒后调用 func 方法
throttle
_throttle(func, [wait=0], [options=])
创建一个节流函数,在 wait 秒内最多执行 func 一次的函数。
通俗易懂的讲的话,debounce函数就如同是技能前摇,如果在前摇阶段一直触发这个技能,只有最后一次操作会被执行。而throttle
是属于技能冷却,每隔一段时间可以施法一次。
常见实现方式及问题点
下面列出一种常见的 debounce
与 throttle
函数实现。
function debounce(fn,wait){
var timer = null;
return function(){
if(timer !== null){
clearTimeout(timer);
}
timer = setTimeout(fn,wait);
}
}
function throttle(fn, delay = 1000) {
let timer = null;
let result = null;
return function (...args) {
if (timer) {
return result;
}
timer = setTimeout(() => {
timer = null;
result = fn.apply(this, args);
}, delay);
return result;
}
}
但是这种实现方式有一个很明显的问题,我想让节流函数在最后没有达到执行间隔时不执行最后一次,或者是我想让节流函数在第一次执行的时候立刻执行,也就是说我无法在函数第一次执行和最后一次执行的时候让其选择执行或者不执行。想让函数在第一次执行的时候选择是否执行这个实现起来还算比较简单,我们可以添加一个 isImmediately
这样的一个标识位可以实现这样的功能,但是想让在函数最后一次执行与否的时候来判断就比较比较困难了。
那就让我们看看lodash中是如何实现这两个函数的吧。
源代码解读
throttle 源代码
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
})
}
我们可以看到 throttle
的源代码不算长,它引用了 debounce
来实现防抖的功能。所以我们接下来的重心放在解读 debounce
函数上。
debounce 源代码
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() {
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.
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;
}
debounced.cancel = cancel
debounced.flush = flush
debounced.pending = pending
return debounced
}
我们先查询一下官方文档解释。
创建一个 debounced(防抖动)函数,该函数会从上一次被调用后,延迟 wait 毫秒后调用 func 方法。 debounced(防抖动)函数提供一个 cancel 方法取消延迟的函数调用以及 flush 方法立即调用。 可以提供一个 options(选项) 对象决定如何调用 func 方法,options.leading 与|或 options.trailing 决定延迟前后如何触发(注:是 先调用后等待 还是 先等待后调用)。 func 调用时会传入最后一次提供给 debounced(防抖动)函数 的参数。 后续调用的 debounced(防抖动)函数返回是最后一次 func 调用的结果。
它不仅返回了一个防抖函数,并且这个函数上还被添加了两个方法: cancel
和 flush
。cancel
可以立刻取消延迟的函数调用。 flush
可以让延迟的函数立刻调用。
options.leading = true
表示在第一次调用函数的时候立刻执行,options.trailing = true
表示最后一次调用函数时需要触发func函数。
抽丝剥茧
我们可以看到该函数是比较长的,而且里面有很多小的函数,那么我们应该从何看起呢?我们还是先从大局看起,debounce
函数最后返回了一个debounced
(注意最后有一个d
)函数,这个函数也就是真正被频繁触发的函数,那么我们就先从debounced
函数看起。
里面值得我们注意的点有:
shouldInvoke
函数中有一个lastCallTime
与lastInvokeTime
,call 与 invoke在中文中其实都可以翻译为 “调用” 的意思,那么这两个变量到底有什么区别?shouldInvoke
函数本身的判断逻辑也值得我们去注意,shouldInvoke
函数是非常重要的一个函数。leadingEdge
与trailingEdge
的调用时机。
shouldInvoke
从字面意思上来讲是 “是否应该调用?”,它会接受一个时间time
作为参数,根据时间来判断是否应该调用我们的func
。上面也提到了,该函数中出现了两个变量 lastInvokeTime
与 lastCallTime
。我们看看这两者之间的区别。
我们可以通过搜索lastCallTime
与 lastInvokeTime
在文中出现的地方大致判断它是什么意思。 我们在两处地方发现了lastCallTime
被赋值的地方,分别在cancel
函数和debounced
函数中。 由于cancel
函数是一个独立的函数,没有被其他的函数所调用,所以我们这里只需要观察 lastCallTime
在 debounced
函数中的位置。我们可以看到lastCallTime
在函数一开头紧随着在 shouldInvoke
函数后就被赋值为当前的time
值了,这个时候lastCallTime
的意思就显而易见了,其实就是debounced
函数每次被调用时所记录的时间。
接着我们查看 lastInvokeTime
,除了lastInvokeTime
初始化的位置我们发现了3处赋值的地方,分别是 cancel
, invokeFunc
和 leadingEdge
函数中,同理我们排除cancel
函数。所以只在invokeFunc
与 leadingEdge
中出现了。
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 invokeFunc(time) {
const args = lastArgs
const thisArg = lastThis
lastArgs = lastThis = undefined
lastInvokeTime = time
result = func.apply(thisArg, args)
return result
}
我们可以发现,lastInvokeTime
都是在func函数被真正调用或者延时调用之前赋值的。
所以,让我们大概总结一下:
lastInvokeTime
与 lastCallTime
的区别:
lastCallTime
:debounced
函数最近一次执行时所记录的当前时间。lastInvokeTime
:func
函数最近一次执行时所记录的时间。
我们在看看 shouldInvoke
函数本身的逻辑,什么情况下才是“应该调用” func函数呢?
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))
}
作者在注释中写的很清楚,有两种情况,满足其中一种即返回true,也就是"应该调用"
- 第一次调用函数时
- 触发函数结束时,也就是达到了trailingEdge 边界条件
满足以下条件之一时表示达到了trailingEdge:
- 两次调用的间隔超过我们设置的延迟时间
- time比最后一次调用时间更小
- 如果设置了maxing属性,最后一次调用func的时间间隔大于maxWait
我们现在先不考虑设置了maxing属性的情况(实际上设置了maxing属性时,防抖函数就变成了节流函数了)。大致的逻辑是
所以后续的逻辑主要是在 leadingEdge
中触发
leadingEdge
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
}
leadingEdge
中的逻辑不算复杂,大致的意思是:
- 记录最后一次调用 func 函数的时间。
- 开启一个定时器,
wait
时间后执行timeExpired
函数。 - 如果需要第一次调用的时候执行则立刻执行func函数并返回值,否则直接返回result。
其中涉及到 timeExpired
函数,源代码如下
timeExpired
function timerExpired() {
const time = Date.now()
if (shouldInvoke(time)) {
return trailingEdge(time)
}
// Restart the timer.
timerId = startTimer(timerExpired, remainingWait(time))
}
基本逻辑也比较简单: 根据当前时间判断是否应该调用?
- 如果是,则返回
trailingEdge
函数的执行结果。 - 如果不是,则开启一个定时器,延时"剩余时间"后执行timeExpired函数。
trailingEdge
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
}
基本逻辑如下: 如果有trailing配置,表示最后一次调用应该执行,则返回func函数的执行结果,否则直接返回result。
可能上面分开进行解释大家无法将其串起来,下面这张调用时序图大家可以体会一下。
- 向下的箭头表示调用的时机
- 假设真实的函数触发间隔是200ms(也就是说每200ms调用一次
debounced
函数) - 我们设定的防抖间隔是1100ms。
我们用文字再一次梳理一下:
由于我们的防抖延迟是1100ms,函数每次调用的间隔是200ms,所以在第6次调用函数后 timeExpired
函数被执行了,此时进行 shouldInvoke
判断,我们可以在图中清晰的看到 timeSinceLastCall
远远不足 wait === 1000ms
的时间,所以我们需要计算还剩余多少时间,在“剩余”时间后再次执行 timeExpired
函数。
第二次执行 timeExpired
函数的流程与第一次类似,第二次的 timeSinceLastCall
为600ms,仍小于1100ms,故还需要第三次执行 timeExpired
函数。
第三次执行timeExpired
函数,达到了1100ms,此时才真正的调用 func 函数。
以上,就是一次完整的防抖函数执行流程。
throttle
throttle
函数即是在 debounce
函数的基础上传入了不同的参数,它设置了 maxWait
属性后就变成了一个throttle
函数了。我们看一下调用时序图。
上下两条时间轴分别表示设置 trailing
参数为false和true的情况,其余的参数 wait = 500
, maxWait=500
相同。我们可以看到:
在 trailing=false
时,函数的调用时机是当两次调用间隔大于maxWait时,实际在第二次调用的开始时触发的func函数。
在 trailing=true
时,函数的调用时机是在当两次调用间隔大于maxWait时,实际在第一次调用的末尾结束时触发的func函数。
(注:图中的红色三角形表示真实的func函数调用时机,可以发现trailing为false时比trailing为true时调用的时机要晚一帧)
以上就是throttle函数的调用逻辑了。
总结
让我们回顾一下throttle与debounce这两个函数。
- lodash中使用了debounce函数,使用不同的参数统一了防抖与节流的实现。
- debounce主要通过lastCallTime来判断是否应该进行调用
- throttle主要通过lastInvokeTime来判断是否应该进行调用
- leading表示调用时机在执行间隔的下一帧进行调用
- trailing表示调用时机在执行间隔的末尾进行调用
总来的来说该函数的逻辑还是比较复杂的,还是比较绕的,我们可以通过绘制调用时序图的方式帮助我们梳理清楚函数的逻辑。希望各位自行绘制时序图以加深理解。
如果你觉得该文章对你有帮助的话,点个赞再走哦!