写一个完备的防抖函数 debounce

909 阅读3分钟

在不看 underscore 源码之前,我自己写的一版可能是这样的。

function debounce(func,wait = 1500) {
  let timeout;

  function later() {
    timeout = setTimeout(() => {
      func();
      clearTimeout(timeout);
      timeout = null;
    }, wait)
  }

  return function debounced() {
    if (timeout) {
      clearTimeout(timeout);
      timeout = null;
      later();
    } else {
      later();
    }
  }
}

可能很多同学和我一样,都使用 clearTimeout 去中途中断定时器,但是 _.debounce 却没有,它是怎么做到的?

分析过源码之后,才知道,它是用当前时间戳和上一次触发 debounced 的时间戳做比较来确定的。

我们把上一次的时间戳记作 previous,当前的时间戳记作 nownow - previous 的差值记作 passed

每次重新触发 debounced 时,previous 的时间戳都会更新,而此时我们计算 passed 的规则也就变化了,此时仅仅通过比较 wait - passed 就能知道要不要执行了。 从而避免了 clearTimeout 这一步。

如果把这个思路应用到我们写的第一版上去,就会变成:

function now() {
  return Date.now();
}

function debounce(func,wait = 1500) {
  let timeout, previous;

  function later() {
    const passed = now() - previous;
    if (wait > passed) {
      timeout = setTimeout(later, wait - passed);
    } else {
      timeout = null;
      func();
    }
  }

  return function debounced() {
    previous = now()
    if (!timeout) {
      timeout = setTimeout(later, wait)
    } 
  }
}

理清楚了这个思路后,剩下的代码相对比较简单,只是一些扩展 API 的代码,相信大家也都能自己看明白,只不过有一行代码刚看会比较懵,我给大家解释一下。

这是它的 later 函数,大家请看倒数第三、四行

var later = function() {
    var passed = now() - previous;
    if (wait > passed) {
      timeout = setTimeout(later, wait - passed);
    } else {
      timeout = null;
      func.apply(context, args);
      // This check is needed because `func` can recursively invoke `debounced`.
      if (!timeout) args = context = null; // 这一行。
    }
  };

我们上一步不是让 timeout = null 了吗,怎么下一步还要有 if (!timeout) 这个判断?这个肯定成立呀!

大家觉得这个判断不必要的原因可能是:这一段并不是异步代码,执行了上一语句肯定就直接执行下一条语句了,所以,这个判断没有必要。

我看过其他同学的源码解析,好像都没有比较详细的说明这一个过程,甚至有些同学说这行代码没有必要,其实是很有必要的。

下面我就来带大家分析一下这个过程。

我们只要举一个反例就好了,下面我们来设计一个例子:

我们写一个简单的递归函数:

let i = 0;
const debouncedFn = debounce(() => {
  if (i >= 10) {
    return;
  }
  i++;
  debouncedFn()
})

同时把 later 打个标记:

function later() {
    const passed = now() - previous;
    if (wait > passed) {
      timeout = setTimeout(later, wait - passed);
    } else {
      timeout = null;
      func();
      if (!timeout) {
        console.log('调用这里-1')
      } else {
        console.log('调用这里-2')
      }
    }
  }

image.png

我们惊奇的发现 '调用这里-2' 被调用了 10 次。

这时候我们就发现了这个判断的必要性,因为我们 debounce 的第一个参数 func ,也可能递归的调用 func 的 debounced 版 。如果不加这个判断的话,就会出现无法递归调用的问题。

这一点,如果我不看它的源码,我肯定不会想到还有这种场景。而我不动手写一遍,也肯定理解不了它的真正用意。总的来说,还是很有收获的。

本篇文章就着重分析了 debounce 的两个点:

  1. 使用时间戳来判断执行点
  2. 考虑递归函数的场景

希望能帮助到你,谢谢阅读!


_.debounce 的源码如下:

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);
      // This check is needed because `func` can recursively invoke `debounced`.
      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;
}