【源码分析】给你几个闹钟,或许用 10 分钟就能写出 lodash 中的 debounce & throttle

相比网上教程中的 debounce 函数,lodash 中的 debounce 功能更为强大,相应的理解起来更为复杂;

解读源码一般都是直接拿官方源码来解读,不过这次我们采用另外的方式:从最简单的场景开始写代码,然后慢慢往源码上来靠拢,循序渐进来实现 lodash 中的 debounce 函数,从而更深刻理解官方 debounce 源码的用意

为了减少纯代码带来的晦涩感,本文以图例来辅助讲解,一方面这样能减少源码阅读带来的枯燥感,同时也让后续回忆源码内容更加的具体形象。(记住图的内容,后续再写出源码也变得简单些)

在本文的末尾还会附上简易的 debounce & throttle 的实现的代码片段,方便平时快速用在简单场景中,免去引用 lodash 库。

本文属于源码解读类型的文章,对 debounce 还不熟悉的读者建议先通过参考文章(在文末)了解该函数的概念和用法。

1、用图例解析 debounce 源码

附源码 debounce: https://github.com/boycgit/ts...

首先搬出 debounce(防抖)函数的概念:函数在 wait 秒内只执行一次,若这 wait 秒内,函数高频触发,则会重新计算时间

看似简单一句话,内含乾坤。为方便行文叙述,约定如下术语:

  • 假定我们要对 func 函数进行 debounce 处理,经 debounced 后的返回值我们称之为 debounced func
  • wait 表示传入防抖函数的时间
  • time 表示当前时间戳
  • lastCallTime 表示上一次调用 debounced func 函数的时间
  • lastInvokeTime 表示上一次 func 函数执行的时间
  • result 是每次调用 debounced func 函数的返回值
  • time 表示当前时间

本文将搭配图例 + 程序代码的方式,将上述概念具象到图中。

2、最简单的案例

以最简单的情景为例:在某一时刻点只调用一次 debounced func 函数,那么将在 wait 时间后才会真正触发 func 函数。

将这个情景形成一幅图例,最终绘制出的图如下所示:

简单场景下的图例

下面我们详细讲解这幅图的产生过程,其实不难,基本上看一遍就懂。

首先绘制在图中放置一个黑色闹钟表示用户调用 debounced func 函数:(同时用 lastCallTime 标示出最近一次调用 debounced func 的时间)

绘制黑色闹钟表示调用 debounced func

同时在距离该黑色闹钟 wait 处放置一个蓝色闹钟,表示setTimout(..., wait),该蓝色闹钟表示未来当代码运行到该时间点时,需要做一些判断:

放置一个蓝色闹钟

为了标示出表示程序当前运行的进度(当前时间戳),我们用橙红色滑块来表示:

橙红色表示当前时间戳

当红色滑块到达该蓝色闹钟处的时候,蓝色闹钟会进行判断:因为当前滑块距离最近的黑色闹钟的时间差为 wait

判断时间差为 wait

故而做出判断(依据 debounce 函数的功能定义):需要触发一次 func 函数,我们用红色闹钟来表示 func 函数的调用,所以就放置一个红色闹钟

放置红色闹钟,表示 func 函数被调用

很显然蓝色和红色闹钟重叠起来的。

同时我们给红色闹钟标上 lastInvokeTime,记录最近一次调用 func 的时间:

给红色闹钟标上 lastInvokeTime

注意 lastInvokeTimelastCallTime 的区别,两者含义是不一样的

这样我们就完成了最简单场景下 debounce 图例的绘制,简单易懂。

后续我们会逐渐增加黑色闹钟出现的复杂度,不断去分析红色闹钟的位置。这样就能将理解 debounce 源码的问题转换成“根据图上黑色闹钟的位置,请画出红色闹钟位置”的问题,而分析红色闹钟位置的过程中也就是理解 debounce 源码的过程;

用图例方式辅助理解源码的方式可以减少源码阅读带来的枯燥感,同时后续回忆源码内容起来也更加具体形象。

为避免后续写文章到处解释图中元素的概念含义,这里不妨先罗列出来,如果阅读过程中忘记到这里回忆一下也会方便许多:

  • 横线代表时间轴,橙红色滑块代表当前时间 time
  • 每个黑色箭头表示 debounced func 函数的调用
  • 黑色闹钟表示调用 debounced func 函数时的时间,最后一次黑色闹钟上标上 lastCallTime,表示最近一次调用的时间戳;
  • 红色闹钟表示调用 func 函数的时间,最后一次红色闹钟上标上 lastInvokeTime,表示最近一次调用的时间戳;
  • 此外还有一个蓝色闹钟,表示 setTimeout 时间戳(用来规划 func 函数执行的时间),每次时间轴上的橙红色滑块到这个时间点就要做判断:是执行 func 或者推迟蓝色闹钟位置

有关蓝色闹钟,这里有两个注意点:

  1. 时间轴上最多同时只有一个蓝色闹钟
  2. 只有在第一次调用 debounced func 函数时才会在 wait 时间后放置蓝色闹钟,后续闹钟的出现位置就由蓝色闹钟自己决策(下文会举例说明)

3、有 N 多个黑色闹钟的场景

现在我们来一个稍微复杂的场景:

假如在 wait 时间内(记住这个前提条件)调用 n 次 debounced func 函数,如下所示:

调用 n 次codedebounced func/code 函数

第一次调用 debounced func 函数会在 wait 时间后放置蓝色闹钟(只有第一次调用会放置蓝色闹钟,后续闹钟的位置由蓝色闹钟自己决策):

放置蓝色闹钟

以上就是描述,那么问题来了:请问红色闹钟应该出现在时间轴哪个位置?

3.1、分析红色闹钟出现的位置

我们只关注最后一个黑色闹钟,并假设蓝色闹钟距离该黑色闹钟时间间隔为 x

假设两闹钟距离 x

那么第一个黑色闹钟和最后一个黑色闹钟的时间间隔是 wait - x

两个黑闹钟间距

接下来我们关注橙红色滑块(即当前时间time)到达蓝色闹钟的时,蓝色闹钟开始做决策:计算可知 x < wait,此时蓝色闹钟决定不放置红色闹钟(即不触发 func),而是将蓝色闹钟往后挪了挪,挪动距离为 wait - x,调整完之后的蓝色闹钟位置如下:

调整后蓝色闹钟位置

之所以挪 wait - x 的距离,是因为挪完后的蓝色闹钟距离最后一个黑色闹钟恰好为 wait 间隔(从而保证 debounce 函数至少间隔 wait 时间 才触发的条件):

保证挪完后的蓝色闹钟距离最后一个黑色闹钟恰好为 codewait/code 间隔

从挪移之后开始,到下一次橙色闹钟再次遇到蓝色闹钟这段期间,我们暂且称之为 ”蓝色决策间隔期“(请忍耐这抽象的名称,毕竟我想了好久),蓝色闹钟基于此间隔期的内容来进行决策,只有两种决策:

  1. 如果在”蓝色决策间隔期“内没有黑闹钟出现,那么红色滑块达到蓝色闹钟的时候,蓝色闹钟计算获知当前蓝色闹钟距离上一个黑色闹钟的时间间隔不少于 waittime - lastCallTime >= wait),那就会放置红色闹钟(即调用 func),目标达成;

”蓝色决策间隔期“内没有黑闹钟出现,则可以直接放置红色闹钟

  1. 如果在”蓝色决策间隔期“内仍旧有黑闹钟出现,那么当橙红色滑块到达蓝色闹钟时,蓝色闹钟又会重新计算与该间隔期内最后一只黑色闹钟的距离 y,随后 又会往后挪动位置 wait-y,再一次保证蓝色闹钟距离最后一个黑色闹钟恰好为 wait 间隔 —— 没错,又形成了新的 ”蓝色决策间隔期“;那接下去的分析就又回到了 这里两点(即递归决策),直到能放置到红闹钟为止。

重新形成”蓝色决策间隔期“

从上我们可以看到,蓝色闹钟一直保持 ”绅士“ 风范,随着黑色闹钟的逼近,蓝色闹钟一直保持”克制“态度,不断调整自己的位置,让调整后的位置总是和最后一个黑色闹钟保持 wait 的距离。

3.2、用代码描述图例过程

我们用代码将上述的过程描述出来,就是下面这个样子:

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 源码的核心骨架,接下来我们继续扩展场景,将源码内容丰满起来。

4、丰富功能特性

4.1、支持 leading 特性

leading 功能简单理解就是,在第一次(注意这个条件)放下黑色闹钟的时候:

  1. 立即放置红闹钟,同时在
  2. 此后 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;
}

4.2、支持 maxWait 特性

要理解这个 maxWait 特性,我们先看一种特殊情况,在 {leading: false} 下, 时间轴上我们很密集地放置黑色闹钟:

按之前的所述规则,我们的蓝色闹钟一直保持绅士态度,随着黑色闹钟的逼近,蓝色闹钟将不断将调整自己的位置,让自己调整后的位置总是和最后一个黑色闹钟保持 wait 的距离:

密集的黑色闹钟将会让蓝色闹钟无处安放

那么在这种情况下,如果黑色闹钟一直保持这种密集放置状态,理论上就红色闹钟就没有机会出现在时间轴上。

那在这种情况下能否实现一个功能,无论黑色闹钟多么密集,时间轴上最多隔 maxWait 时间就出现红色闹钟,就像下图那样:

使用 maxWait 保证红色闹钟能出现

有了这个功能属性后,蓝色闹钟从此 ”变得坚强“,也有了 "底线",纵使黑色闹钟的不断逼近,也会坚守 maxWait 底线,到点就放置红色闹钟。

实现该特性的大致思路如下:

  1. maxWait 是与 lastInvokeTime 共同协作
  2. 在蓝色闹钟计算后退距离时,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 次;

4.3、支持 cancel / flush 方法

这两个函数是为了能随时控制 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 函数,也就相当于阅读了一遍其源码。

5、实现 throttle 函数

在完成上面 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实现

6、小结

在前端领域的性能优化手段中,防抖(debounce)和节流(throttle)是必备的技能,网上随便一搜就有很多文章去分析解释,不乏优秀的文章使用 图文混排 + 类比方式 深入浅出探讨这两函数的用法和使用场景(见文末的参考文档)。

那我为什么还要写这一篇文章?

缘起前两天手动将 lodash 中的 debouncethrottle 两个函数 TS 化的需求,而平时我也只是使用并没有在意它们真正的实现原理,因此在迁移过程我顺带阅读了一番 lodash 中这两个函数的源码。

具体原因和迁移过程请移步《技巧 - 快速 TypeScript 化 lodash 中的 throttle & debounce 函数

本文尝试提供了另一个视角去解读,通过时间轴 + 闹钟图例 + 代码的方式来解读 lodash 中的 debounce & throttle 源码;
整个流程下来只要理解了黑色、蓝色、红色这 3 种闹钟的关系,那么凭着理解力去实现简版 lodashdebounce 函数并非难事。

当然上述的叙述中,略过了很多细节和存在性的判断(诸如 timeId 的存在性判断、isInvoking的出现位置等),省略这主要是为了降低源码阅读的难度;(实际中这些细节的处理有时候反而很重要,是代码健壮性不可或缺的一部分)

希望本文能对读者理解 lodash 中的 debounce & throttle 源码有些许的帮助,欢迎随时关注微信公众号或者技术博客留言交流。

【附】代码片段

如果在你仅仅需要应付简单的一些场景,也可以直接使用下方的代码片段。

A. 简易 debounce - 只实现 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;
  }
}
  • debounce 返回闭包(匿名函数)
  • 假如调用该闭包两次:

    • 如果调用两次间隔 < wait 数值,先前调用会被 clearTimeout ,也就不执行;最终只执行 1 次调用(即第 2 次的调用)
    • 如果调用两次间隔 > wait 数值,当执行 clearTimeout 的时候,前一次调用已经执行了;所以最终这两次调用都会执行

不同间隔下调用 2 次最终触发函数情况不一样

上述的实现,是最经典的 trailing 情况,即以 wait 间隔结束点作为函数调用计时点,是我们平时用的最多的场景

B. 简易 debounce - 只实现 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 条件包裹,是针对首次调用的边界情况
  • debounce 仍旧返回闭包(匿名函数)
  • timerId 是闭包变量,相当于标志位,通过它可以知道某个函数的调用是否在上一次函数调用的影响范围内
  • 假如调用该闭包两次:

    • 如果调用两次间隔 < wait 数值,后调用因为仍在前一次的 wait 影响范围内,所以会被 clearTimeout 掉;最终只执行 1 次调用(即第 1 次的调用)
    • 如果调用两次间隔 > wait 数值,当执行第二次时 timerId 已经是 underfined 的,所以会立即执行 函数,所以最终这两次调用都会执行

不同间隔下调用 2 次最终触发函数情况不一样

C. 简易 throttle 函数

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)
    }
  }

参考文章

下面的是我的公众号二维码图片,欢迎关注交流。
个人微信公众号

阅读 1.7k

推荐阅读
JSCON简时空
用户专栏

任何时候开始学都不算晚,任何时候开始学都已经不早了

727 人关注
20 篇文章
专栏主页