JavaScript防抖节流原理

一、防抖debounce

你是否在日常开发中遇到一个问题,在滚动事件中需要做个复杂计算或者实现一个按钮的防二次点击操作

- 这些需求都可以通过函数防抖动来实现。如果在频繁的事件回调中做复杂计算,很有可能导致页面卡顿,不如将多次计算合并为一次计算,只在一个精确点做操作

- 防抖和节流的作用都是防止函数多次调用。区别在于,假设一个用户一直触发这个函数,且每次触发函数的间隔小于wait,防抖的情况下只会调用一次,而节流的 情况会每隔一定时间(参数wait)调用函数

持续触发scroll事件时,并不执行handle函数,当1000毫秒内没有触发scroll事件时,才会延时触发scroll事件

18721079-49409e2d862991d5.png

// 防抖

function debounce(fn, wait) {   

    var timeout = null;   

    return function() {       

        if(timeout !== null)  clearTimeout(timeout);       

        timeout = setTimeout(fn, wait);   

    }

}

// 处理函数

function handle() {   

    console.log(Math.random());

}

// 滚动事件

// 当持续触发scroll事件时,事件处理函数handle只在停止滚动1000毫秒之后才会调用一次,也就是说在持续触发scroll事件的过程中,事件处理函数handle一直没有执行

window.addEventListener('scroll', debounce(handle, 1000));

我们先来看一个袖珍版的防抖理解一下防抖的实现

// func是用户传入需要防抖的函数

// wait是等待时间

const debounce = (func, wait = 50) => {

  // 缓存一个定时器id

  let timer = 0

  // 这里返回的函数是每次用户实际调用的防抖函数

  // 如果已经设定过定时器了就清空上一次的定时器

  // 开始一个新的定时器,延迟执行用户传入的方法

  return function(...args) {

    if (timer) clearTimeout(timer)

    timer = setTimeout(() => {

      func.apply(this, args)

    }, wait)

  }

}

// 不难看出如果用户调用该函数的间隔小于wait的情况下,上一次的时间还未到就被清除了,并不会执行函数

这是一个简单版的防抖,但是有缺陷,这个防抖只能在最后调用。一般的防抖会有immediate选项,表示是否立即调用。这两者的区别,举个栗子来说

- 例如在搜索引擎搜索问题的时候,我们当然是希望用户输入完最后一个字才调用查询接口,这个时候适用延迟执行的防抖函数,它总是在一连串(间隔小于wait的)函数触发之后调用。

- 例如用户给interviewMap点star的时候,我们希望用户点第一下的时候就去调用接口,并且成功之后改变star按钮的样子,用户就可以立马得到反馈是否star成功了,这个情况适用立即执行的防抖函数,它总是在第一次调用,并且下一次调用必须与前一次调用的时间间隔大于wait才会触发

完整代码

下面我们来实现一个带有立即执行选项的防抖函数

// 这个是用来获取当前时间戳的

function now() {

  return +new Date()

}

/**

* 防抖函数,返回函数连续调用时,空闲时间必须大于或等于 wait,func 才会执行

*

* @param  {function} func        回调函数

* @param  {number}  wait        表示时间窗口的间隔

* @param  {boolean}  immediate  设置为ture时,是否立即调用函数

* @return {function}            返回客户调用函数

*/

function debounce (func, wait = 50, immediate = true) {

  let timer, context, args

  // 延迟执行函数

  const later = () => setTimeout(() => {

    // 延迟函数执行完毕,清空缓存的定时器序号

    timer = null

    // 延迟执行的情况下,函数会在延迟函数中执行

    // 使用到之前缓存的参数和上下文

    if (!immediate) {

      func.apply(context, args)

      context = args = null

    }

  }, wait)

  // 这里返回的函数是每次实际调用的函数

  return function(...params) {

    // 如果没有创建延迟执行函数(later),就创建一个

    if (!timer) {

      timer = later()

      // 如果是立即执行,调用函数

      // 否则缓存参数和调用上下文

      if (immediate) {

        func.apply(this, params)

      } else {

        context = this

        args = params

      }

    // 如果已有延迟执行函数(later),调用的时候清除原来的并重新设定一个

    // 这样做延迟函数会重新计时

    } else {

      clearTimeout(timer)

      timer = later()

    }

  }

}

- 对于按钮防点击来说的实现:如果函数是立即执行的,就立即调用,如果函数是延迟执行的,就缓存上下文和参数,放到延迟函数中去执行。一旦我开始一个定时器,只要我定时器还在,你每次点击我都重新计时。一旦你点累了,定时器时间到,定时器重置为 null,就可以再次点击了。

- 对于延时执行函数来说的实现:清除定时器ID,如果是延迟调用就调用函数

二、节流throttle

防抖动和节流本质是不一样的。防抖动是将多次执行变为最后一次执行,节流是将多次执行变成每隔一段时间执行

如下图,持续触发scroll事件时,并不立即执行handle函数,每隔1000毫秒才会执行一次handle函数


18721079-381b96f5989cc3b4.png

节流版本

/**

* underscore 节流函数,返回函数连续调用时,func 执行频率限定为 次 / wait

*

* @param  {function}  func      回调函数

* @param  {number}    wait      表示时间窗口的间隔

* @param  {object}    options  如果想忽略开始函数的的调用,传入{leading: false}。

*                                如果想忽略结尾函数的调用,传入{trailing: false}

*                                两者不能共存,否则函数不能执行

* @return {function}            返回客户调用函数 

*/

_.throttle = function(func, wait, options) {

    var context, args, result;

    var timeout = null;

    // 之前的时间戳

    var previous = 0;

    // 如果 options 没传则设为空对象

    if (!options) options = {};

    // 定时器回调函数

    var later = function() {

      // 如果设置了 leading,就将 previous 设为 0

      // 用于下面函数的第一个 if 判断

      previous = options.leading === false ? 0 : _.now();

      // 置空一是为了防止内存泄漏,二是为了下面的定时器判断

      timeout = null;

      result = func.apply(context, args);

      if (!timeout) context = args = null;

    };

    return function() {

      // 获得当前时间戳

      var now = _.now();

      // 首次进入前者肯定为 true

  // 如果需要第一次不执行函数

  // 就将上次时间戳设为当前的

      // 这样在接下来计算 remaining 的值时会大于0

      if (!previous && options.leading === false) previous = now;

      // 计算剩余时间

      var remaining = wait - (now - previous);

      context = this;

      args = arguments;

      // 如果当前调用已经大于上次调用时间 + wait

      // 或者用户手动调了时间

  // 如果设置了 trailing,只会进入这个条件

  // 如果没有设置 leading,那么第一次会进入这个条件

  // 还有一点,你可能会觉得开启了定时器那么应该不会进入这个 if 条件了

  // 其实还是会进入的,因为定时器的延时

  // 并不是准确的时间,很可能你设置了2秒

  // 但是他需要2.2秒才触发,这时候就会进入这个条件

      if (remaining <= 0 || remaining > wait) {

        // 如果存在定时器就清理掉否则会调用二次回调

        if (timeout) {

          clearTimeout(timeout);

          timeout = null;

        }

        previous = now;

        result = func.apply(context, args);

        if (!timeout) context = args = null;

      } else if (!timeout && options.trailing !== false) {

        // 判断是否设置了定时器和 trailing

    // 没有的话就开启一个定时器

        // 并且不能不能同时设置 leading 和 trailing

        timeout = setTimeout(later, remaining);

      }

      return result;

    };

};