lodash.debounce 解析

2,101 阅读8分钟

一 防抖、节流定义和作用

  1. 防抖debounce简单地说,就是 当一个动作连续触发,只执行最后一次
  1. 节流throttle简单地说,就是限制一个动作在一段时间内只能执行一次

作用:防抖和节流作为页面性能优化的一种策略,可以降低回调函数的执行频率,节省计算资源,能有效减少浏览器引擎的损耗,防止出现页面堵塞卡顿现象。

二 解析源码

参数了解

lodash的防抖函数lodash.debounce(func, [wait=0], [options=])一共三个入参,func要防抖的函数,wait需要延迟的毫秒数。
option对象一共三个属性,

  1. options.leading(boolean)指定在延迟开始前调用
  1. options.maxWait(number)设置func允许被延迟的最大值
  1. options.trailing(boolean)指定在延迟结束后调用

简易实现

首先,我们自己写一个简易防抖函数

export function debounce(func, wait) {
    let timerId
    return function debounced() {
        clearTimeout(timerId)
        timerId = setTimeout(func, wait)
    }
}

这样就实现了最简单的 debounce 函数了。

但是用户在使用的时候,可能会不按照要求传入参数,为了更加严谨和友好,再对参数进行一些处理。

我们希望传入的 func 为一个函数,如果不是函数则报错;希望 wait 是一个数字,如果不是数字,则转换成数字类型。

代码修改如下:

function debounce(func, wait) {
    let timerId

    if (typeof func !== 'function') {
        throw new TypeError('Expected a function')
    }

    wait = +wait || 0
    return function debounced() {
        clearTimeout(timerId)
        timerId = setTimeout(func, wait)
    }
}

wait 转换成数字后,也有可能是 NaN 的情况,这里默认为 0

使用 requestAnimationFrame 优化

如果 wait 没有传递,按照之前的实现,是使用 setTimeout ,时间设置为 0 来实现的。

现代浏览器有一个 requestAnimationFrameapi ,会在浏览器重绘之前调用,绘制动画的时候性能比 setTimeout 更好。

因此增加一个标志符是否需要使用 requestAnimationFrame

const useRAF = (!wait && wait !== 0 && typeof window.requestAnimationFrame === 'function')

只有在 wait 为假值, 并且 wait 也没有指定为 0 ,并且当前环境支持 requestAnimationFrame 的情况下,才使用 requestAnimationFrame

代码更改如下:

function debounce(func, wait) {
    let timerId
    const useRAF = (!wait && wait !== 0 && typeof window.requestAnimationFrame === 'function')

    if (typeof func !== 'function') {
        throw new TypeError('Expected a function')
    }

    wait = +wait || 0
    return function debounced() {
        if (useRAF) {
            window.cancelAnimationFrame(timerId)
        } else {
            clearTimeout(timerId)
        }

        if (useRAF) {
            timerId = window.requestAnimationFrame(func)
        } else {
            timerId = setTimeout(func, wait)
        }
    }
}

将开启定时器和清除定时器抽象成两个函数

function debounce(func, wait) {
    let timerId
    const useRAF = (!wait && wait !== 0 && typeof window.requestAnimationFrame === 'function')

    if (typeof func !== 'function') {
        throw new TypeError('Expected a function')
    }

    wait = +wait || 0

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

    function clearTimer(id) {
        if (useRAF) {
            window.cancelAnimationFrame(id)
        }
        clearTimeout(id)
    }

    return function debounced() {
        clearTimer(timerId)
        timerId = startTimer(func, wait)
    }
}

参数、this 及返回值

目前的实现,在最后调用 func 的时候是没有传入参数的,而且函数调用的时候的 this 也没有绑定,可能跟预期的指向不一致,也没有返回值。所以我们记录一下this和入参,以及运行结果result

因为最后返回调用的函数是 debounced,因此可以将 debounced 修改如下:

function debounced(...args) {
        let lastThis = this
        let result
        clearTimer(timerId)

        startTimer(function () {
            result = func.apply(lastThis, args)
        }, wait)

        startTimer(func, wait)

        return result
    }

为了后续其他功能的实现 我们把函数调用,this记录这些 提取一下,完整函数变成如下

function debounce(func, wait) {
    let lastArgs,
        lastThis,
        result,
        timerId
    const useRAF = (!wait && wait !== 0 && typeof window.requestAnimationFrame === 'function')

    if (typeof func !== 'function') {
        throw new TypeError('Expected a function')
    }

    wait = +wait || 0

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

    function clearTimer(id) {
        if (useRAF) {
            window.cancelAnimationFrame(id)
        }
        clearTimeout(id)
    }
    
   function invokeFunc () {
      const args = lastArgs
      const thisArg = lastThis

      lastArgs = lastThis = undefined
      result = func.apply(thisArg, args)
      return result
   }
    
    return function debounced(...args) {
        lastArgs=args
        lastThis = this
        let result
        clearTimer(timerId)
        startTimer(invokeFunc, wait)
    }
}

最大等待时间

现在 debounced 的设计是,如果事件触发的间隔总不超过 wait ,则会一直不会调用到 func ,但是有些场景下,我们希望可以设置一个最大的等待时间 maxWait ,如果上一次调用 func 的时间间隔超过 maxWait ,则无论事件的触发间隔是否超过 wait ,都会调用 func

增加 maxWait 后,我们需要对 maxWait 进行取值:

let maxWait
let maxing = false

if (Object.prototype.toString.call(options) === '[object Object]') {
  maxing = 'maxWait' in options
  maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait
}

maxing 来表示有没有传入 maxWait 参数,也即表示要不要开启最大等待时间这个功能。

用户还可能传入 maxWait 的值比 wait 还要小,因此要取 maxWaitwait 之间的较大值来作为最大等待时间。

let lastCallTime
let lastInvokeTime = 0

因为涉及到了两个时间的竞争,这里用 lastCallTime 来记录最后一次 debounced 调用时的时间,用 lastInvokeTime 来记录最后一次 func 被调用时的时间。

lastInvokeTimeinvokeFunc 里保存:

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

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

lastCallTimedebounced 调用时保存:

function debounced (...args) {
  lastArgs = args
  lastThis = this
  lastCallTime = Date.now()
  
  clearTimer(timerId)
  startTimer(invokeFunc, wait)
}

因为涉及到了 waitmaxWait 的竞争,这时,我们就不能每次调用 debounced 的时候都直接 clearTimer 了和 startTimer 了,因为这只是以 wait 为维度的。

因此我们需要计算一个状态来是否执行函数 。

以下为源码:

function shouldInvoke(time) {
  const timeSinceLastCall = time - lastCallTime
  const timeSinceLastInvoke = time - lastInvokeTime

  return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
          (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait))
}

逐个条件分析下:

lastCallTime === undefined ,即第一次调用的时候

timeSinceLastCall >= wait,即事件触发的间隔超过 wait 时,这是 debounce 最开始就要支持的功能。

timeSiceLastCall < 0 ,这个不太好理解,time 什么时候会比 lastCallTime 还要小的情况,其实这种应该算是边缘情况,例如在修改系统时间的情况下。

maxing && timeSinceLastInvoke >= maxWait, 最后这个条件是处理最大等待时候的情况,如果 timeSinceLastInvoke 比最大的等待时间 maxWait 还要大,也要重新开启 timer

有了判断条件,还要计算 timer 的时间间隔,之前直接使用 wait ,现在因为有了 maxWait ,因此时间间隔也要使用这两者进行计算了。

源码如下:

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

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

使用 wait - timeSinceLastCall 可以计算出没有 maxWait 的时候的等待时间。

如果要支持最大的等待时候,可以使用 maxWait - timeSinceLastInvoke 得出最大的等待时间所剩余的时间。

然后取这两者中较小者就可以得出 timer 所要求的时间间隔了。

在这里,我们使用一个 timerExpired 函数来进行 timer 的重启工作。

源码如下:

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

如果在 wait 时间过后,shouldInvkoefalse 则表示还没达到调用 func 的条件,这里需要考虑 maxWait 的情况,因此再次调用 startTimer ,这次传入的时间为 remainingWait(time) ,即重新计算后的等待时间来重启 timer

如果达到调用 func 的条件,直接调用 func 即可。

debounced 函数也需要作如下的修改:

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

  lastArgs = args
  lastThis = this
  lastCallTime = time
  
  if (isInvoking) {
    if (timeId === undefined) {
      lastInvokeTime = lastCallTime
      return startTimer(timerExpired, wait)
    }
  }
  return result
}

汇总一下代码:

function debounce(func, wait, options) {
  let lastArgs,
    lastThis,
    maxWait,
    result,
    timerId,
    lastCallTime

  let lastInvokeTime = 0
  let maxing = false

  const useRAF = (!wait && wait !== 0 && typeof window.requestAnimationFrame === 'function')

  if (typeof func !== 'function') {
    throw new TypeError('Expected a function')
  }
  wait = +wait || 0
  if (Object.prototype.toString.call(options) === '[object Object]') {
    maxing = 'maxWait' in options
    maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait
  }

  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) {
      window.cancelAnimationFrame(timerId)
      return window.requestAnimationFrame(pendingFunc)
    }
    return setTimeout(pendingFunc, wait)
  }

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

  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

    return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
      (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait))
  }

  function timerExpired() {
    const time = Date.now()
    if (shouldInvoke(time)) {
      timerId = undefined
      return invokeFunc(time)
    }
    timerId = startTimer(timerExpired, remainingWait(time))
  }

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

    lastArgs = args
    lastThis = this
    lastCallTime = time

    if (isInvoking) {
      if (timerId === undefined) {
        lastInvokeTime = lastCallTime
        return startTimer(timerExpired, wait)
      }
    }
   
    return result
  }
  return debounced
}

启用定时器前先调用 func

这个开关同样也放在 options 中,用 leading 来表示。

获取值:

let leading = false

if (Object.prototype.toString.call(options) === '[object Object]') {
  leading = !!options.leading
}

我们有一个函数 leadingEdge 来判断是否需要一开始调用 func ,则 debounced 函数需要修改如下:

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)
    }
  }

  return result
}

leadingEdge 函数的实现:

function leadingEdge(time) {
  lastInvokeTime = time
  timerId = startTimer(timerExpired, wait)
  return leading ? invokeFunc(time) : result
}

这里前两行是之前的逻辑,第三行就个判断,如果 leadingtrue ,则马上调用 invokeFunc ,不需要等待 timer 跑完。

定时器跑完后不调用 func

同样在 options 中增加一个配置 trailing

let trailing = true

if (Object.prototype.toString.call(options) === '[Object, Object]') {
  trailing = 'trailing' in options ? !!options.trailing : trailing
}

然后从 trailing 中获取值。

我们假设控制定时器跑完后是否调用 func 的逻辑在 trailingEdge 中,则 timerExpired 需要作如下修改:

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

trailingEdge 的源码如下:

function trailingEdge(time) {
  timerId = undefined

  if (trailing && lastArgs) {
    return invokeFunc(time)
  }
  lastArgs = lastThis = undefined
  return result
}

原来 invokeFunc 里会重置 lastArgslastThis ,但是因为有 trailing 这个开关,invokeFunc 可能会调用不到,因此在这里也将 lastArgslastThis 重置。

这里除了 trailing 的判断外还有 lastArgs 的判断,没有 lastArgs 调用 invokeFunc 可能会出错,因为 func 可能会得不到它想要的参数。

其实后面会看到,手动调用 flush 函数的时候,可能会将 lastArgs 设置为 undefined ,如果 timer 时间到的时候,这里 invokeFunc 是不应该执行的,因此还没有获得它所需要的参数。

其他方法

其实 debounce  到这里已经实现完毕了,但是 debounce 还提供一些方法来供外界控制 debounce 的内部进程,和查询进度,这些方法作为 debounce 返回的函数 debounced 的属性来提供给外界使用。

cancel

cancel 方法其实在一开始的时候就已经实现,后来就不需要用到了,但是提供给外界可以取消 timer 也是挺有用的:

function cancelTimer(id) {
  if (useRAF) {
    return window.cancelAnimationFrame(id)
  }
  clearTimeout(id)
}
debounced.cancel = cancel

flush

flush 可以控制 func 是否立即执行,不需要等待 timer 时间到后再触发,源码如下:

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

如果没有 timerId 表示 func 已经执行过,或者第一次还没有调用,这里是没有 lastArgs 的,直接返回上一次的结果 result 即可。

否则调用 traillingEdge 方法去调用 func ,得到结果。

pending

pending 方法用来检测 timer 是否正在运行中。

源码如下:

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

如果 timerId 不为 undefined, 则表示 timer 正在运行中。

最后整合好基本就和源码一致了 debounce