函数去抖(debounce)和函数节流(throttle)

目的

以下场景往往由于事件频繁被触发,因而频繁执行DOM操作、资源加载等重行为,导致UI停顿甚至浏览器崩溃。

  • window对象的resizescroll事件
  • 拖拽时的mousemove事件
  • 射击游戏中的mousedownkeydown事件
  • 文字输入、自动完成的keyup事件

实际上对于window的resize事件,实际需求大多为停止改变大小n毫秒后执行后续处理;而其他事件大多的需求是以一定的频率执行后续处理。针对这两种需求就出现了debounce和throttle两种解决办法。

throttle(又称节流)和debounce(又称去抖)其实都是函数调用频率的控制器,

debounce去抖

当调用函数n秒后,才会执行该动作,若在这n秒内又调用该函数则将取消前一次并重新计算执行时间,举个简单的例子,我们要根据用户输入做suggest,每当用户按下键盘的时候都可以取消前一次,并且只关心最后一次输入的时间就行了。

在lodash中提供了debounce函数:

_.debounce(func, [wait=0], [options={}])

lodash在opitons参数中定义了一些选项,主要是以下三个:

  • leading,函数在每个等待时延的开始被调用,默认值为false
  • trailing,函数在每个等待时延的结束被调用,默认值是true
  • maxwait,最大的等待时间,因为如果debounce的函数调用时间不满足条件,可能永远都无法触发,因此增加了这个配置,保证大于一段时间后一定能执行一次函数

根据leadingtrailing的组合,可以实现不同的调用效果:

  • leading-falsetrailing-true:默认情况,即在延时结束后才会调用函数
  • leading-truetrailing-true:在延时开始时就调用,延时结束后也会调用
  • leading-true, trailing-false:只在延时开始时调用

deboucne还有cancel方法,用于取消防抖动调用

下面是一些简单的用例:

// 避免窗口在变动时出现昂贵的计算开销。
jQuery(window).on('resize', _.debounce(calculateLayout, 150));

// 当点击时 `sendMail` 随后就被调用。
jQuery(element).on('click', _.debounce(sendMail, 300, {
  'leading': true,
  'trailing': false
}));

// 确保 `batchLog` 调用1次之后,1秒内会被触发。
var debounced = _.debounce(batchLog, 250, { 'maxWait': 1000 });
var source = new EventSource('/stream');
jQuery(source).on('message', debounced);

// 取消一个 trailing 的防抖动调用
jQuery(window).on('popstate', debounced.cancel);

在学习Vue的时候,官网也用到了一个里子,就是用于对用户输入的事件进行了去抖,因为用户输入后需要进行ajax请求,如果不进行去抖会频繁的发送ajax请求,所以通过debounce对ajax请求的频率进行了限制

完整的demo在这里。

methods: {
  // `_.debounce` 是一个通过 Lodash 限制操作频率的函数。
  // 在这个例子中,我们希望限制访问 yesno.wtf/api 的频率
  // AJAX 请求直到用户输入完毕才会发出。想要了解更多关于
  getAnswer: _.debounce(function() {
    if (!reg.test(this.question)) {
      this.answer = 'Questions usually end with a question mark. ;-)';
      return;
    }
    this.answer = 'Thinking ... ';
    let self = this;
    axios.get('https://yesno.wtf/api')
    // then中的函数如果不是箭头函数,则需要对this赋值self
    .then((response) = > {
      this.answer = _.capitalize(response.data.answer)
    }).
    catch ((error) = > {
      this.answer = 'Error! Could not reach the API. ' + error
    })
  }, 500) // 这是我们为判定用户停止输入等待的毫秒数
},

简单的实现

一个简单的手写的去抖函数:

function test() {
  console.log(11)
}

function debounce(method, context) {
  clearTimeout(method.tId);
  method.tId = setTimeout(function() {
    method.call(context)
  }, 500)
}
window.onresize = function() {
  debounce(test, window);
}

lodash中debounce的源码学习

function debounce(func, wait, options) {
  var nativeMax = Math.max,
    toNumber,
    nativeMin

  var lastArgs,
    lastThis,
    maxWait,
    result,
    timerId,
    lastCallTime,
    // func 上一次执行的时间
    lastInvokeTime = 0,
    leading = false,
    maxing = false,
    trailing = true;

  // func必须是函数
  if (typeof func != 'function') {
    throw new TypeError(FUNC_ERROR_TEXT);
  }

  // 对间隔时间的处理
  wait = toNumber(wait) || 0;

  // 对options中传入参数的处理
  if (isObject(options)) {
    leading = !!options.leading;
    maxing = 'maxWait' in options;
    maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait;
    trailing = 'trailing' in options ? !!options.trailing : trailing;
  }

  // 执行要被触发的函数
  function invokeFunc(time) {
    var args = lastArgs,
      thisArg = lastThis;
    lastArgs = lastThis = undefined;
    lastInvokeTime = time;
    result = func.apply(thisArg, args);
    return result;
  }

  // 在leading edge阶段执行函数
  function leadingEdge(time) {
    // Reset any `maxWait` timer.
    lastInvokeTime = time;
    // 为 trailing edge 触发函数调用设定定时器
    timerId = setTimeout(timerExpired, wait);
    // leading = true 执行函数
    return leading ? invokeFunc(time) : result;
  }

  // 剩余时间
  function remainingWait(time) {
    // 距离上次debounced函数被调用的时间
    var timeSinceLastCall = time - lastCallTime,
      // 距离上次函数被执行的时间
      timeSinceLastInvoke = time - lastInvokeTime,
      // 用 wait 减去 timeSinceLastCall 计算出下一次trailing的位置
      result = wait - timeSinceLastCall;
    // 两种情况
    // 有maxing: 比较出下一次maxing和下一次trailing的最小值,作为下一次函数要执行的时间
    // 无maxing: 在下一次trailing时执行timerExpired
    return maxing ? nativeMin(result, maxWait - timeSinceLastInvoke) : result;
  }

  // 根据时间判断 func 能否被执行
  function shouldInvoke(time) {
    var timeSinceLastCall = time - lastCallTime,
      timeSinceLastInvoke = time - lastInvokeTime;
    // 几种满足条件的情况
    return (lastCallTime === undefined // 首次执行
      || (timeSinceLastCall >= wait) // 距离上次被调用已经超过 wait
      || (timeSinceLastCall < 0)// 系统时间倒退
      || (maxing && timeSinceLastInvoke >= maxWait)); //超过最大等待时间
  }

  // 在 trailing edge 且时间符合条件时,调用 trailingEdge函数,否则重启定时器
  function timerExpired() {
    var time = now();
    if (shouldInvoke(time)) {
      return trailingEdge(time);
    }
    // 重启定时器
    timerId = setTimeout(timerExpired, remainingWait(time));
  }

  // 在trailing edge阶段执行函数
  function trailingEdge(time) {
    timerId = undefined;
    // 有lastArgs才执行,
    // 意味着只有 func 已经被 debounced 过一次以后才会在 trailing edge 执行
    if (trailing && lastArgs) {
      return invokeFunc(time);
    }
    // 每次 trailingEdge 都会清除 lastArgs 和 lastThis,目的是避免最后一次函数被执行了两次
    // 举个例子:最后一次函数执行的时候,可能恰巧是前一次的 trailing edge,函数被调用,而这个函数又需要在自己时延的 trailing edge 触发,导致触发多次
    lastArgs = lastThis = undefined;
    return result;
  }

  // cancel方法
  function cancel() {
    if (timerId !== undefined) {
      clearTimeout(timerId);
    }
    lastInvokeTime = 0;
    lastArgs = lastCallTime = lastThis = timerId = undefined;
  }

  // flush方法--立即调用
  function flush() {
    return timerId === undefined ? result : trailingEdge(now());
  }

  function debounced() {
    var time = now(),
      //是否满足时间条件
      isInvoking = shouldInvoke(time);
    lastArgs = arguments;
    lastThis = this;
    lastCallTime = time; //函数被调用的时间
    // 无timerId的情况有两种:
    // 1.首次调用
    // 2.trailingEdge执行过函数
    if (isInvoking) {
      if (timerId === undefined) {
        return leadingEdge(lastCallTime);
      }
      if (maxing) {
        // Handle invocations in a tight loop.
        timerId = setTimeout(timerExpired, wait);
        return invokeFunc(lastCallTime);
      }
    }
    // 负责一种case:trailing 为 true 的情况下,在前一个 wait 的 trailingEdge 已经执行了函数;
    // 而这次函数被调用时 shouldInvoke 不满足条件,因此要设置定时器,在本次的 trailingEdge 保证函数被执行
    if (timerId === undefined) {
      timerId = setTimeout(timerExpired, wait);
    }
    return result;
  }

  debounced.cancel = cancel;
  debounced.flush = flush;
  return debounced;
}

throttle节流

throttle将一个函数的调用频率限制在一定阈值内,例如1s内一个函数不能被调用两次。

同样,lodash提供了这个方法:

_.throttle(func, [wait=0], [options={}])

具体使用的例子:

// 避免在滚动时过分的更新定位
jQuery(window).on('scroll', _.throttle(updatePosition, 100));

// 点击后就调用 `renewToken`,但5分钟内超过1次。
var throttled = _.throttle(renewToken, 300000, { 'trailing': false });
jQuery(element).on('click', throttled);

// 取消一个 trailing 的节流调用。
jQuery(window).on('popstate', throttled.cancel);

throttle同样提供了leadingtrailing参数,与debounce含义相同

其实throttle就是设置了maxwait的debounce

注意,debounce返回的是一个经过包装的函数,被包装的函数必须是要立刻执行的函数。例如:

function test() {
  console.log(123)
}
setInterval(function () {
  _.debounce(test, 1500)
}, 500)

上面的效果不会是我们想要的效果,因为每次setInterval执行之后,都返回了一个没有执行的、经过debounce包装后的函数,所以debounce是无效的

点击事件也是同样:

btn.addEventListener('click', function () {
  _.debounce(test, 1500)
})

上面的代码同样不会生效,正确的做法是:

btn.addEventListener('click', test)
setInterval(_.debounce(test, 1500), 500)

参考

  • http://www.css88.com/doc/lodash/#_debouncefunc-wait0-options
  • https://segmentfault.com/a/1190000012102372?utm_source=tuicool&utm_medium=referral

你可能感兴趣的:(JavaScript)