相比网上教程中的 debounce
函数,lodash 中的 debounce
功能更为强大,相应的理解起来更为复杂;
解读源码一般都是直接拿官方源码来解读,不过这次我们采用另外的方式:从最简单的场景开始写代码,然后慢慢往源码上来靠拢,循序渐进来实现 lodash 中的 debounce
函数,从而更深刻理解官方 debounce 源码的用意。
为了减少纯代码带来的晦涩感,本文以图例来辅助讲解,一方面这样能减少源码阅读带来的枯燥感,同时也让后续回忆源码内容更加的具体形象。(记住图的内容,后续再写出源码也变得简单些)
在本文的末尾还会附上简易的 debounce & throttle
的实现的代码片段,方便平时快速用在简单场景中,免去引用 lodash
库。
本文属于源码解读类型的文章,对 debounce 还不熟悉的读者建议先通过参考文章(在文末)了解该函数的概念和用法。
附源码 debounce: https://github.com/boycgit/ts-debounce-throttle/blob/master/src/lib/debounce.ts#L80
首先搬出 debounce
(防抖)函数的概念:函数在 wait
秒内只执行一次,若这 wait
秒内,函数高频触发,则会重新计算时间。
看似简单一句话,内含乾坤。为方便行文叙述,约定如下术语:
func
函数进行 debounce
处理,经 debounced 后的返回值我们称之为 debounced func
wait
表示传入防抖函数的时间time
表示当前时间戳lastCallTime
表示上一次调用 debounced func
函数的时间lastInvokeTime
表示上一次 func
函数执行的时间result
是每次调用 debounced func
函数的返回值time
表示当前时间本文将搭配图例 + 程序代码的方式,将上述概念具象到图中。
以最简单的情景为例:在某一时刻点只调用一次 debounced func
函数,那么将在 wait
时间后才会真正触发 func
函数。
将这个情景形成一幅图例,最终绘制出的图如下所示:
简单场景下的图例
下面我们详细讲解这幅图的产生过程,其实不难,基本上看一遍就懂。
首先绘制在图中放置一个黑色闹钟表示用户调用 debounced func
函数:(同时用 lastCallTime
标示出最近一次调用 debounced func
的时间)
绘制黑色闹钟表示调用 debounced func
同时在距离该黑色闹钟 wait
处放置一个蓝色闹钟,表示setTimout(..., wait)
,该蓝色闹钟表示未来当代码运行到该时间点时,需要做一些判断:
放置一个蓝色闹钟
为了标示出表示程序当前运行的进度(当前时间戳),我们用橙红色滑块来表示:
橙红色表示当前时间戳
当红色滑块到达该蓝色闹钟处的时候,蓝色闹钟会进行判断:因为当前滑块距离最近的黑色闹钟的时间差为 wait
:
判断时间差为 wait
故而做出判断(依据 debounce
函数的功能定义):需要触发一次 func
函数,我们用红色闹钟来表示 func
函数的调用,所以就放置一个红色闹钟
放置红色闹钟,表示 func 函数被调用
很显然蓝色和红色闹钟重叠起来的。
同时我们给红色闹钟标上 lastInvokeTime
,记录最近一次调用 func
的时间:
给红色闹钟标上 lastInvokeTime
注意
lastInvokeTime
和lastCallTime
的区别,两者含义是不一样的
这样我们就完成了最简单场景下 debounce
图例的绘制,简单易懂。
后续我们会逐渐增加黑色闹钟出现的复杂度,不断去分析红色闹钟的位置。这样就能将理解 debounce
源码的问题转换成“根据图上黑色闹钟的位置,请画出红色闹钟位置”的问题,而分析红色闹钟位置的过程中也就是理解 debounce
源码的过程;
用图例方式辅助理解源码的方式可以减少源码阅读带来的枯燥感,同时后续回忆源码内容起来也更加具体形象。
为避免后续写文章到处解释图中元素的概念含义,这里不妨先罗列出来,如果阅读过程中忘记到这里回忆一下也会方便许多:
time
debounced func
函数的调用debounced func
函数时的时间,最后一次黑色闹钟上标上 lastCallTime
,表示最近一次调用的时间戳;func
函数的时间,最后一次红色闹钟上标上 lastInvokeTime
,表示最近一次调用的时间戳;func
函数执行的时间),每次时间轴上的橙红色滑块到这个时间点就要做判断:是执行 func
或者推迟蓝色闹钟位置有关蓝色闹钟,这里有两个注意点:
debounced func
函数时才会在 wait
时间后放置蓝色闹钟,后续闹钟的出现位置就由蓝色闹钟自己决策(下文会举例说明)现在我们来一个稍微复杂的场景:
假如在 wait
时间内(记住这个前提条件)调用 n 次 debounced func
函数,如下所示:
调用 n 次debounced func
函数
第一次调用 debounced func
函数会在 wait
时间后放置蓝色闹钟(只有第一次调用会放置蓝色闹钟,后续闹钟的位置由蓝色闹钟自己决策):
放置蓝色闹钟
以上就是描述,那么问题来了:请问红色闹钟应该出现在时间轴哪个位置?
我们只关注最后一个黑色闹钟,并假设蓝色闹钟距离该黑色闹钟时间间隔为 x
:
假设两闹钟距离 x
那么第一个黑色闹钟和最后一个黑色闹钟的时间间隔是 wait - x
:
两个黑闹钟间距
接下来我们关注橙红色滑块(即当前时间time
)到达蓝色闹钟的时,蓝色闹钟开始做决策:计算可知 x < wait
,此时蓝色闹钟决定不放置红色闹钟(即不触发 func
),而是将蓝色闹钟往后挪了挪,挪动距离为 wait - x
,调整完之后的蓝色闹钟位置如下:
调整后蓝色闹钟位置
之所以挪 wait - x
的距离,是因为挪完后的蓝色闹钟距离最后一个黑色闹钟恰好为 wait
间隔(从而保证 debounce
函数至少间隔 wait
时间 才触发的条件):
保证挪完后的蓝色闹钟距离最后一个黑色闹钟恰好为 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
也有这种操作,那么就不多此一举。记住:整个时间轴上最多只能同时有一个蓝色闹钟)用图说话:
支持 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
底线,到点就放置红色闹钟。
实现该特性的大致思路如下:
maxWait
是与 lastInvokeTime
共同协作maxWait
发挥作用;在没有 maxWait
的时候,是按上一次黑色闹钟进行测距,保证调整后的蓝色闹钟和黑色闹钟保持 wait
的距离;而在有了 maxWait
后,蓝色闹钟调整距离还会考虑上一次红色闹钟的位置,保持调整后闹钟的位置和红色闹钟距离不能超过 `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 实现执行效果图,用来自测是否真的理解通透:
loadash 执行效果图
注:此图取自于文章《 聊聊lodash的debounce实现》
在前端领域的性能优化手段中,防抖(debounce
)和节流(throttle
)是必备的技能,网上随便一搜就有很多文章去分析解释,不乏优秀的文章使用 图文混排 + 类比方式 深入浅出探讨这两函数的用法和使用场景(见文末的参考文档)。
那我为什么还要写这一篇文章?
缘起前两天手动将 lodash 中的 debounce
和 throttle
两个函数 TS 化的需求,而平时我也只是使用并没有在意它们真正的实现原理,因此在迁移过程我顺带阅读了一番 lodash 中这两个函数的源码。
具体原因和迁移过程请移步《技巧 - 快速 TypeScript 化 lodash 中的 throttle & debounce 函数》
本文尝试提供了另一个视角去解读,通过时间轴 + 闹钟图例 + 代码的方式来解读 lodash 中的 debounce
& throttle
源码;
整个流程下来只要理解了黑色、蓝色、红色这 3 种闹钟的关系,那么凭着理解力去实现简版 lodash 的 debounce
函数并非难事。
当然上述的叙述中,略过了很多细节和存在性的判断(诸如 timeId
的存在性判断、isInvoking
的出现位置等),省略这主要是为了降低源码阅读的难度;(实际中这些细节的处理有时候反而很重要,是代码健壮性不可或缺的一部分)
希望本文能对读者理解 lodash 中的 debounce
& throttle
源码有些许的帮助,欢迎随时关注微信公众号或者技术博客留言交流。
如果在你仅仅需要应付简单的一些场景,也可以直接使用下方的代码片段。
防抖函数的概念:函数在 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;
}
}
如果调用两次间隔 < wait 数值,先前调用会被 clearTimeout ,也就不执行;最终只执行 1 次调用(即第 2 次的调用)
如果调用两次间隔 > wait 数值,当执行 clearTimeout 的时候,前一次调用已经执行了;所以最终这两次调用都会执行
上述的实现,是最经典的 trailing
情况,即以 wait 间隔结束点作为函数调用计时点,是我们平时用的最多的场景
另外用得比较多的就是以 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
是闭包变量,相当于标志位,通过它可以知道某个函数的调用是否在上一次函数调用的影响范围内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)
}
}
REFERENCE
参考文档
—END—