lessfish / underscore-analysis

【NO LONGER UPDATE】underscore-1.8.3.js 源码解读 & 系列文章(完)
MIT License
3.96k stars 642 forks source link

underscore 函数节流的实现 #22

Open lessfish opened 8 years ago

lessfish commented 8 years ago

Throttle

上文 我们聊了聊函数去抖(debounce),去抖的作用简单说是 使连续的函数执行降低到一次(通常情况下此函数为 DOM 事件的回调函数),核心实现也非常简单,重复添加定时器即可(具体可以参考 上文)。本文我们聊聊函数节流(throttle)。

简单的说,函数节流能使得连续的函数执行,变为 固定时间段 间断地执行。

还是以 scroll 事件为例,如果不加以节流控制:

window.onscroll = function() {
  console.log('hello world');
};

轻轻滚动下窗口,控制台打印了 N 多个 hello world 字符串。如果 scroll 回调不是简单的打印字符串,而是涉及一些 DOM 操作,这样频繁的调用,低版本浏览器可能就会直接假死,我们希望回调可以间隔时间段触发,比如上面的例子每 1000ms 打印一次,如何实现之?

大概有两种方式(underscore 也并用了这两种方式)。其一是用时间戳来判断是否已到回调该执行时间,记录上次执行的时间戳,然后每次触发 scroll 事件执行回调,回调中判断当前时间戳距离上次执行时间戳的间隔是否已经到达 1000ms,如果是,则执行,并更新上次执行的时间戳,如此循环;第二种方法是使用定时器,比如当 scroll 事件刚触发时,打印一个 hello world,然后设置个 1000ms 的定时器,此后每次触发 scroll 事件触发回调,如果已经存在定时器,则回调不执行方法,直到定时器触发,handler 被清除,然后重新设置定时器。

underscore 实现

如果是一般的使用场景,则上面的两个方式大同小异,都可以应用,但是 underscore 考虑了高级配置,即可以选择是否需要响应事件刚开始的那次回调(配置 leading 参数),以及事件结束后的那次回调(配置 trailing 参数)。 还是以 scroll 举例,设置 1000ms 触发一次,并且不配置 leading 和 trailing 参数,那么 scroll 开始的时候会响应回调,scroll 停止后还会触发一次回调。如果配置 {leading: false},那么 scroll 开始的那次回调会被忽略,如果配置 {trailing: false},那么 scroll 结束的后的那次回调会被忽略。需要特别注意的是,两者不能同时配置

所以说,underscore 的函数节流有三种调用方式,默认的(有头有尾),设置 {leading: false} 的,以及设置 {trailing: false} 的。再来看上面说的 throttle 的两种实现,第一种方式有缺陷,当事件停止触发时,便不能响应回调,所以如果没有设置 {trailing: false} (需要执行最后一次方法)也不能执行最后一次方法,这时我们需要用到定时器;而单纯的定时器方式,也有漏洞,因为使用了定时器延迟执行,所以当事件触发结束时还存在定时器,{trailing: false} 设置无法生效(还会执行最后一次方法)。所以我们需要两者并用。

上 underscore 源码,包含大量注释:

// Returns a function, that, when invoked, will only be triggered at most once
// during a given window of time. Normally, the throttled function will run
// as much as it can, without ever going more than once per `wait` duration;
// but if you'd like to disable the execution on the leading edge, pass
// `{leading: false}`. To disable execution on the trailing edge, ditto.
// 函数节流(如果有连续事件响应,则每间隔一定时间段触发)
// 每间隔 wait(Number) milliseconds 触发一次 func 方法
// 如果 options 参数传入 {leading: false}
// 那么不会马上触发(等待 wait milliseconds 后第一次触发 func)
// 如果 options 参数传入 {trailing: false}
// 那么最后一次回调不会被触发
// **Notice: options 不能同时设置 leading 和 trailing 为 false**
// 示例:
// var throttled = _.throttle(updatePosition, 100);
// $(window).scroll(throttled);
// 调用方式(注意看 A 和 B console.log 打印的位置):
// _.throttle(function, wait, [options])
// sample 1: _.throttle(function(){}, 1000)
// print: A, B, B, B ...
// sample 2: _.throttle(function(){}, 1000, {leading: false})
// print: B, B, B, B ...
// sample 3: _.throttle(function(){}, 1000, {trailing: false})
// print: A, A, A, A ...
// ----------------------------------------- //
_.throttle = function(func, wait, options) {
  var context, args, result;

  // setTimeout 的 handler
  var timeout = null;

  // 标记时间戳
  // 上一次执行回调的时间戳
  var previous = 0;

  // 如果没有传入 options 参数
  // 则将 options 参数置为空对象
  if (!options)
    options = {};

  var later = function() {
    // 如果 options.leading === false
    // 则每次触发回调后将 previous 置为 0
    // 否则置为当前时间戳
    previous = options.leading === false ? 0 : _.now();
    timeout = null;
    // console.log('B')
    result = func.apply(context, args);

    // 这里的 timeout 变量一定是 null 了吧
    // 是否没有必要进行判断?
    if (!timeout)
      context = args = null;
  };

  // 以滚轮事件为例(scroll)
  // 每次触发滚轮事件即执行这个返回的方法
  // _.throttle 方法返回的函数
  return function() {
    // 记录当前时间戳
    var now = _.now();

    // 第一次执行回调(此时 previous 为 0,之后 previous 值为上一次时间戳)
    // 并且如果程序设定第一个回调不是立即执行的(options.leading === false)
    // 则将 previous 值(表示上次执行的时间戳)设为 now 的时间戳(第一次触发时)
    // 表示刚执行过,这次就不用执行了
    if (!previous && options.leading === false)
      previous = now;

    // 距离下次触发 func 还需要等待的时间
    var remaining = wait - (now - previous);
    context = this;
    args = arguments;

    // 要么是到了间隔时间了,随即触发方法(remaining <= 0)
    // 要么是没有传入 {leading: false},且第一次触发回调,即立即触发
    // 此时 previous 为 0,wait - (now - previous) 也满足 <= 0
    // 之后便会把 previous 值迅速置为 now
    // ========= //
    // remaining > wait,表示客户端系统时间被调整过
    // 则马上执行 func 函数
    // @see https://blog.coding.net/blog/the-difference-between-throttle-and-debounce-in-underscorejs
    // ========= //

    // console.log(remaining) 可以打印出来看看
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        // 解除引用,防止内存泄露
        timeout = null;
      }

      // 重置前一次触发的时间戳
      previous = now;

      // 触发方法
      // result 为该方法返回值
      // console.log('A')
      result = func.apply(context, args);

      // 引用置为空,防止内存泄露
      // 感觉这里的 timeout 肯定是 null 啊?这个 if 判断没必要吧?
      if (!timeout)
        context = args = null;
    } else if (!timeout && options.trailing !== false) { // 最后一次需要触发的情况
      // 如果已经存在一个定时器,则不会进入该 if 分支
      // 如果 {trailing: false},即最后一次不需要触发了,也不会进入这个分支
      // 间隔 remaining milliseconds 后触发 later 方法
      timeout = setTimeout(later, remaining);
    }

    // 回调返回值
    return result;
  };
};

调用也是非常的简单:

function log() {
  console.log('hello world');
}

window.onscroll = _.throttle(log, 1000);
window.onscroll = _.throttle(log, 1000, {leading: false});
window.onscroll = _.throttle(log, 1000, {trailing: false});

有兴趣的可以琢磨下它是如何实现两种方式并用的,可以将我代码块中的三处注释打开看下(分别打印了 AB 以及 remaining )。

Read more

johnsoncheg commented 7 years ago

问个问题,remaing > wait 这种情况好像不可能发生吧

lessfish commented 7 years ago

@Johnson-Tech

有注释

// remaining > wait,表示客户端系统时间被调整过

johnsoncheg commented 7 years ago

@hanzichi 不好意思,眼拙了- -

mqyqingfeng commented 7 years ago

timeout = null设置为null,不仅仅是为了防止内存泄漏,而是clearTimeout(timeout)后,timeout的值并不会清空,如果不设置为null,就不能根据!timeout设置下次的timeout

ginnko commented 6 years ago

大神,想问下这里:

  1. previous = options.leading === false ? 0 : _.now(); 这里previous始终赋值为_.now()感觉也是可以正常运行的,不太明白为何这里要做这个选择.
  2. if (!previous && options.leading === false)这里感觉去掉!previous也可以运行, 也不太明白为何要增加这样一个判断
yinguangyao commented 6 years ago

@ginnko 1、如果是在leading为false的情况下,每次触发后一定会延迟wait时间才会调用later函数,如果将这个判断去掉,那么会出现一种情况就是,我执行later后很长时间(超过wait)没有触发(比如我触发scroll的时候突然我就不滚动页面了),那么now-previous就会大于wait,导致remaining为负数,会直接调用func函数,这样这次执行就不会延迟wait时间了。 2、这种情况下我就不清楚了,看起来只会在leading和trailing同时为false的时候去掉!previous才会出现问题,这个时候如果去掉!previous会导致func函数永远不会执行,但是看他们并不推荐两个同时为false

wangliang1124 commented 6 years ago

@ginnko 2去掉之后remaining就一直等于wait,if 语句还怎么执行

yinguangyao commented 6 years ago

@anotherleon if语句是肯定不会执行了,但是会走else if语句,下面还是可以正常运行的。