Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

underscore 函数节流的实现 #22

Open
lessfish opened this issue Oct 11, 2016 · 8 comments
Open

underscore 函数节流的实现 #22

lessfish opened this issue Oct 11, 2016 · 8 comments

Comments

@lessfish
Copy link
Owner

lessfish commented Oct 11, 2016

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
Copy link

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

@lessfish
Copy link
Owner Author

@johnson-tech

有注释

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

@johnsoncheg
Copy link

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

@mqyqingfeng
Copy link

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

@ginnko
Copy link

ginnko commented Jan 30, 2018

大神,想问下这里:

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

@yinguangyao
Copy link

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

@wangliang1124
Copy link

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

@yinguangyao
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants