前言
debounce 和 throttle 相信大家并不陌生,我猜想过去,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 的源码之后,才发现 throttle 是 debounce的一种特殊情况。如果从上面的看不出来的话,可以通俗地这么理解:
debounce 将密集触发的事件合并成一个单独事件(不限时间,你可以一直密集地触发,它最终只会触发一次)而 throttle 在 debounce 的基础上增加了时间限制(maxWait),也就是你一直密集地触发时间,但是到了限定时间,它一定要触发一次,也就是上文中提到的 a constant flow of executions 。
可以照着这个 可视化分析界面 理解一下。
如果还没用过 lodash 的同学,建议先看下 lodash 里 debounce 和 throttle 的用法:
分步实现 debounce

debounce 实现,下面我们来按照 lodash 的实现思路,进行 第一步 拆解。
第一步 —— 基础的拆解

为了后续的扩展实现,第一步我们将一个基本的 debounce 拆分为五个部分。
- formatArgs()
没有什么好说的,一个健壮的工具函数是少不了入参校验的,当然,在第一步只是实现了最基本的校验和格式化。 - debounced()
和基础实现一样,最后的结果是返回一个包装了所有操作的函数,可以看到,里面的实现和基础实现类似,不同的是这里多了一步记录上一次调用的this和args。 - startTimer(wait)
将setTimeout设置定时器操作语义化为一个函数,入参是wait - timeExpired()
将回调函数抽成一个函数,目前的操作只有 invoke 需要防抖的函数,后续会慢慢添加功能。 - invokeFunc()
调用需要防抖的函数,这里做了一个参数的传递,获取
this和args。
经过上面的拆分,其实一个基本可用的 debounce 函数已经实现好了,但是我们会发现一个问题,他的调用严重依赖于 setTimeout,那么延迟时间是否一定为 wait 呢?其实是不一定的。
举个例子,比如说
wait为5,此时在某一个定时器的回调函数timeExpired检测到上一次触发时间的lastCallTime为100,而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 的时间
- 缓存计算最大值、最小值的函数
max和min
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 里,将这两个操作分为 leading 和 trailing 两个参数,分别对应控制 leadingEdge 和 trailingEdge 两个工具函数的执行,这里我们先实现 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)
}
// ......
}
// ......
}
至此,一个基本完整的 debounce 和 throttle 已经实现了,下一步只是锦上添花,加一些额外的 feature。
对应完整源码以及 Demo:
第六步 —— 增加 cancel 和 flush 功能
在 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 地址: github.com/LazyDuke/de…
后记
这是一个系列,系列文章:
- 手撕源码系列 —— lodash 的 debounce 与 throttle
- 手撕源码系列 —— 函子 + 观察者模式 + 状态 = Promise
- 手撕源码系列 —— 浅拷贝和深拷贝的完全实现(未完成)
- 手撕源码系列 —— 老生常谈的call、apply、bind和new(未完成)