手撕源码系列 —— lodash 的 debounce 与 throttle

前言

debouncethrottle 相信你们并不陌生,我猜测过去,FEer 对它们的了解大概分为如下几个阶段:javascript

  • 没据说过的
  • 据说过的
  • 了解原理可是徒手写不出来的
  • 能写出最基本的实现的
  • 能理解并写出 lodash 这种稍微复杂一点实现的

固然,在第三个阶段的人应该占绝大多数,当我还在第三阶段的时候,就但愿有一篇技术文章,能让我一下就能达到最后一个阶段。结果就是我 naive 了,google 了许多资料,50%在反复地聊基本实现,20%在基础上聊了二者的区别,20%在聊 underscore 的实现,剩下10%很粗暴地把源码和注释贴了上来。这就让我很难受了,没办法,万事开头难,我只能将这些资料和源码结合起来,事半功倍地进行探索。事实也证实,一口是吃不成胖子的,因此这篇文章旨在拆分 lodash 的实现,一步一步地理解并缩短 第四阶段第五阶段 的时间,至于以前处于前三阶段的同窗,能够去找些其余的文章来进行学习。java

一些必须知道的

什么是 debouncegit

debounce: Grouping a sudden burst of events (like keystrokes) into a single one.
防抖:将一组例如按下按键这种密集的事件归并成一个单独事件。

什么是 throttlegithub

throttle: Guaranteeing a constant flow of executions every X milliseconds.
节流:保证每 X 毫秒恒定地执行一些操做。

为何要重提一下二者的概念呢?由于我在第三阶段的时候,一直是把这二者分开理解的,等到理解了 lodash 的源码以后,才发现 throttledebounce的一种特殊状况。若是从上面的看不出来的话,能够通俗地这么理解:
debounce 将密集触发的事件合并成一个单独事件(不限时间,你能够一直密集地触发,它最终只会触发一次)而 throttledebounce 的基础上增长了时间限制(maxWait),也就是你一直密集地触发时间,可是到了限定时间,它必定要触发一次,也就是上文中提到的 a constant flow of executions缓存

能够照着这个 可视化分析界面 理解一下。app

若是还没用过 lodash 的同窗,建议先看下 lodashdebouncethrottle 的用法:函数

分步实现 debounce

carbon.png

上图是一个最基本的 debounce 实现,下面咱们来按照 lodash 的实现思路,进行 第一步 拆解。工具

第一步 —— 基础的拆解

carbon的副本.png

为了后续的扩展实现,第一步咱们将一个基本的 debounce 拆分为五个部分学习

  • formatArgs()

没有什么好说的,一个健壮的工具函数是少不了入参校验的,固然,在第一步只是实现了最基本的校验和格式化。优化

  • debounced()

和基础实现同样,最后的结果是返回一个包装了全部操做的函数,能够看到,里面的实现和基础实现相似,不一样的是这里多了一步记录上一次调用的 thisargs

  • startTimer(wait)

setTimeout 设置定时器操做语义化为一个函数,入参是 wait

  • timeExpired()

将回调函数抽成一个函数,目前的操做只有 invoke 须要防抖的函数,后续会慢慢添加功能。

  • invokeFunc()

调用须要防抖的函数,这里作了一个参数的传递,获取 thisargs

通过上面的拆分,其实一个基本可用的 debounce 函数已经实现好了,可是咱们会发现一个问题,他的调用严重依赖于 setTimeout,那么延迟时间是否必定为 wait 呢?实际上是不必定的。

举个例子,好比说 wait5,此时在某一个定时器的回调函数 timeExpired 检测到上一次触发时间的
lastCallTime100,而 Date.now()103,此时虽热 `103 - 100 = 3 <
5 ,要开启下一次定时,但这个时候定时的时间为 5 - 3 = 2` 就能够了。

接下来,就要进行定时时间的优化。

对应完整源码以及 Demo:debounce-1

第二步 —— 对定时时间的优化

为了达到对定时时间的优化,咱们须要加入时间参数进行详细计算,分为如下几步:

  • 缓存上一个执行 debounced 函数的时间 lastCallTime
var lastCallTime // 缓存的上一个执行 debounced 的时间
  • 缓存获取当前时间的函数
/**辅助函数的缓存 */
    now = Date.now
  • 加入判断某一时刻是否要调用 func 的工具函数 shouldInvoke

carbon的副本2.png

  • 加入计算真正延迟时间的工具函数 remainingWait

carbon的副本3.png

  • 运用上诉的两个新增的工具函数,修改回调的执行函数 timeExpired

carbon的副本4.png

修改后的回调函数再也不是单纯的调用 invokeFunc,而是先判断执行回调的时刻是否可以调用 func,若是能够,直接调用;若是不行,计算出真正的延迟时间并重置定时器。

对应完整源码以及 Demo:debounce-2

### 第三步 —— 加入maxWait ,实现基本的 throttle
为了以后 lodash 的功能扩展以及 throttle 的实现,这一步加入参数 最大限制时间 maxWait。分为如下几步:

  • 缓存上一个执行 invokeFunc 函数的时间 lastInvokeTime

    var lastInvokeTime = 0, // 缓存的上一个 执行 invokeFunc 的时间
  • 缓存计算最大值、最小值的函数 maxmin

    nativeMax = Math.max,
    nativeMin = Math.min
  • 增长对新入参 options 的校验

    if (isObject(options)) {
      maxing = 'maxWait' in options
      maxWait = maxing ? nativeMax(+options.maxWait || 0, wait) : maxWait
    }
  • 优化计算真正延迟时间的工具函数 remainingWait

carbon的副本5.png

  • 增长工具函数 shouldInvoke 的判断条件

    (maxing && timeSinceLastInvoke >= maxWait) // 等待时间超过最大等待时间
    • 优化包装函数 debounced 的执行过程

carbon的副本6.png

还记得开头说的 throttle 只是一个 debounce 的特殊状况吗?准确的说这一步就增长了这个特殊状况(maxWait),那么咱们就能够实现一个基本的 throttle了。

function debounce(func, wait, options) {
// ......
}

function throttle(func, wait) {
  return debounce(func, wait, {
    maxWait: wait
  })
}

对应完整源码以及 Demo:

第四步 —— 增长入参选项 trailing 以及 trailingEdge 工具函数

通常一些基础实现的 debounce ,在解决完 this 的指向event 对象 时,紧接就要处理 前置执行后置执行 的问题。在 lodash 里,将这两个操做分为 leadingtrailing 两个参数,分别对应控制 leadingEdgetrailingEdge 两个工具函数的执行,这里咱们先实现 trailing 。分为如下几步:

  • 初始化并给 trailing 设置默认值
var trailing = true
  • 增长对 trailing 的校验和格式化
trailing = 'trailing' in options ? !!options.trailing : trailing
  • 增长工具函数 trailingEdge

carbon的副本7.png

  • 修改回调函数,不直接调用 invokeFunc,而是经过 trailingEdge 来间接调用
// setTimeout 定时器的回调函数
function timeExpired() {
  // ......
  if (canInvoke) {
    return trailingEdge(time)
  }
  // ......
}

对应完整源码以及 Demo:

第五步 —— 增长入参选项 leading 以及 leadingEdge 工具函数

这一步基本和上一步相似,分为以几步:

  • 初始化并给 leading 设置默认值
var leading = false
  • 增长对 leading 的校验和格式化
leading = !!options.leading
  • 增长工具函数 leadingEdge

carbon的副本8.png

  • 修改包装函数 debounced 的执行过程
// 要返回的包装 debounce 操做的函数
function debounced() {
  // ......
  if (isInvoking) {
    if (timerId === undefined) {
      return leadingEdge(lastCallTime)
    }
    // ......
  }
  // ......
}

至此,一个基本完整的 debouncethrottle 已经实现了,下一步只是锦上添花,加一些额外的 feature

对应完整源码以及 Demo:

第六步 —— 增长 cancelflush 功能

lodash 的实现里,还增长了两个贴心的小功能,这里也一并贴上来:

  • 取消 debounce 效果的 cancel
// 取消 debounce 函数
function cancel() {
  if (timerId !== undefined) {
    clearTimeout(timerId)
  }
  lastInvokeTime = 0
  lastArgs = lastCallTime = lastThis = timerId = undefined
}
  • 取消并当即执行一次 debounce 函数的 flush
// 取消并当即执行一次 debounce 函数
function flush() {
  return timerId === undefined ? result : trailingEdge(now())
}

对应完整源码以及 Demo:

总结

虽然一开始直接撕源码,以为有点小复杂,可是只要将其主干剥离以后再理逻辑,就会将难度减小不少。从上述分步过程来看 lodash 的整体实现,整体能够分为

  • 返回的包装函数 debounced()
  • 校验并格式化入参的函数 fomrtArgs()
  • 设置 Timer 的工具函数 startTimer(time)
  • 定时器的回调函数 timeExpired()
  • 判断是否要调用 func 的函数shouldInvoke(time)
  • 触发 func 的函数 invokeFunc(time)
  • 前置触发 func 的边界函数 leadingEdge(time)
  • 后置触发 func 的边界函数 trailingEdge(time)
  • 内部的两个小工具函数(判断是不是 object 的 isObject(value)计算真正延迟时间的函数 remainingWait(time)
  • 两个小功能(取消 debounce 效果的 cancel()取消并当即执行一次 debounce 函数的 flush()

如下是我整理的一个执行流程图(完整大图在 repo 里),能够照着参考一下

lodash的debounce2.jpg

篇幅有限,不免一些错误,欢迎探讨和指教~
附一个 GitHub 完整的 repo 地址: https://github.com/LazyDuke/debounce-throttle-exploring

后记

接下来这个系列想继续写下去,目前想写的有

  • 用 TypeScript 实现一个 符合 Promise A+ 规范的Promise
  • 浅拷贝和深拷贝的彻底实现
  • 老生常谈的call、apply、bind和new
相关文章
相关标签/搜索