考古节流与防抖

139 阅读3分钟

节流

节流很像我们玩游戏时的技能 cd,释放一次之后,在冷却期间不可以再次释放,那么我们很容易得出以下代码,本质上都是利用时间做判断

// 时间戳
function throttle(fun, wait) {
  let previous = 0;
  return function() {
    const now = +new Date();
    if (now - previous > wait) {
      fun.apply(this, arguments);
      previous = now;
    }
  }
}
// 定时器
function throttle(fun, wait) {
  let timer;
  return function() {
    if (!timer) {
      timer = setTimeout(() => {
        fun.apply(this, arguments);
        clearTimeout(timer);
        timer = null;
      }, wait);
    }
  }
}

仔细观察上述两种写法,时间戳写法的执行时机是在 wait 的开头执行(立即执行后进入冷却),而定时器写法则是在 wait 的结尾执行,那么我们可不可以同时具备这两种功能呢,而且在真实场景中,也可能会遇到此类需求

代码如下,初步具备首次执行和尾调用的功能

function throttle(func, wait) {
  let previous = 0;
  let timer;
  return function() {
    let now = +new Date();
    let remain = wait - (now - previous);
    if (remain <= 0) {
      // 第一次会立即执行,后续执行的条件是贤者时间(必须经过了等价于 wait 的时间)
      previous = now;
      func.apply(this, arguments);
    } else if (!timer){
      // 假如第一次执行后,在极短时间内再次执行了一次,那么我们设置一个尾调用
      // 比如 wait = 1000,距离第一次执行经过了 200ms,那么 remain 就是 800ms,即尾调用
      // 再次执行的话,也不会重新设置尾调用(到 wait 结束之前,都是冷却期)
      timer = setTimeout(() => {
        clearTimeout(timer);
        timer = null;
        previous = +new Date();
        fun.apply(this, arguments);
      }, remain);
    }
  };
}

我们可能认为没什么问题了,但是其实还有一个很隐蔽的坑,按照理想情况来说,我们在第二次贤者时间时,上次的尾调用已经结束并且完成了自我清理,众所周知,js 的定时器是不准确的,定时器回调的执行时机有可能晚于我们指定的时间

这是因为定时器的回调被会放在任务队列中,当时间到后,任务队列会通知主执行栈,若主执行栈的同步任务耗时过久,就可能会影响定时器的准确性,所以,我们对以上代码进行优化

function throttle(func, wait) {
  let previous = 0;
  let timer;
  return function() {
    let now = +new Date();
    let remain = wait - (now - previous);
    if (remain <= 0) {
      if (timer) {
          // 改动如下
          clearTimeout(timer);
          timer = null;
      }
      previous = now;
      func.apply(this, arguments);
    } else if (!timer){
      timer = setTimeout(() => {
        clearTimeout(timer);
        timer = null;
        previous = +new Date();
        fun.apply(this, arguments);
      }, remain);
    }
  };
}

在贤者时间过后,我们发起调用时,清除一下上次的尾调用,防止因为事件循环带来的副作用

最后我们把初次调用和尾调用封装成选项,并提供取消方法,终极代码如下

leading:false 表示禁用初次调用

trailing:false 表示禁用尾调用

function throttle(func, wait, options = {}) {
  let previous = 0;
  let timer;
  let throttled = function() {
    const now = +new Date();
    if (!previous && !options.leading) {
      previous = now;
    }
    const remain = wait - (now - previous);
    if (remain <= 0) {
      // 第一次会立即执行,后续执行的条件是贤者时间(必须经过了等价于 wait 的时间)
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      previous = now;
      func.apply(this, arguments);
    } else if (!timer && options.trailing){
      // 假如第一次执行后,在极短时间内再次执行了一次,那么我们设置一个尾调用
      // 比如 wait = 1000,距离第一次执行经过了 200ms,那么 remain 就是 800ms,即尾调用
      // 再次执行的话,也不会重新设置尾调用(到 wait 结束之前,都是冷却期)
      timer = setTimeout(() => {
        clearTimeout(timer);
        timer = null;
        previous = options.leading ? +new Date() : 0;
        fun.apply(this, arguments);
      }, remain);
    }
  };
  throttled.cancel = function() {
    clearTimeout(timer);
    previous = 0;
    timer = null;
  };
  return throttled;
}

注意这两个选项不能同时为 false,由于 trailing 为 false,不会有尾调用,所以在等待了大于 wait 的时间后,即 remain 为负数的情况下,这时函数会立即执行,违反了 leading 为 false 的配置

防抖

防抖就像送外卖一样,骑手不可能接一单就去送一单,以 10 分钟为例,如果该时间内再加一单,那么可以再等 10 分钟,直至没有新的单子后,10 分钟的末尾执行回调,可以见得,防抖默认就是 trailing 为 true 的情况

我们添加立即执行选项,直接贴出终极代码

function debounce(func, wait, options = {}) {
  let timer;
  return function() {
    if (options.immediate) {
      // 当下无定时器的情况下立即执行,否则等价于普通防抖(尾调用)
      const callNow = !timer;
      clearTimeout(timer);
      timer = setTimeout(() => {
        func.apply(this, arguments)
      }, wait);
      if (callNow) {
        func.apply(this, arguments)
      }
    } else {
      clearTimeout(timer);
      timer = setTimeout(() => {
        func.apply(this, arguments)
      }, wait);
    }
  };
}

多多重复,百炼成钢