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

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

解读源码通常都是直接拿官方源码来解读,不过此次咱们采用另外的方式:从最简单的场景开始写代码,而后慢慢往源码上来靠拢,按部就班来实现 lodash 中的 debounce 函数,从而更深入理解官方 debounce 源码的用意前端

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

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

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

一、用图例解析 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 的时间)

绘制黑色闹钟表示调用 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 时间后放置蓝色闹钟,后续闹钟的出现位置就由蓝色闹钟本身决策(下文会举例说明)

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

如今咱们来一个稍微复杂的场景:

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

调用 n 次codedebounced func/code 函数

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

放置蓝色闹钟

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

3.一、分析红色闹钟出现的位置

咱们只关注最后一个黑色闹钟,并假设蓝色闹钟距离该黑色闹钟时间间隔为 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.二、用代码描述图例过程

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

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.一、支持 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.二、支持 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.三、支持 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 函数,也就至关于阅读了一遍其源码。

五、实现 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实现

六、小结

在前端领域的性能优化手段中,防抖(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)
    }
  }

参考文章

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

相关文章
相关标签/搜索