聊聊lodash的debounce实现

#8 · open · 6 comments

View on GitHub ↗

ColaDaddyz

前段时间团队内部搞了一个代码训练营,大家组织在一起实现 `lodash` 的 `throttle` 和 `debounce`,实现起来觉得并不麻烦,但是最后和官方的一对比,发现功能的实现上还是有差距的,为了寻找我的问题,把官方源码阅读了一遍,本文是我阅读完成后的一篇总结。 > 本文只会列出比较核心部分的代码和注释,如果对全部的源码有兴趣的欢迎直接看我的[repo](https://github.com/ZhangFe/source-code-learning): ## 什么是throttle和debounce `throttle`(又称节流)和`debounce`(又称防抖)其实都是函数调用频率的控制器,这里只做简单的介绍,如果想了解更多关于这两个定义的细节可以看下后文给出的一张图片,或者阅读一下[lodash的文档](https://lodash.com/docs/4.17.4)。 `throttle`:将一个函数的调用频率限制在一定阈值内,例如 1s 内一个函数不能被调用两次。 `debounce`:当调用函数n秒后,才会执行该动作,若在这n秒内又调用该函数则将取消前一次并重新计算执行时间,举个简单的例子,我们要根据用户输入做suggest,每当用户按下键盘的时候都可以取消前一次,并且只关心最后一次输入的时间就行了。 `lodash` 对这两个函数又增加了一些参数,主要是以下三个: - leading,函数在每个等待时延的开始被调用 - trailing,函数在每个等待时延的结束被调用 - maxwait(debounce才有的配置),最大的等待时间,因为如果 `debounce` 的函数调用时间不满足条件,可能永远都无法触发,因此增加了这个配置,保证大于一段时间后一定能执行一次函数 > 这里直接剧透一下,其实 `throttle` 就是设置了 `maxwait` 的 `debounce`,所以我这里也只会介绍 `debounce` 的代码,聪明的读者们可以自己思考一下为什么。 ## 我的实现与lodash的区别 我自己的代码实现放在我的[repo](https://github.com/ZhangFe/source-code-learning/blob/master/debounce-throttle/my-debounce.js)里,大家有兴趣的可以看下。之前说过我的实现和 `lodash` 有些区别,下面就用两张图来展示一下。 这是我的实现 ![](http://ww3.sinaimg.cn/large/0060lm7Tly1flq1ddbguyj308u0c3wfk.jpg) 这是lodash的实现 ![](http://ww3.sinaimg.cn/large/0060lm7Tly1flq1ea6yz3j30cj0cb3zt.jpg ) 这里看到,我的代码主要有两个问题: 1. `throttle` 的最后一次函数会执行两次,而且并非稳定复现。 2. `throttle` 里函数执行的顺序不对,虽然我的功能实现了,但是对于每一次 `wait` 来说,我都是执行的 `leading` 那一次 ## lodash 的实现解读 下面,我就会带着这几个问题去看看 `lodasah` 的代码。 官方代码的实现也不是很复杂,这里我贴出一些核心部分代码和我阅读后的注释,后面会讲一下 lodash 的大概流程: ``` function debounce(func, wait, options) { let lastArgs, lastThis, maxWait, result, timerId, lastCallTime // 参数初始化 let lastInvokeTime = 0 // func 上一次执行的时间 let leading = false let maxing = false let trailing = true // 基本的类型判断和处理 if (typeof func != 'function') { throw new TypeError('Expected a function') } wait = +wait || 0 if (isObject(options)) { // 对配置的一些初始化 } function invokeFunc(time) { const args = lastArgs const thisArg = lastThis lastArgs = lastThis = undefined lastInvokeTime = time result = func.apply(thisArg, args) return result } function leadingEdge(time) { // Reset any `maxWait` timer. lastInvokeTime = time // 为 trailing edge 触发函数调用设定定时器 timerId = setTimeout(timerExpired, wait) // leading = true 执行函数 return leading ? invokeFunc(time) : result } function remainingWait(time) { const timeSinceLastCall = time - lastCallTime // 距离上次debounced函数被调用的时间 const timeSinceLastInvoke = time - lastInvokeTime // 距离上次函数被执行的时间 const timeWaiting = wait - timeSinceLastCall // 用 wait 减去 timeSinceLastCall 计算出下一次trailing的位置 // 两种情况 // 有maxing:比较出下一次maxing和下一次trailing的最小值,作为下一次函数要执行的时间 // 无maxing:在下一次trailing时执行 timerExpired return maxing ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) : timeWaiting } // 根据时间判断 func 能否被执行 function shouldInvoke(time) { const timeSinceLastCall = time - lastCallTime const timeSinceLastInvoke = time - lastInvokeTime // 几种满足条件的情况 return (lastCallTime === undefined //首次 || (timeSinceLastCall >= wait) // 距离上次被调用已经超过 wait || (timeSinceLastCall < 0) //系统时间倒退 || (maxing && timeSinceLastInvoke >= maxWait)) //超过最大等待时间 } function timerExpired() { const time = Date.now() // 在 trailing edge 且时间符合条件时,调用 trailingEdge函数,否则重启定时器 if (shouldInvoke(time)) { return trailingEdge(time) } // 重启定时器,保证下一次时延的末尾触发 timerId = setTimeout(timerExpired, remainingWait(time)) } function trailingEdge(time) { timerId = undefined // 有lastArgs才执行,意味着只有 func 已经被 debounced 过一次以后才会在 trailing edge 执行 if (trailing && lastArgs) { return invokeFunc(time) } // 每次 trailingEdge 都会清除 lastArgs 和 lastThis,目的是避免最后一次函数被执行了两次 // 举个例子:最后一次函数执行的时候,可能恰巧是前一次的 trailing edge,函数被调用,而这个函数又需要在自己时延的 trailing edge 触发,导致触发多次 lastArgs = lastThis = undefined return result } function cancel() {} function flush() {} function pending() {} function debounced(...args) { const time = Date.now() const isInvoking = shouldInvoke(time) //是否满足时间条件 lastArgs = args lastThis = this lastCallTime = time //函数被调用的时间 if (isInvoking) { if (timerId === undefined) { // 无timerId的情况有两种:1.首次调用 2.trailingEdge执行过函数 return leadingEdge(lastCallTime) } if (maxing) { // Handle invocations in a tight loop. timerId = setTimeout(timerExpired, wait) return invokeFunc(lastCallTime) } } // 负责一种case:trailing 为 true 的情况下,在前一个 wait 的 trailingEdge 已经执行了函数; // 而这次函数被调用时 shouldInvoke 不满足条件,因此要设置定时器,在本次的 trailingEdge 保证函数被执行 if (timerId === undefined) { timerId = setTimeout(timerExpired, wait) } return result } debounced.cancel = cancel debounced.flush = flush debounced.pending = pending return debounced } ``` 这里我用文字来简单描述一下流程: 首次进入函数时因为 lastCallTime === undefined 并且 timerId === undefined,所以会执行 leadingEdge,如果此时 leading 为 true 的话,就会执行 func。同时,这里会设置一个定时器,在等待 wait(s) 后会执行 timerExpired,timerExpired 的主要作用就是触发 trailing。 如果在还未到 wait 的时候就再次调用了函数的话,会更新 lastCallTime,并且因为此时 isInvoking 不满足条件,所以这次什么也不会执行。 时间到达 wait 时,就会执行我们一开始设定的定时器timerExpired,此时因为time-lastCallTime < wait,所以不会执行 trailingEdge。 这时又会新增一个定时器,下一次执行的时间是 remainingWait,这里会根据是否有 maxwait 来作区分: - 如果没有 maxwait,定时器的时间是 wait - timeSinceLastCall,保证下一次 trailing 的执行。 - 如果有 maxing,会比较出下一次 maxing 和下一次 trailing 的最小值,作为下一次函数要执行的时间。 最后,如果不再有函数调用,就会在定时器结束时执行 trailingEdge。 ## 我的问题出在哪? 那么,回到上面的两个问题,我的代码究竟是哪里出了问题呢? #### 为什么顺序图不对 研究了一下,lodash是比较稳定的在trailing时触发前一次函数调用的,而我的则是每次在 maxWait 时触发的下一次调用。问题就出在对于定时器的控制上。 因为在编码时考虑到定时器和 maxwait 会冲突的问题,在函数每次被调用的时候都会 `clearTimeout(timer)`,因此我的 `trailing` 判断其实只对整个执行流的最后一次有效,而非 lodash 所说的 `trailing` 控制的是函数在每个 `wait` 的最后执行。 而 lodash 并不会清除定时器,只是每次生成新的定时器的时候都会根据 lastCallTime 来计算下一次该执行的时间,不仅保证了定时器的准确性,也保证了对每次 `trailing` 的控制。 #### 为什么最后会触发两次 通过打 log 我发现这种触发两次的情况非常凑巧,最后一次函数执行的时候,正好满足前一个时延的 trailing,然后自己这个 wait 的定时器也触发了,所以最后又触发了一次本次时延的 trailing,所以触发了两次。 理论上 lodash 也会出现这种情况,但是它在每次函数执行的时候都会删除 lastArgs 和 lastThis,而下次函数执行的时候都会判断这两个参数是否存在,因此避免了这种情况。 ## 总结 其实之前就知道 `debounce` 和 `throttle` 的用途和含义,但是每次用起来都得去看一眼文档,通过这次自己实现以及对源码的阅读,终于做到了了熟于心,也发现自己的代码设计能力还是有缺陷,一开始并没有想的很到位。 写代码的,还是要多写,多看;慢慢做到会写,会看;与大家共勉。

Comments

dbwcooper

题主你好,我调用你的de bounce 代码段 ,发现并不能正常运行, 代码如下 ``` return function () { context = this; args = arguments; now = new Date().getTime(); clearTimeout(timer); console.log('执行了'); timer = setTimeout(function () { // ?? 如果我不设置trailing 并且options也不设置, 那么exec无执行的可能 if (trailing) exec(); // 保证下一次leading可用 first = true; // 要重置last_call 保证停止再执行的情况下时间无误 last_call = null; }, delay); if (first && leading) { first = false; exec(); return; } if (maxWait && !last_call) { last_call = now; } if (maxWait && now - last_call >= maxWait) { exec(); } ``` 如题主所说, trailing,函数在每个等待时延的结束被调用 [segmentfault说明](https://segmentfault.com/a/1190000012102372?utm_source=tuicool&utm_medium=referral) 如果我并没有配置 options 调用debounce ``` debounce(fn, 1000)``` 那么 exec函数将永不执行 (first, leading maxWait 均为false) 望回复。

ColaDaddyz

@duanbowen 不好意思哈,我的代码确实是有问题的,放在仓库里是为了用来做比对;最好是学习lodash的实现,哈哈

dbwcooper

@ZhangFe 好的,看你文章还是受益匪浅,非常感谢你的回复。

codenoobforreal

@ColaDaddyz 你好👋,我自己在阅读debounce相关源码时遇到了一些问题,所以在掘金搜索文章以求解惑,看到你的文章,解答了一部分我的问题,但是我对lodash源码中trailingEdge函数特意清除lastArgs的行为还是不能理解透彻(也就是执行两次的问题)。可能是理解能力的问题,我对于文章末尾的解释还是有些云里雾里。麻烦给一个稍微具体一些的例子,比如函数使用哪些参数调用时,在哪个特殊场景才会出现重复的问题。

codenoobforreal

@ColaDaddyz 其实主要是对于文章中“最后一次函数执行的时候,正好满足前一个时延的 trailing,然后自己这个 wait 的定时器也触发了,所以最后又触发了一次本次时延的 trailing”的描述不甚理解。对其中的指代可能产生了误解,比如最后一次函数指的是debounced吗?满足前一个时延的trailing是否指的trailingedge函数?自己wait的定时器指的什么?最后触发的时延trailing指的是?

codenoobforreal

@ColaDaddyz 结合代码中的注释“最后一次函数执行的时候,可能恰巧是前一次的 trailing edge,函数被调用,而这个函数又需要在自己时延的 trailing edge 触发,导致触发多次”,这句话中的多个函数哪些指的是debounced,哪些又指的是trailingedge,又或者是settimeout的参数中的fn函数。