防抖函数debounce的分析

337 阅读6分钟

参考文献:

从入门到放弃预警:lodash防抖判断实在过于复杂,建议新手可以跳过,避免还没入门就放弃(笔者已放弃...)

1.说明

一万个读者就有一万个哈姆雷特,一万个用户就可以设计出一万种防抖函数。

但是归根结底,防抖的原理是一样的,防抖的意义也是一样的,但是不同人设计的防抖函数,难易不同、复杂度不同、扩展的功能同样也会不同。

本文会针对业界大佬封装的几种防抖函数进行逻辑分析,旨在一方面开拓个人眼界,原来防抖也可以多姿多样?同时拓宽平时在开发过程自己封装工具函数的思路和功能;最后也算是提升源码阅读的能力了。

那么,就出发吧。

2.防抖的目的

防抖的意义:不停的触发某个事件,比如mousemove,但是事件的回调函数只能在指定的n秒后才执行,如果在n秒内再次触发了事件,就会更新时间点,以这个时间点为基准的n秒后才执行回调函数,即不管怎样都要触发事件n秒后才会执行。

功能补充:有时候我们希望,一触发事件就执行回调函数,但是如果紧接着n秒内再次不停的触发事件,则都不执行回调,直到n秒后才可以再次执行。当然n秒内不停的触发事件依旧会更新时间点的基准,即不管怎样,除了第一次,都要触发事件n秒后才能再次执行。

3.简单的debounce

下面防抖函数代码取至冴羽大佬的文章

function debounce(func, wait, immediate) {
  var timeout, result;

  var debounced = function () {
    var context = this;
    var args = arguments;

    if (timeout) clearTimeout(timeout);
    if (immediate) {
      // 如果已经执行过,不再执行
      var callNow = !timeout;
      timeout = setTimeout(function () {
        timeout = null;
      }, wait);
      // 接收参数传递给func,并保证func的this和debounced一致
      if (callNow) result = func.apply(context, args);
    } else {
      timeout = setTimeout(function () {
       // 接收参数传递给func,并保证func的this和debounced一致
        result = func.apply(context, args);
      }, wait);
    }
    //有immediate时拿到的是当前返回值,没有时,拿到的是上一个事件防抖过程的值(因为异步直接返回值了)
    return result;
  };

    //取消当前防抖过程,重新开始
  debounced.cancel = function () {
    clearTimeout(timeout);
    timeout = null;
  };

  return debounced;
}

以上的防抖比较通用,也容易理解。

4.underscore的防抖

underscore的git地址:underscore

underscore对于防抖函数debounce的封装还是比较复杂的,中间还引用了其他函数,为了方便理解,此处分析过程会简化部分代码,完整代码后面会附上,大家也可以去github中查看源码。

function debounce(func, wait, immediate) {
  var timeout, previous, args, result, context;

  var later = function () {
    //获取wait时间后执行的late和最近一次debounced执行的时差,如果大于等于wait表示等待时间到了,
    // 否则刷新wait等待时间
    var passed = Date.now() - previous;
    if (wait > passed) {
      // 刷新等待:注意因为执行该later时,距离上一次执行debounced已经过了passed时间,由于总时间是
      // 固定的wait,因此下一次递归定时器延时为wait - passed
      timeout = setTimeout(later, wait - passed);
    } else {
      // 等待结束:如果没有immediate需要执行一次func回调
      timeout = null;
      if (!immediate) result = func.apply(context, args);
      // 官网对于下面代码的解释是:This check is needed because `func` can recursively invoke `debounced`.
      // 翻译来就是:这个检查是必要的,因为“ func”可以递归地调用“debounced”。
      // 个人认为如果不是为了方便垃圾回收,下面代码没必要写,但是如果是垃圾回收为啥不写previous呢?不懂!
      if (!timeout) args = context = null;
    }
  };
  
  var debounced = function () {
    context = this;
    args = arguments;
    // 获取每次执行debounced函数的时间戳
    previous = Date.now();
    // 每一次事件防抖完整过程,只有第一次debounced会执行到里面,因此第一次timout为null
    if (!timeout) {
      // 等待wait时间执行later,注意后面不停执行的debounced只会更新previous的值
      timeout = setTimeout(later, wait);
      // 如果是里面执行,则直接执行
      if (immediate) result = func.apply(context, args);
    }
    //有immediate时拿到的是当前返回值,没有时,拿到的是上一个事件防抖过程的值(因为异步直接返回值了)
    return result;
  };
    //取消当前防抖过程,重新开始
  debounced.cancel = function () {
    clearTimeout(timeout);
    timeout = args = context = null;
  };

  return debounced;
}

以上就是提取了实际debounced的代码,为了方便理解,分析过程已经写在了代码上。

以下简单总结下该防抖的原理:

  1. 接收func回调、wait、等待时间、immediate是否立即执行(立即执行流程在前面说明中有介绍);
  2. 第一次执行debounced时,会执行整个防抖中唯一一次的timeout = setTimeout(later, wait), 后续的防抖过滤操作都是在later函数中的递归定时器执行的;
  3. 第一次时如果是immediate,则立即执行func,因为后续wait时间内都不会执行func了;
  4. later中会根据当前时间戳和最新一次执行debounced的时间戳previous差,和wait比较,如果大于,表示该次debounced已经过了等待时间,表示整个过程可以结束,即此时执行func回调,并重置timeout;
  5. 如果小于,则把剩余的等待时间继续设置为定时器等待时间,递归调用later,重复4步骤;

可以看到前面讲的两种防抖函数的设计方式是不一样的,至于两者性能上是否有差异,笔者暂时不清楚怎么去考究,留个证据立个flag,以后水平上去了去深究。

附录:underscore中源码

// 处理参数函数:
function restArguments(func, startIndex) {
  console.log(func.length, startIndex);
  // startIndex如果没传,默认是最后一个索引,否则取正数(防止传负值)
  startIndex = startIndex == null ? func.length - 1 : +startIndex;
  return function () {
    var length = Math.max(arguments.length - startIndex, 0),
      rest = Array(length),
      index = 0;
    for (; index < length; index++) {
      rest[index] = arguments[index + startIndex];
    }
    switch (startIndex) {
      case 0:
        return func.call(this, rest);
      case 1:
        return func.call(this, arguments[0], rest);
      case 2:
        return func.call(this, arguments[0], arguments[1], rest);
    }
    var args = Array(startIndex + 1);
    for (index = 0; index < startIndex; index++) {
      args[index] = arguments[index];
    }
    args[startIndex] = rest;
    return func.apply(this, args);
  };
}

// 获取当前时间戳函数
const now = Date.now || function () {
    return new Date().getTime();
};

// 防抖函数:
export default function debounce(func, wait, immediate) {
  var timeout, previous, args, result, context;
  var later = function () {
    var passed = now() - previous;
    if (wait > passed) {
      timeout = setTimeout(later, wait - passed);
    } else {
      timeout = null;
      if (!immediate) result = func.apply(context, args);
      if (!timeout) args = context = null;
    }
  };
  var debounced = restArguments(function (_args) {
    context = this;
    args = _args;
    previous = now(); 
    if (!timeout) {
      timeout = setTimeout(later, wait);
      if (immediate) result = func.apply(context, args);
    }
    return result;
  });

  debounced.cancel = function () {
    clearTimeout(timeout);
    timeout = args = context = null;
  };

  return debounced;
}

相比于我们精简的防抖,源码中对debounced防抖函数进行了参数处理,使用restArguments函数根据需求去出处理参数。

restArguments无非是根据需求把接收的参数的后面指定个数的参数合并到一个数组上,类似arguments,读者可自行研究,此处不加以过多分析。

5.收获和总结

  • 锻炼了逻辑思维能力,说实话多看回调函数确实对提升抽象能力有一定的帮助,前提要有毅力和比较长的脑回路;
  • 对加深了闭包和垃圾回收的认知;
  • 复习了下html2canvas插件的使用,什么?你问我哪里使用了?侬,封面。