防抖和节流

245 阅读9分钟

前言

  常常听到防抖和节流这两个名词,但一直只是有个粗浅的认识而已,现在终于腾出时间来一探这两者的究竟啦。先用一句话概括这两者:防抖和节流都是为了限制函数的执行频率,以优化函数触发频率过高导致的响应速度跟不上触发频率,出现页面延迟、假死或卡顿的现象。主要应用于一些会被频繁触发的事件中,例如input输入框的监听,resizescrollmousemovekeyup等。

  下面分别介绍防抖和节流的异同和实现方式,其中的函数实现主要参照underscore,再根据自己的一些理解写成的,诸位看官可以翻读underscore的源码。

防抖

  防抖其实就是当触发一个事件后,如果在指定时间 n 内再次触发该事件,则以新的触发时间为准重新计算执行时间,保证每次触发都会等到 n 秒后才执行并且不会累计触发(持续触发的情况下只会执行一次)。

  举个栗子,我们常常会给输入框绑定一个input监听函数,根据用户的输入内容来发送Ajax请求实时显示搜索结果。但这样有个问题,每次用户敲一下键盘都会触发到监听函数,造成监听函数被频繁地触发到了(尤其是输入中文的时候),如果监听函数是发送Ajax请求的话那简直就是场灾难。这时候防抖就可以派上用场了,我们设置一个定时器setTimeout让监听函数在指定的延迟时间比如 1s 后才执行,如果在定时器计时的这 1s 内再次触发了事件,则清除之前的定时器重新开始计时,保证监听函数一定会在每次触发的 1s 后才执行。这样监听input事件时就不会频繁地触发到监听函数了,大大优化了页面性能。其他的监听事件如scrollmousemove也是如此。

  我们先来看看普通的input监听函数的执行情况,从下图中可以看到我只是输入短短的 “世界很温柔” 五个字,监听函数却执行了多达 17 次。可以戳这里进行实验

  下面我们先来实现一个简单版的防抖,抓住每次触发事件时都会先清除掉之前的定时器再开始计时,我们不难写出下面这几行代码。需要注意的是要使用applycall来显示绑定监听函数(以下简称fn)的 this 为当前绑定监听函数的 DOM 对象,否则fn里的 this 会指向 window。

function debounce(fn, wait) {
  let timer = null;
  return function(...arg) {
    // 为fn绑定this指向
    let context = this;
    // 先清空定时器再重新开始计时
    clearTimeout(timer);
    timer = setTimeout(function() {
      fn.apply(context, arg);
    }, wait)
  }
}

  上面基本版的防抖,是每次触发事件后都会等过了指定的延迟时间才执行 fn,但有时候我们需要让事件触发的时候马上执行 fn,所以我们可以给debounce函数增加第三个参数 immediate 来选择是否触发事件后马上执行 fn。当 immediate 为 true 时,第一次触发事件的时候 timer 因为被初始化成了 null,所以会马上执行 fn。而每一次触发的时候都会重新将 timer 指向一个定时器,使 timer 在指定的延迟时间后重置为 null,从而控制 fn 可以再次执行。当 immediate 为 false 时,则和上文的基本版防抖一样。修改后的代码如下,实现效果可以戳这里查看

function debounce(fn, wait, immediate) {
  let timer = null;
  return function(...arg) {
    let context = this;
    if(timer)  clearTimeout(timer);
    // 触发后立即执行
    if(immediate) {
      // 如果两次触发的间隔小于wait,此时timer还不为null,不执行fn
      if(!timer) {
        fn.apply(context, arg);
      }
      // wait时间后把timer重新设置为null,表示可以再次执行fn了
      timer = setTimeout(function() {
        timer = null;
      }, wait)
    }
    // 触发后延时执行
    else {
      timer = setTimeout(function() {
        fn.apply(context, arg);
      }, wait);
    }
  }
}

节流

  说完防抖我们再说说节流,节流和防抖功能大致相同,不同点在于如果持续触发一个事件,防抖只会执行一次 fn,而节流则是每隔指定的时间就执行一次,保证每次执行 fn 的间隔时间相同。防抖和节流都可以降低函数的执行频率,而实际写代码的时候是选择防抖还是节流,还得要取决于具体的需求。

  节流有两种实现方式,一种是使用时间戳,另一种是使用定时器。使用时间戳的话,因为开始时间被初始化为 0,所以第一次触发时会马上执行 fn,并且停止触发后不会再执行。而使用定时器的话,第一次触发则会延迟执行,而停止触发后因为还有定时器的存在,所以会再执行一次 fn。下面给出两种实现方式的代码。

// 使用时间戳
function throttle(fn, wait) {
  let startTime = 0;
  return function(...arg) {
    let now = new Date();
    // 当前触发和上一次触发的时间间隔
    if(now - startTime > wait) {
      fn.apply(this, arg);
      startTime = now;
    }
  }
}
// 使用定时器
function throttle(fn, wait) {
  let timer = null;
  return function(...arg) {
    let context = this;
    if(timer === null) {
      timer = setTimeout(function() {
        timer = null;
        // 实际上只要把fn执行代码放到setTimeout外就可以使第一次触发立即执行了,但停止触发后就不会再执行一次了
        fn.apply(context, arg);
      }, wait)
    }
  }
}

  如果结合两者,就可以设置一个第一次触发就能够马上执行,最后一次触发后还能再执行一次的节流函数了。这里主要是使用一个 remaining 来计算还剩下多少时间才能够执行 fn,如果 remaining 小于等于 0,则说明距离上一次的 fn 执行时间已超出指定的间隔时间,此时可以再次执行 fn。如果 remaining 大于 0,则说明当前还没有达到间隔时间,若此时没有定时器在计时等待执行 fn,则设置一个定时器在稍后执行 fn,否则不再重新设置定时器。

function throttle(fn, wait) {
  let startTime = 0;
  let timer = null;
  return function(...arg) {
    let context = this;
    let now = new Date();
    // remaining表示还剩下多少时间就能执行fn
    let remaining = wait - (now - startTime);
    // 为了第一次触发能够马上执行
    if(remaining <= 0) {
      // 需要先清空定时器,否则会重复执行
      if(timer) {
        clearTimeout(timer);
        timer = null;
      }
      fn.apply(context, arg);~
      startTime = now;
    }
    // 为了最后一次触发还能够再执行一次
    else {
      // 如果不能马上执行fn并且定时器为空(表示前面没有定时器在计时)才开始计时等待执行
      if(timer === null) {
        timer = setTimeout(function() {
          fn.apply(context, arg);
          timer = null;
          startTime = new Date();
        }, remaining)
      }
    }
  }
}

  再进一步,我们还可以跟防抖一样,给节流函数设置第三个参数来传入一个对象,其中 leading 属性表示第一次触发是否马上执行 fn,trailing 属性表示最后一次触发是否会再执行一次 fn。我们先看看具体的实现代码,注释部分表示和上面代码的不一样之处。

function throttle(fn, wait, options) {
  // 若没有传递options,则默认两者都开启
  if(!options) {
    options = {
      leading: true,
      trailing: true
    }
  }
  let startTime = 0;
  let timer = null;
  return function(...arg) {
    let context = this;
    let now = new Date();
    // 若设置第一次触发不马上执行,则将开始时间设置为当前时间
    if(startTime === 0 && options.leading === false) {
      startTime = now;
    }
    let remaining = wait - (now - startTime);
    if(remaining <= 0) {
      if(timer) {
        clearTimeout(timer);
        timer = null;
      }
      fn.apply(context, arg);
      startTime = now;
    }
    else {
      // 如果设置最后一次触发不再执行的话,则直接使用时间戳法就够了
      if(timer === null && options.trailing === true) {
        timer = setTimeout(function() {
          fn.apply(context, arg);
          timer = null;
          // 需要将开始时间置为0,才能在下一次触发时将开始时间置为now,即不马上执行
          startTime = options.leading === false ? 0 : new Date();
        }, remaining)
      }
    }
  }
}

  这里的实现思想是,通过是否重置初始的 startTime 为触发的当前时间来控制第一次触发是否马上执行 fn。如果最后一次触发不再执行一次 fn,则直接使用时间戳法就够了,否则还得使用定时器。可能定时器里这句startTime = options.leading === false ? 0 : new Date()不太容易理解,这里解释一下。当一次持续触发过后,startTime 还会保留着上次的执行时间,如果等一会我再次触发它,因为此时 remaining 小于等于 0,所以第二次触发会马上执行。因此当设置了 options.leading 为 false 时就需要把 startTime 置为 0,才能使第二次触发能够把跟第一次触发一样把 startTime 重置为当前触发时间,从而不马上执行。

  需要注意的是,leading 和 trailing 两者不能同时设置为 false,否则第二次触发时还是会马上执行 fn。这是因为当 trailing 为 false 时,执行事件没有使用到定时器。也就同上面解释的,最后一次执行后没有把 startTime 置为 0,startTime 还是保留着上次的执行时间。导致下一次触发时 remaining 小于等于 0 所以会立即执行 fn。

  不得不说这是一个很大的 bug,在 underscore 的 issues 里也有人提出过这个问题,但作者似乎不理解为什么要把 leading 和 trailing 都设置为 false,作者的原意就是 leading 和 trailing 必须至少一个为 true。作者原话:

Why are both leading and trailing false? One of them should be true.

  emmm,我不太理解作者的想法,难道不应该考虑两者都为 false 的情况吗?好吧,我自己想了下,发现要解决这个问题的关键就在于如何在一次持续触发结束后把 startTime 重置为 0,但我想来想去还是没想明白要在哪里重置 startTime,新设置一个定时器来重置它好像也行不通。如何区分同一次的持续触发和不同次的持续触发,才好重置 startTime?

  读者可以在这里进行实验,设置不同的 leading 和 trailing 值来观察差别。