相比网上教程中的 debounce
函数,lodash 中的 debounce
功能更为强大,相应的理解起来更为复杂;css
解读源码通常都是直接拿官方源码来解读,不过此次咱们采用另外的方式:从最简单的场景开始写代码,而后慢慢往源码上来靠拢,按部就班来实现 lodash 中的 debounce
函数,从而更深入理解官方 debounce 源码的用意。前端
为了减小纯代码带来的晦涩感,本文以图例来辅助讲解,一方面这样能减小源码阅读带来的枯燥感,同时也让后续回忆源码内容更加的具体形象。(记住图的内容,后续再写出源码也变得简单些)git
在本文的末尾还会附上简易的 debounce & throttle 的实现的代码片断,方便平时快速用在简单场景中,免去引用 lodash
库。github
本文属于源码解读类型的文章,对 debounce 还不熟悉的读者建议先经过参考文章(在文末)了解该函数的概念和用法。
附源码 debounce: https://github.com/boycgit/ts...
首先搬出 debounce
(防抖)函数的概念:函数在 wait
秒内只执行一次,若这 wait
秒内,函数高频触发,则会从新计算时间。面试
看似简单一句话,内含乾坤。为方便行文叙述,约定以下术语:npm
func
函数进行 debounce
处理,经 debounced 后的返回值咱们称之为 debounced func
wait
表示传入防抖函数的时间time
表示当前时间戳lastCallTime
表示上一次调用 debounced func
函数的时间lastInvokeTime
表示上一次 func
函数执行的时间result
是每次调用 debounced func
函数的返回值time
表示当前时间本文将搭配图例 + 程序代码的方式,将上述概念具象到图中。segmentfault
以最简单的情景为例:在某一时刻点只调用一次 debounced func
函数,那么将在 wait
时间后才会真正触发 func
函数。缓存
将这个情景造成一幅图例,最终绘制出的图以下所示:性能优化
下面咱们详细讲解这幅图的产生过程,其实不难,基本上看一遍就懂。微信
首先绘制在图中放置一个黑色闹钟表示用户调用 debounced func
函数:(同时用 lastCallTime
标示出最近一次调用 debounced func
的时间)
同时在距离该黑色闹钟 wait
处放置一个蓝色闹钟,表示setTimout(..., wait)
,该蓝色闹钟表示将来当代码运行到该时间点时,须要作一些判断:
为了标示出表示程序当前运行的进度(当前时间戳),咱们用橙红色滑块来表示:
当红色滑块到达该蓝色闹钟处的时候,蓝色闹钟会进行判断:由于当前滑块距离最近的黑色闹钟的时间差为 wait
:
故而作出判断(依据 debounce
函数的功能定义):须要触发一次 func
函数,咱们用红色闹钟来表示 func
函数的调用,因此就放置一个红色闹钟
很显然蓝色和红色闹钟重叠起来的。
同时咱们给红色闹钟标上 lastInvokeTime
,记录最近一次调用 func
的时间:
注意lastInvokeTime
和lastCallTime
的区别,二者含义是不同的
这样咱们就完成了最简单场景下 debounce
图例的绘制,简单易懂。
后续咱们会逐渐增长黑色闹钟出现的复杂度,不断去分析红色闹钟的位置。这样就能将理解 debounce
源码的问题转换成“根据图上黑色闹钟的位置,请画出红色闹钟位置”的问题,而分析红色闹钟位置的过程当中也就是理解 debounce
源码的过程;
用图例方式辅助理解源码的方式能够减小源码阅读带来的枯燥感,同时后续回忆源码内容起来也更加具体形象。
为避免后续写文章处处解释图中元素的概念含义,这里不妨先罗列出来,若是阅读过程当中忘记到这里回忆一下也会方便许多:
time
debounced func
函数的调用debounced func
函数时的时间,最后一次黑色闹钟上标上 lastCallTime
,表示最近一次调用的时间戳;func
函数的时间,最后一次红色闹钟上标上 lastInvokeTime
,表示最近一次调用的时间戳;func
函数执行的时间),每次时间轴上的橙红色滑块到这个时间点就要作判断:是执行 func
或者推迟蓝色闹钟位置有关蓝色闹钟,这里有两个注意点:
debounced func
函数时才会在 wait
时间后放置蓝色闹钟,后续闹钟的出现位置就由蓝色闹钟本身决策(下文会举例说明)如今咱们来一个稍微复杂的场景:
假如在 wait
时间内(记住这个前提条件)调用 n 次 debounced func
函数,以下所示:
第一次调用 debounced func
函数会在 wait
时间后放置蓝色闹钟(只有第一次调用会放置蓝色闹钟,后续闹钟的位置由蓝色闹钟本身决策):
以上就是描述,那么问题来了:请问红色闹钟应该出如今时间轴哪一个位置?
咱们只关注最后一个黑色闹钟,并假设蓝色闹钟距离该黑色闹钟时间间隔为 x
:
那么第一个黑色闹钟和最后一个黑色闹钟的时间间隔是 wait - x
:
接下来咱们关注橙红色滑块(即当前时间time
)到达蓝色闹钟的时,蓝色闹钟开始作决策:计算可知 x < wait
,此时蓝色闹钟决定不放置红色闹钟(即不触发 func
),而是将蓝色闹钟日后挪了挪,挪动距离为 wait - x
,调整完以后的蓝色闹钟位置以下:
之因此挪 wait - x
的距离,是由于挪完后的蓝色闹钟距离最后一个黑色闹钟刚好为 wait
间隔(从而保证 debounce
函数至少间隔 wait
时间 才触发的条件):
从挪移以后开始,到下一次橙色闹钟再次遇到蓝色闹钟这段期间,咱们暂且称之为 ”蓝色决策间隔期“(请忍耐这抽象的名称,毕竟我想了很久),蓝色闹钟基于此间隔期的内容来进行决策,只有两种决策:
wait
(time - lastCallTime >= wait
),那就会放置红色闹钟(即调用 func
),目标达成;y
,随后 又会日后挪动位置 wait-y
,再一次保证蓝色闹钟距离最后一个黑色闹钟刚好为 wait
间隔 —— 没错,又造成了新的 ”蓝色决策间隔期“;那接下去的分析就又回到了 这里两点(即递归决策),直到能放置到红闹钟为止。从上咱们能够看到,蓝色闹钟一直保持 ”绅士“ 风范,随着黑色闹钟的逼近,蓝色闹钟一直保持”克制“态度,不断调整本身的位置,让调整后的位置老是和最后一个黑色闹钟保持 wait
的距离。
咱们用代码将上述的过程描述出来,就是下面这个样子:
function debounce(func, wait, options) { var lastArgs, lastThis, result, timerId, lastCallTime, lastInvokeTime = 0, trailing = true; wait = toNumber(wait) || 0; // 红色滑块达到蓝色闹钟时,蓝色闹钟根据条件做出决策 function timerExpired() { var time = now(); // 决策 1: 知足放置红色闹钟的条件,则放置红闹钟 if (shouldInvoke(time)) { return trailingEdge(time); } // 不然,决策 2:将蓝色闹钟再日后挪 `wait-x` 位置,造成 ”蓝色决策间隔期“ timerId = setTimeout(timerExpired, remainingWait(time)); } // === 如下是具体决策中的函数实现 ==== // 作出 ”应当放置红色闹钟“ 的决策的条件:蓝色闹钟和最后一个黑色闹钟的间隔不小于 wait 间隔 function shouldInvoke(time) { var timeSinceLastCall = time - lastCallTime; return ( timeSinceLastCall >= wait ); } // 具体函数:放置红色闹钟 function trailingEdge(time) { timerId = undefined; if (trailing && lastArgs) { return invokeFunc(time); } lastArgs = lastThis = undefined; return result; } // 具体函数 - 子函数:在时间轴上放置红闹钟 function invokeFunc(time) { var args = lastArgs, thisArg = lastThis; lastArgs = lastThis = undefined; lastInvokeTime = time; result = func.apply(thisArg, args); return result; } // 具体函数:计算让蓝色闹钟日后挪 wait-x 位置 function remainingWait(time) { var timeSinceLastCall = time - lastCallTime, timeWaiting = wait - timeSinceLastCall; return timeWaiting ; } // ============== // 主流程:让红色滑块在时间轴上前进(即 debounced func 函数的执行) function debounced() { var time = now(); lastArgs = arguments; lastThis = this; lastCallTime = time; if (timerId === undefined) { timerId = setTimeout(timerExpired, wait); } return result; } return debounced; }
这部分代码还请不要略过,由于代码是从debounce
源码中整理过来,除了函数顺序略有调整外,源码风格保持原有的,至关于直接阅读源码。每一个函数都有注释,对比着图例阅读下来相信读完会有收获的。
上述这份代码已经包含了 debounce
源码的核心骨架,接下来咱们继续扩展场景,将源码内容丰满起来。
leading
功能简单理解就是,在第一次(注意这个条件)放下黑色闹钟的时候:
wait
处放置方式蓝色闹钟(注:第一次放下黑色闹钟的时候,按理说也会在 wait
处放下蓝色闹钟,考虑既然 leading
也有这种操做,那么就很少此一举。记住:整个时间轴上最多只能同时有一个蓝色闹钟)用图说话:
第一次放置黑色闹钟的时候,会叠加上红色闹钟(固然这个红色闹钟上会标示 lastInvokeTime
),另外在 wait
间隔后会有蓝色闹钟。其余流程和以前案例分析同样。
在代码层面,咱们给刚才的 debounce
函数添加 leading
功能(经过 options.leading
开启)、新增一个 leadingEdge
方法后,再微调刚才的代码:
function debounce(func, wait, options) { ... var leading = false; // 默认不开启 leading = !!options.leading; // 经过 options.leading 开启 ... // 首先:新增执行 leading 处的操做的函数 function leadingEdge(time) { lastInvokeTime = time; // 设置 lastInvokeTime 时间标签 timerId = setTimeout(timerExpired, wait); // 同时在此后 `wait` 处放置一个蓝色闹钟 return leading ? invokeFunc(time) : result; // 若是开启,直接放置红色闹钟;不然直接返回 result 数值 } ... // 其次:给放置红色闹钟新增一种条件 function shouldInvoke(time) { ... return ( lastCallTime === undefined || // 初次执行时 timeSinceLastCall >= wait // 或者前面分析的条件,两次 `debounced func` 调用间隔大于 wait ); } // 注意:放置完红色闹钟后,记得要清空 timerId,至关于清空时间轴上蓝色闹钟; function trailingEdge(time) { timerId = undefined; ... } // 最后:leading 边界调用 function debounced(){ ... var isInvoking = shouldInvoke(time); // 判断是否能够放置红色闹钟 ... if (isInvoking) { // 若是能够放置红色闹钟 if (timerId === undefined) { // 且当时间轴上没有蓝色闹钟 // 执行 leading 边界处操做(放置红色闹钟 或 直接返 result) return leadingEdge(lastCallTime); } } ... return result; } return debounced; }
要理解这个 maxWait
特性,咱们先看一种特殊状况,在 {leading: false} 下, 时间轴上咱们很密集地放置黑色闹钟:
按以前的所述规则,咱们的蓝色闹钟一直保持绅士态度,随着黑色闹钟的逼近,蓝色闹钟将不断将调整本身的位置,让本身调整后的位置老是和最后一个黑色闹钟保持 wait
的距离:
那么在这种状况下,若是黑色闹钟一直保持这种密集放置状态,理论上就红色闹钟就没有机会出如今时间轴上。
那在这种状况下可否实现一个功能,不管黑色闹钟多么密集,时间轴上最多隔 maxWait
时间就出现红色闹钟,就像下图那样:
有了这个功能属性后,蓝色闹钟今后 ”变得坚强“,也有了 "底线",纵使黑色闹钟的不断逼近,也会坚守 maxWait
底线,到点就放置红色闹钟。
实现该特性的大体思路以下:
maxWait
是与 lastInvokeTime
共同协做maxWait
发挥做用;在没有 maxWait
的时候,是按上一次黑色闹钟进行测距,保证调整后的蓝色闹钟和黑色闹钟保持 wait
的距离;而在有了 maxWait
后,蓝色闹钟调整距离还会考虑上一次红色闹钟的位置,保持调整后闹钟的位置和红色闹钟距离不能超过 maxWait
,这就是底线了,到了必定程度,就算黑色闹钟在逼近,蓝色闹钟也不会 ”退缩“:从代码层面上看, maxWait
具体是在 remainingWait
方法 和 shouldInvoke
中发挥做用的:
function debounce(func, wait, options) { ... var lastInvokeTime = 0; // 初始化 var maxing = false; // 默认没有底线 maxing = 'maxWait' in options; maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait; // 从 options.maxWait 中获取底线数值 ... // 首先,在在蓝色闹钟决策后退多少距离时,maxWait 发挥了做用 function remainingWait(time) { var timeSinceLastCall = time - lastCallTime, timeSinceLastInvoke = time - lastInvokeTime, timeWaiting = wait - timeSinceLastCall; // 在这里发挥做用,保持底线 return maxing ? nativeMin(timeWaiting, maxWait - timeSinceLastInvoke) : timeWaiting; } ... // 其次:针对 `maxWait`,给放置红色闹钟新增一种可能条件 function shouldInvoke(time) { ... var timeSinceLastInvoke = time - lastInvokeTime; // 获取距离上一次红色闹钟的时间间隔 return ( lastCallTime === undefined || // 初次执行时 timeSinceLastCall >= wait || // 或者前面分析的条件,两次 `debounced func` 调用间隔大于 wait (maxing && timeSinceLastInvoke >= maxWait) // 两次红色闹钟间隔超过 maxWait ); } // 最后:leading 边界调用 function debounced(){ ... var isInvoking = shouldInvoke(time); // 判断是否能够放置红色闹钟的条件 ... if (isInvoking) { // 若是能够放置红色闹钟 ... // 边界状况的处理,保证在紧 loop 中能正常保持触发 if (maxing) { timerId = setTimeout(timerExpired, wait); return invokeFunc(lastCallTime); } } ... return result; } return debounced; }
所以,maxWait
可以让红色闹钟保证在 maxWait
间隔内至少出现 1 次;
这两个函数是为了能随时控制 debounce
的缓存状态;
其中 cancel
方法源码以下:
// 取消防抖 function cancel() { if (timerId !== undefined) { clearTimeout(timerId); } lastInvokeTime = 0; lastArgs = lastCallTime = lastThis = timerId = undefined; }
调用该方法,至关于直接在时间轴上去除蓝色闹钟,这样红色方块(时间)就永远碰见不了蓝色闹钟,那样也就不会有放置红色闹钟的可能了。
其中 flush
方法源码以下:
function flush() { return timerId === undefined ? result : trailingEdge(now()); }
很是直观,调用该方法至关于直接在时间轴上放置红色闹钟。
至此,咱们已经完整实现了 lodash 的 debounce
函数,也就至关于阅读了一遍其源码。
在完成上面 debounce
功能和特性后(尤为是 maxWait
特性),就能借助 debounce
实现 throttle
函数了。
看 throttle 源码 就能明白:
function throttle(func, wait, options) { var leading = true, trailing = true; // ... return debounce(func, wait, { 'leading': leading, 'maxWait': wait, 'trailing': trailing }); }
因此在 lodash
中,只须要 debounce
函数便可,throttle
至关于 ”充话费“ 送的。
至此,咱们已经解读完 lodash 中的 debounce & throttle
函数源码;
最后附带一张 lodash 实现执行效果图,用来自测是否真的理解通透:
注:此图取自于文章《 聊聊lodash的debounce实现》
在前端领域的性能优化手段中,防抖(debounce
)和节流(throttle
)是必备的技能,网上随便一搜就有不少文章去分析解释,不乏优秀的文章使用 图文混排 + 类比方式 深刻浅出探讨这两函数的用法和使用场景(见文末的参考文档)。
那我为何还要写这一篇文章?
缘起前两天手动将 lodash 中的 debounce
和 throttle
两个函数 TS 化的需求,而平时我也只是使用并无在乎它们真正的实现原理,所以在迁移过程我顺带阅读了一番 lodash 中这两个函数的源码。
具体缘由和迁移过程请移步《 技巧 - 快速 TypeScript 化 lodash 中的 throttle & debounce 函数》
本文尝试提供了另外一个视角去解读,经过时间轴 + 闹钟图例 + 代码的方式来解读 lodash 中的 debounce
& throttle
源码;
整个流程下来只要理解了黑色、蓝色、红色这 3 种闹钟的关系,那么凭着理解力去实现简版 lodash 的 debounce
函数并不是难事。
固然上述的叙述中,略过了不少细节和存在性的判断(诸如 timeId
的存在性判断、isInvoking
的出现位置等),省略这主要是为了下降源码阅读的难度;(实际中这些细节的处理有时候反而很重要,是代码健壮性不可或缺的一部分)
但愿本文能对读者理解 lodash 中的 debounce
& throttle
源码有些许的帮助,欢迎随时关注微信公众号或者技术博客留言交流。
若是在你仅仅须要应付简单的一些场景,也能够直接使用下方的代码片断。
trailing
状况防抖函数的概念:函数在 n
秒内只执行一次,若这 n
秒内,函数高频触发,则会从新计算时间。
将这段话翻译成代码,你会发现并不难:
//防抖代码最简单的实现 function debounce(func, wait) { let timerId, result; return function() { if(timerId){ clearTimeout(timerId); // 每次触发 都清除当前timer,从新设置时间 } timerId = setTimeout(function(){ result = func.apply(this, arguments); }, wait); return result; } }
假如调用该闭包两次:
上述的实现,是最经典的 trailing
状况,即以 wait 间隔结束点做为函数调用计时点,是咱们平时用的最多的场景
leading
功能另外用得比较多的就是以 wait 间隔开始点做为函数调用计时点,即 leading
功能。
将上面代码中最后的 setTimeout
内容改为 timerId = undefined
,而将 fn.apply
提取出来加个 if
条件语句就行 ,修改后代码以下:
//防抖代码最简单的实现 function debounce(func, wait) { let timerId, result; return function() { if(timerId){ clearTimeout(timerId); // 每次触发 都清除当前timer,从新设置时间 } if(!timerId){ result = fn.apply(this, arguments); } timerId = setTimeout(function() { timerId = undefined; }, wait); return result; } }
fn.apply(lastThis, lastArgs)
之因此用 if 条件包裹,是针对首次调用的边界状况
timerId
是闭包变量,至关于标志位,经过它能够知道某个函数的调用是否在上一次函数调用的影响范围内假如调用该闭包两次:
timerId
已是 underfined
的,因此会当即执行 函数,因此最终这两次调用都会执行 throttle
函数的概念:函数在 n
秒内只执行一次,若这 n
秒内还在有函数调用的请求都直接被忽略掉。
实现原理也很简单:定义开关变量 canRun
,在定时开启的这段时间内控制这个开关变量为canRun = false
(上锁),执行完后才让 canRun = true
便可。
function throttle(func, wait) { let canRun = true return function () { if (!canRun) { return // 若是开关关闭了,那就直接不执行下边的代码 } canRun = false // 持续触发的话,run一直是false,就会停在上边的判断那里 setTimeout(() => { func.apply(this, arguments) canRun = true // 定时器到时间以后,会把开关打开,咱们的函数就会被执行 }, wait) } }
下面的是个人公众号二维码图片,欢迎关注交流。