likes
comments
collection
share

lodash源代码解析—— debounce & throttle

作者站长头像
站长
· 阅读数 0

前言

这周阅读的代码是大家经常用到的防抖(debounce)与节流函数(throttle)。 这两个函数可以说是大家会经常用到了,lodash中的实现也是非常的完善,接下来我们就一起看看 debouncethrottle 这两个函数吧。

debounce 与 throttle

在正式分析源代码之前,还是容我先啰嗦几句介绍一下 debouncethrottle 函数之间的区别。

debounce

_.debounce(func, [wait=0], [options=]) 创建一个 debounced(防抖动)函数,该函数会从上一次被调用后,延迟 wait 毫秒后调用 func 方法

throttle

_throttle(func, [wait=0], [options=]) 创建一个节流函数,在 wait 秒内最多执行 func 一次的函数。

通俗易懂的讲的话,debounce函数就如同是技能前摇,如果在前摇阶段一直触发这个技能,只有最后一次操作会被执行。而throttle是属于技能冷却,每隔一段时间可以施法一次。

常见实现方式及问题点

下面列出一种常见的 debouncethrottle函数实现。

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 调用的结果。

它不仅返回了一个防抖函数,并且这个函数上还被添加了两个方法: cancelflushcancel可以立刻取消延迟的函数调用。 flush 可以让延迟的函数立刻调用。

options.leading = true表示在第一次调用函数的时候立刻执行,options.trailing = true表示最后一次调用函数时需要触发func函数。

抽丝剥茧

我们可以看到该函数是比较长的,而且里面有很多小的函数,那么我们应该从何看起呢?我们还是先从大局看起,debounce 函数最后返回了一个debounced(注意最后有一个d)函数,这个函数也就是真正被频繁触发的函数,那么我们就先从debounced 函数看起。

里面值得我们注意的点有:

  1. shouldInvoke函数中有一个 lastCallTimelastInvokeTime,call 与 invoke在中文中其实都可以翻译为 “调用” 的意思,那么这两个变量到底有什么区别?
  2. shouldInvoke 函数本身的判断逻辑也值得我们去注意,shouldInvoke 函数是非常重要的一个函数。
  3. leadingEdgetrailingEdge的调用时机。

shouldInvoke

从字面意思上来讲是 “是否应该调用?”,它会接受一个时间time作为参数,根据时间来判断是否应该调用我们的func。上面也提到了,该函数中出现了两个变量 lastInvokeTimelastCallTime。我们看看这两者之间的区别。

我们可以通过搜索lastCallTimelastInvokeTime在文中出现的地方大致判断它是什么意思。 我们在两处地方发现了lastCallTime 被赋值的地方,分别在cancel函数和debounced 函数中。 由于cancel函数是一个独立的函数,没有被其他的函数所调用,所以我们这里只需要观察 lastCallTimedebounced 函数中的位置。我们可以看到lastCallTime 在函数一开头紧随着在 shouldInvoke 函数后就被赋值为当前的time 值了,这个时候lastCallTime的意思就显而易见了,其实就是debounced函数每次被调用时所记录的时间。

接着我们查看 lastInvokeTime,除了lastInvokeTime 初始化的位置我们发现了3处赋值的地方,分别是 cancel, invokeFuncleadingEdge 函数中,同理我们排除cancel 函数。所以只在invokeFuncleadingEdge 中出现了。


  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函数被真正调用或者延时调用之前赋值的。

所以,让我们大概总结一下:

lastInvokeTimelastCallTime 的区别:

  • 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,也就是"应该调用"

  1. 第一次调用函数时
  2. 触发函数结束时,也就是达到了trailingEdge 边界条件

满足以下条件之一时表示达到了trailingEdge:

  1. 两次调用的间隔超过我们设置的延迟时间
  2. time比最后一次调用时间更小
  3. 如果设置了maxing属性,最后一次调用func的时间间隔大于maxWait

我们现在先不考虑设置了maxing属性的情况(实际上设置了maxing属性时,防抖函数就变成了节流函数了)。大致的逻辑是

lodash源代码解析—— debounce & throttle

所以后续的逻辑主要是在 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 中的逻辑不算复杂,大致的意思是:

  1. 记录最后一次调用 func 函数的时间。
  2. 开启一个定时器,wait 时间后执行 timeExpired 函数。
  3. 如果需要第一次调用的时候执行则立刻执行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。

可能上面分开进行解释大家无法将其串起来,下面这张调用时序图大家可以体会一下。

  1. 向下的箭头表示调用的时机
  2. 假设真实的函数触发间隔是200ms(也就是说每200ms调用一次debounced函数)
  3. 我们设定的防抖间隔是1100ms。

lodash源代码解析—— debounce & throttle

我们用文字再一次梳理一下:

由于我们的防抖延迟是1100ms,函数每次调用的间隔是200ms,所以在第6次调用函数后 timeExpired 函数被执行了,此时进行 shouldInvoke 判断,我们可以在图中清晰的看到 timeSinceLastCall 远远不足 wait === 1000ms 的时间,所以我们需要计算还剩余多少时间,在“剩余”时间后再次执行 timeExpired 函数。

第二次执行 timeExpired 函数的流程与第一次类似,第二次的 timeSinceLastCall 为600ms,仍小于1100ms,故还需要第三次执行 timeExpired 函数。

第三次执行timeExpired 函数,达到了1100ms,此时才真正的调用 func 函数。

以上,就是一次完整的防抖函数执行流程。

throttle

throttle 函数即是在 debounce 函数的基础上传入了不同的参数,它设置了 maxWait 属性后就变成了一个throttle函数了。我们看一下调用时序图。 lodash源代码解析—— debounce & throttle

上下两条时间轴分别表示设置 trailing参数为false和true的情况,其余的参数 wait = 500, maxWait=500相同。我们可以看到: 在 trailing=false时,函数的调用时机是当两次调用间隔大于maxWait时,实际在第二次调用的开始时触发的func函数。 在 trailing=true 时,函数的调用时机是在当两次调用间隔大于maxWait时,实际在第一次调用的末尾结束时触发的func函数。 (注:图中的红色三角形表示真实的func函数调用时机,可以发现trailing为false时比trailing为true时调用的时机要晚一帧)

以上就是throttle函数的调用逻辑了。

总结

让我们回顾一下throttle与debounce这两个函数。

  1. lodash中使用了debounce函数,使用不同的参数统一了防抖与节流的实现。
  2. debounce主要通过lastCallTime来判断是否应该进行调用
  3. throttle主要通过lastInvokeTime来判断是否应该进行调用
  4. leading表示调用时机在执行间隔的下一帧进行调用
  5. trailing表示调用时机在执行间隔的末尾进行调用

总来的来说该函数的逻辑还是比较复杂的,还是比较绕的,我们可以通过绘制调用时序图的方式帮助我们梳理清楚函数的逻辑。希望各位自行绘制时序图以加深理解。

如果你觉得该文章对你有帮助的话,点个赞再走哦!