跟underscore一起学防抖与节流

3,275 阅读9分钟

前言

节流(throttle)防抖(debounce)是网页性能优化的手段之一,也是大家在开发过程中经常忽视的点。面试也经常会被问,同时是前端进阶重要的知识点。
本文从概述,实现和源码三个部分着手,由浅入深的给大家分析和讲解节流和防抖的原理及实现,使读者能够明白其中原理并能够手写出相关代码。

概述

在解释防抖(debounce)和节流(throttle)之前,我们来看一下下面的例子

GIF.gif

此处是被我改造过的百度,我们看到一旦我们输入,控制台就同步输出。此处有一个细节,一开始使用keydown触发时,input中的value为空,也就是此时还没有输入任何信息

核心代码

let search = document.getElementById("kw");
search.addEventListener('keydown',function(){
     console.log(node.value)
})

一旦用户输入(keydown),百度就是根据请求查询相关词条。如果我们对连续不断输入,首先降低前端的性能,输入过快或者网速过慢就会出现延迟请求卡顿,增加后端服务器的压力。
现在使用underscore的防抖函数,来看一下加入防抖之后的效果

GIF.gif

防抖——让输入框更智能化,在用户输入完成超过一定时间才输出结果

核心代码

window.onload = function(){
    function print(){
        console.log(node.value);
    }
    var _print = _.debounce(print, 600)
    let node = document.getElementById("kw");
    node.addEventListener('keydown',_print);
}

直接使用underscore的工具函数debounce,第一个参数是你要触发的内容,第二个参数根据官方解释:

postpone its execution until after wait milliseconds have elapsed since the last time

也就是说

延迟最后执行的时间wait毫秒。在这个例子中,就是指你键盘一直不停的输入,如果两次输入间隔时间大于600ms,执行函数print

防抖用于延迟执行最后的动作。
节流的目的和防抖一样,但有略微区别,根据underscore官网解释的区别如下

when invoked repeatedly, will only actually call the original function at most once per every waitmilliseconds

翻译出来就是

当重复调用的时候,真正触发的只是最开始的函数,而且触发这个函数的等待时间最多是wait毫秒。

什么意思?如果使用 var _print = _.throttle(print, 1000) ,那么如果用户在百度中连续不断的输入数据时,从键入开始,每1s钟就会触发一次打印事件,如下所示:

GIF.gif

节流——在连续不断输入时,我们看到节流很有规律,每1s打印一次。

核心代码

window.onload = function(){
    function print(){
        console.log(node.value)
	}
	var _print = _.throttle(print, 1000)
	let node = document.getElementById("kw");
	node.addEventListener('keydown',_print);
}

我们再来举个栗子🌰,如果我们把百度接受用户请求比作站台载客问题,在政府没有管理(没用防抖和节流)之前,在站台上一旦来了乘客就上了出租车开走了。人一多,车就很多,交通拥挤(服务器压力变大),这个时候政府说我要来介入管理(节流和防抖),政府规定接客用大客车,并且制定了两条规则:

  1. 大客车停在站台上,当第一个乘客上车时,售票员开始计数,30min后,列车就开走
  2. 大客车停在站台上,当每上一个乘客时,售票员都计数,如果时间超过5min,售票员就觉得后续不会有人了,这应该是最后一个乘客,列车开走

这里边的规则1就是节流(第一个人说了算),规则2就是防抖(最后一人说了算)。这两种方式都能够减轻交通压力。
在scroll 事件,resize 事件、鼠标事件(比如 mousemove、mouseover 等)、键盘事件(keyup、keydown 等)存在被频繁触发的回调时间当中,使用throttle(事件节流)和 debounce(事件防抖)能够提高前端的性能,减少服务器压力。

实现

上述其实已经把节流和防抖的概念,作用和区别,下面我们根据原理来进行代码实现

节流

如概述中,节流就是“第一个人说了算”。在上述例子中,当在百度搜索框中,第一次按下键盘,就开始计时,等待“一定时间”后执行,而在这段时间内的触发事件直接被“节流阀”屏蔽掉。根据这个思想可以大致写一个节流函数。

  // fn是我们需要包装的事件回调, interval是时间间隔的阈值
  Function.prototype.throttle = (fn, interval)=>{
    // last为上一次触发回调的时间
    let last = 0;

    // 将throttle处理结果当作函数返回
    return function () {
        // 保留调用时的this上下文
        let context = this
        // 保留调用时传入的参数
        let args = arguments
        // 记录本次触发回调的时间
        let now = +new Date()

        // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
        if (now - last >= interval) {
        // 如果时间间隔大于我们设定的时间间隔阈值,则执行回调
            last = now;
            fn.apply(context, args);
        }
      }
  }

节流函数输入一个函数并返回一个函数(高阶函数)。节流使用闭包,保存上一次触发回调的时间(last),执行函数(fn),时间阀值(interval),在要执行fn时,当前时间与上一次触发时间进行比较,如果时间间隔大于interval(now - last >= interval),执行函数fn.apply(context, args)

防抖

防抖是“最后一个说了算”,也用上述例子,当在搜索框中每次按下键盘时,都启动一个“定时器”,如果在指定时间内又按下时,清除之前定时器,再新建一个。定时器的特性就是超过delay的时间,触发fn。那么我们实现的代码如下:

// fn是我们需要包装的事件回调, delay是每次推迟执行的等待时间
function debounce(fn, delay) {
  // 定时器
  let timer = null
  
  // 将debounce处理结果当作函数返回
  return function () {
    // 保留调用时的this上下文
    let context = this
    // 保留调用时传入的参数
    let args = arguments

    // 每次事件被触发时,都去清除之前的旧定时器
    if(timer) {
        clearTimeout(timer)
    }
    // 设立新定时器
    timer = setTimeout(function () {
      fn.apply(context, args)
    }, delay)
  }
}

防抖函数也是一个高阶函数,也使用了闭包,与节流不同,此处闭包保存的是setTimeout返回的timer,用于在后续持续触发之前及时取消定时器。

underscore源码

理解防抖和节流的概念和基本实现(这部分需要讲出原理,手写实现)。
下面来看一下underscore对于节流和防抖的实现

节流

理解了上述节流和防抖的实现,再来看underscore的源码就会容易很多。下面贴上代码实现,我在上面加了注释

  _.throttle = function(func, wait, options) {
    //timeout存储定时器  context存储上下文 args存储func的参数  result存储func执行的结果
    var timeout, context, args, result;
    var previous = 0;//记录上一次执行func的时间,默认0,也就是第一次func一定执行(now-0)大于wait
    if (!options) options = {};//默认options

    //定时器函数
    var later = function() {
      //记录这次函数执行时间
      previous = options.leading === false ? 0 : _.now();
      timeout = null;
      result = func.apply(context, args);//执行函数func
      if (!timeout) context = args = null;
    };

    var throttled = function() {
      var now = _.now();//当前时间
      //如果第一次不执行,previous等于当前时间
      if (!previous && options.leading === false) previous = now;
      //时间间隔-(当前时间-上一次执行时间)  
      var remaining = wait - (now - previous);
      context = this;
      args = arguments;
      //如果remaining<0,那么距离上次执行时间超过wait,如果(now-previous)<0,也就是now<previous
      if (remaining <= 0 || remaining > wait) {
        //清除定时器
        if (timeout) {
          clearTimeout(timeout);
          timeout = null;
        }
        previous = now;//记录当前执行时间
        result = func.apply(context, args);//执行函数func
        if (!timeout) context = args = null;
      } else if (!timeout && options.trailing !== false) {
        //如果不禁用最后一次执行(trailing为true),定时执行func
        timeout = setTimeout(later, remaining);
      }
      return result;
    };

    throttled.cancel = function() {
      clearTimeout(timeout);
      previous = 0;
      timeout = context = args = null;
    };

    return throttled;
  };

underscore的节流函数多了options参数,其中options有两个配置项leadingtrailing,因为在节流函数默认的第一时间尽快执行这个func(previous=0),如果你想禁用第一次首先执行的话,传递{leading:false},如果你想禁用最后一次执行的话,传递{trailing: false}
underscore使用if (!previous && options.leading === false) previous = now来禁止首次执行,这样后续的remaining等于1000,不会进入if的第一个条件体内,所以不会立即执行。
underscore使用定时器来控制最后一次是否需要执行,if (!timeout && options.trailing !==false)表明如果trailing设置false那么就不会触发定时器,也就不会执行。默认是可以执行最后一次,因为option.trailing=undefined,undefined!==false是true,所以可以执行定时器。

防抖

  _.debounce = function(func, wait, immediate) {
    //timeout存储定时器的返回值  result返回func的结果
    var timeout, result;
		
    //定时器触发函数
    var later = function(context, args) {
      timeout = null;
      if (args) result = func.apply(context, args);
    };

    var debounced = restArguments(function(args) {
      if (timeout) clearTimeout(timeout);//如果存在定时器,先清除原先的定时器
      if (immediate) {
        var callNow = !timeout;
        timeout = setTimeout(later, wait);//启动一个定时器
        if (callNow) result = func.apply(this, args);//如果immediate为true,那么立即执行函数
      } else {
        timeout = _.delay(later, wait, this, args);//同样启动一个定时器
      }

      return result;
    });

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

    return debounced;
  };

underscore的debouce函数多了immediate参数,当immediate为 true, debounce会在 wait 时间间隔的开始调用这个函数 。

总结

节流和防抖是JavaScript中一个非常重要的知识点,我们首先要知道节流是“第一个说了算”,后续都会被节流阀屏蔽掉,防抖是“最后一个说了算”,邪恶的魔鬼每个多会启动一个定时炸弹,只有后面的定时炸弹到了才会拆掉前面的炸弹,但是最后还是会延迟起爆。根据这个思想,我们利用闭包的思想能够手写实现它们。根据underscore的源码我们能够更好更灵活的利用它们。
最后我在贴一个道友制作的地址可以帮助我们直观的理解节流和防抖。
另外《underscore源码系列》已经整理至语雀,点击这里

参考文档

《函数防抖与函数节流》
《underscore中文文档》
《前端性能优化原理与实践》