Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
LazyDuke opened this issue Nov 10, 2019 · 0 comments
Open

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

LazyDuke opened this issue Nov 10, 2019 · 0 comments
Labels
blog blog

Comments

@LazyDuke
Copy link
Owner

LazyDuke commented Nov 10, 2019

前言

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

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

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

一些必须知道的

什么是 debounce

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

什么是 throttle

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

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

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

如果还没用过 lodash 的同学,建议先看下 lodashdebouncethrottle 的用法:

分步实现 debounce


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

第一步 —— 基础的拆解

为了后续的扩展实现,第一步我们将一个基本的 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

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

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

修改后的回调函数不再是单纯的调用 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

  • 增加工具函数 shouldInvoke 的判断条件
(maxing && timeSinceLastInvoke >= maxWait) // 等待时间超过最大等待时间
  • 优化包装函数 debounced 的执行过程

还记得开头说的 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
  • 修改回调函数,不直接调用 invokeFunc,而是通过 trailingEdge 来间接调用
// setTimeout 定时器的回调函数
function timeExpired() {
  // ......
  if (canInvoke) {
    return trailingEdge(time)
  }
  // ......
}

对应完整源码以及 Demo:

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

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

  • 初始化并给 leading 设置默认值
var leading = false
  • 增加对 leading 的校验和格式化
leading = !!options.leading
  • 增加工具函数 leadingEdge
  • 修改包装函数 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 里),可以照着参考一下

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

后记

这是一个系列,系列文章:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
blog blog
Projects
None yet
Development

No branches or pull requests

1 participant