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 函数去抖的实现 #21

Open
hanzichi opened this Issue Oct 8, 2016 · 20 comments

Comments

Projects
None yet
@hanzichi
Owner

hanzichi commented Oct 8, 2016

前文 我们对 JavaScript 中的函数节流和函数去抖的概念和应用场景进行了简单的了解,本文我们来深入探究下函数去抖的实现。(不懂函数去抖概念的建议看下前文 JavaScript 函数节流和函数去抖应用场景辨析

我们以 scroll 事件为例,探究如何实现滚动一次窗口打印一个 hello world 字符串。

如果不对其进行节流或者去抖控制:

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

这样每滚动一次,实际上会打印 N 多个 hello world。函数去抖背后的基本思想是指,某些代码不可以在没有间断的情况连续重复执行。第一次调用函数,创建一个定时器,在指定的时间间隔之后运行代码。当第二次调用该函数时,它会清除前一次的定时器并设置另一个。如果前一个定时器已经执行过了,这个操作就没有任何意义。然而,如果前一个定时器尚未执行,其实就是将其替换为一个新的定时器。目的是只有在执行函数的请求停止了一段时间之后才执行。

《高程三》给出了最简洁最经典的去抖代码(书中说是节流,实则为去抖),调用如下:

function debounce(method, context) {
  clearTimeout(method.tId);
  method.tId = setTimeout(function() {
    method.call(context);
  }, 1000);
}

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

window.onscroll = function() {
  debounce(print);
};

在窗口内滚动一次,停止,1000ms 后,打印了 hello world,因为我们设置了一个 1000ms 延迟的定时器,细思非常巧妙。

underscore 在其基础上进行了扩充,直接看代码,含大量注释:

// Returns a function, that, as long as it continues to be invoked, will not
// be triggered. The function will be called after it stops being called for
// N milliseconds. If `immediate` is passed, trigger the function on the
// leading edge, instead of the trailing.
// 函数去抖(连续事件触发结束后只触发一次)
// sample 1: _.debounce(function(){}, 1000)
// 连续事件结束后的 1000ms 后触发
// sample 1: _.debounce(function(){}, 1000, true)
// 连续事件触发后立即触发(此时会忽略第二个参数)
_.debounce = function(func, wait, immediate) {
  var timeout, args, context, timestamp, result;

  var later = function() {
    // 定时器设置的回调 later 方法的触发时间,和连续事件触发的最后一次时间戳的间隔
    // 如果间隔为 wait(或者刚好大于 wait),则触发事件
    var last = _.now() - timestamp;

    // 时间间隔 last 在 [0, wait) 中
    // 还没到触发的点,则继续设置定时器
    // last 值应该不会小于 0 吧?
    if (last < wait && last >= 0) {
      timeout = setTimeout(later, wait - last);
    } else {
      // 到了可以触发的时间点
      timeout = null;
      // 可以触发了
      // 并且不是设置为立即触发的
      // 因为如果是立即触发(callNow),也会进入这个回调中
      // 主要是为了将 timeout 值置为空,使之不影响下次连续事件的触发
      // 如果不是立即执行,随即执行 func 方法
      if (!immediate) {
        // 执行 func 函数
        result = func.apply(context, args);
        // 这里的 timeout 一定是 null 了吧
        // 感觉这个判断多余了
        if (!timeout)
          context = args = null;
      }
    }
  };

  // 嗯,闭包返回的函数,是可以传入参数的
  return function() {
    // 可以指定 this 指向
    context = this;
    args = arguments;

    // 每次触发函数,更新时间戳
    // later 方法中取 last 值时用到该变量
    // 判断距离上次触发事件是否已经过了 wait seconds 了
    // 即我们需要距离最后一次触发事件 wait seconds 后触发这个回调方法
    timestamp = _.now();

    // 立即触发需要满足两个条件
    // immediate 参数为 true,并且 timeout 还没设置
    // immediate 参数为 true 是显而易见的
    // 如果去掉 !timeout 的条件,就会一直触发,而不是触发一次
    // 因为第一次触发后已经设置了 timeout,所以根据 timeout 是否为空可以判断是否是首次触发
    var callNow = immediate && !timeout;

    // 设置 wait seconds 后触发 later 方法
    // 无论是否 callNow(如果是 callNow,也进入 later 方法,去 later 方法中判断是否执行相应回调函数)
    // 在某一段的连续触发中,只会在第一次触发时进入这个 if 分支中
    if (!timeout)
      // 设置了 timeout,所以以后不会进入这个 if 分支了
      timeout = setTimeout(later, wait);

    // 如果是立即触发
    if (callNow) {
      // func 可能是有返回值的
      result = func.apply(context, args);
      // 解除引用
      context = args = null;
    }

    return result;
  };
};

等等,一下子多了这么多代码,那么我们比基础版多了哪些功能(优势)呢?

首先,基础版能做的,我们一样能做,一样让它在连续滚动后停止的 1000ms 后打印 hello world

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

window.onscroll = _.debounce(print, 1000);

我们还可以在滚动刚触发的时候打印字符串,而不是连续滚动结束后,只需传入第三个参数,会自动忽略第二个参数:

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

window.onscroll = _.debounce(print, 1000, true);

这样对于连续的滚动,也只会打印一次,但是是在事件第一次触发的时候。

回调函数需要传入参数?一点问题都没有。

function print(a) {
  console.log('The passed item is: ' + a);
}

var callback = _.debounce(print, 1000);
window.onscroll = function() {
  var item = 'zichi';
  callback(item);
};

当然,除了功能上的优势,性能也是提高不少,最显而易见的是基础版每此触发事件都会取消定时器,然后重新设置定时器,而 underscore 中会在一定时间后才取消定时器,重新设置定时器。其他更多可以细究下源码。(对性能有兴趣的可以看看这个 pr https://github.com/jashkenas/underscore/pull/1269)

@joesonw

This comment has been minimized.

joesonw commented Oct 9, 2016

基础版最重要的是, 有side effect吧.

@hanzichi

This comment has been minimized.

Owner

hanzichi commented Oct 9, 2016

@joesonw 请教下 side effect 具体是?

@joesonw

This comment has been minimized.

joesonw commented Oct 9, 2016

改变了输入值, 给function多加了属性.

@jsspace

This comment has been minimized.

jsspace commented Oct 11, 2016

发现个问题,这个 clearTimeout() 直接让 setTimeout() 中的函数不执行,而不是调用 clearTimeout 之后立即执行里面的函数。也就是说,setTimeout() 的回调会在最后一次执行 debounce() 后起作用。这样就保证了只执行一次,就是节流啊。。。

@hanzichi

This comment has been minimized.

Owner

hanzichi commented Oct 11, 2016

@jsspace 我的理解是 「节流」(throttle)是控制函数执行的频率,而不是只执行一次(debounce)

@jsspace

This comment has been minimized.

jsspace commented Oct 11, 2016

哦哦,我弄混了

@riskers

This comment has been minimized.

riskers commented Oct 11, 2016

throttle 和 debounce 的应用场景应该是分的很清楚的

  • 按一个按钮发送 AJAX:给 click 加了 debounce 后就算用户不停地点这个按钮,也只会最终发送一次;如果是 throttle 就会间隔发送几次
  • 监听滚动事件判断是否到页面底部自动加载更多:给 scroll 加了 debounce 后,只有用户停止滚动后,才会判断是否到了页面底部;如果是 throttle 的话,只要页面滚动就会间隔一段时间判断一次
@hanzichi

This comment has been minimized.

Owner

hanzichi commented Oct 11, 2016

@riskers 不错,给 throttle 应用加了这个 case

@oakland

This comment has been minimized.

oakland commented Oct 14, 2016

@joesonw ,你说的 side effect 是可以消除的。其实不必要非得给 function 添加这个属性,只要是一个在 debounce 函数外部的变量就可以。高程三里的这个写法其实是可改成下面这个样子的:

var timer = null;
function debounce(method, context) {
    clearTimeout(timer);
    timer = setTimeout(function() {
        method.call(context);
    }, 1000);
}

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

window.onscroll = function() {
    debounce(print);
};

为了避免对全局的污染,其实最好的方式是将 timer 放入函数中,成为一个局部变量,所以上面的写法可以改写成下面的方式:

function debounce(method, context) {
    var timer = null;
    return function() {
        clearTimeout(timer);
        timer = setTimeout(function() {
            method.call(context);
        }, 1000);
    }
}

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

window.onscroll = debounce(print);

从这个意义上讲,闭包其实就是用来将两个内容隔离用的,将 timer 放入函数中,那么就需要将原来的语句放入函数中,使其与 timer 隔离,最近返回这个函数。结果就会和原来的效果是一样的。

@joesonw

This comment has been minimized.

joesonw commented Oct 14, 2016

@oakland
恩, 这样就是接近underscore的方法的. 我的意思是原代码那样是会有隐患.

@oakland

This comment has been minimized.

oakland commented Oct 14, 2016

debounce 有种 hold 住的感觉,一个动作不停地被触发,但是又不停地被终止,两次触发之间的时间长于给定的时间段才会真正触发这个时间。
不断触发又终止的过程,其实有点像卡带一样,不停地在重复一个声音,但是这个声音刚出来就被终止刚出来就被终止,直到不再卡带才会顺畅的播放一次。
上面的代码再做修改,可以发现,其实 debounce() 函数被触发了很多次,不过 print 函数被不断地触发禁止,触发禁止...

function debounce(method, context) {
    var timer = null;
    var n = 0;
    return function() {
        clearTimeout(timer);
        timer = setTimeout(function() {
            method.call(context);
        }, 1000);
        console.log(n++);
    }
}

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

window.onscroll = debounce(print);
@gdh1995

This comment has been minimized.

gdh1995 commented Nov 20, 2016

// 这里的 timeout 一定是 null 了吧

我感觉这里是这样的:func作为用户传入的任意函数,有可能会反过来调用debounce返回的新函数,比如

var func, de, i = 0;
func = function() {
  i++;
  if (i < 10) {
    console.log(i);
    de();
    // setTimeout(de, 10);
  }
};
de = _.debounce(func, 40);
de();

这个会输出1到9,改改条件应该就能出现 de -> func -> de这种嵌套调用了。

更新1:我才意识到我也把debounce当成节流了,抱歉。
更新2:那么debounce函数里应该可以判断immediate,如果是true则不用储存context / args了,正如最新的jashkenas/underscore:master里的写法。

那么,同理我发现底下的if (callNow)有问题,可能会造成context和args被提前释放:

var f, d, tick = 0;
f = function() {
  console.log('tick:', ++tick, [].slice.call(arguments, 0));
  if (tick === 1) {
    return d(1, 2) || 'tick-1 but d(1,2) returns empty';
  }
  return 'tick-' + tick;
};
d = _.debounce(f, 100, true);
var ret1 = d('ni hao');
console.log('first result', ret1);

输出是:

VM94:63 call now: begin with ["ni hao"]
VM94:77 tick: 1 ["ni hao"]
VM94:66 call now: end with [1, 2]
VM94:85 first result tick-1 but d(1,2) returns empty

demo见https://jsfiddle.net/emx3zdd9/1/

@gdh1995

This comment has been minimized.

gdh1995 commented Nov 20, 2016

话说您用的understore 1.8.3 和 现在的jashkenas/underscore:master (https://github.com/jashkenas/underscore/blob/97cfcbcbbcedf544a13127dcca3e0ddad94ff830/underscore.js) 差了很多啊,_.debounce 完全被重写了。

我有个疑问是,master上的debounce已经在每次进入时就clearTimeout了,和您的“性能优化”的解释不一样,请问这两个方案的真正差别是什么?是应用场景导致的取舍吗?

  • 我记得Chrome Developer Tools的Timeline里,每个setTimeout都要0.3-0.5ms(补充:好像是30~50us)吧
  • 我现在想对<input>.oninput做debounce (初步认为300ms比较好),已知用户打字够快且有时会连续输入大段文本,请问是否该每次清理计时器呢?
@gitwd

This comment has been minimized.

gitwd commented Dec 30, 2016

@joesonw 基础方法确实有隐患,如果传入的method是一个匿名函数,绑定到匿名函数的timer将不会被清理掉

@gdh1995

This comment has been minimized.

gdh1995 commented Dec 31, 2016

@gitwd 请问为什么匿名函数timer不会被清理?区别在哪里呢?

@luoliqiang luoliqiang referenced this issue Feb 9, 2017

Open

文章1 #1

@warjiang

This comment has been minimized.

warjiang commented Aug 4, 2017

最显而易见的是基础版每此触发事件都会取消定时器,然后重新设置定时器,而 underscore 中会在一定时间后才取消定时器,重新设置定时器

我认为你这儿说的有问题,setTimeout是不精准延时,debounce里面补充判断如果last在[0,wait)区间,则继续setTimeout一个wait-last的时间再执行函数,保证函数执行程序一定在延时了wait之后执行。

@zhongdeming428

This comment has been minimized.

zhongdeming428 commented Mar 5, 2018

@hanzichi 韩老师你好!我想请教一下一个问题。

在您所阅读的underscore源码中(1.8.3),假设有如下代码:

var a = _.debounce((a)=>{
      console.log(a);
  }, 5000);
  a(1);
  a(2);
  a(3);
  a(4);
  a(5);
  a(6);

我的理解是:

a只有第一次被调用时才会进入later函数,但每次调用a都会更新时间戳Timestamp,而later内部会计算时间差,时间差不足时,递归调用later计算时间差,一旦时间差足够就触发传入的异步函数,最终执行的还是只有最后一个a函数。不知道正不正确?

我现在阅读的源码是最新版的,其中的_.debounce函数已经完全改进了,不再依赖于计算时间差,而是利用了JavaScript的异步机制:

code

假设有同样一段代码在最新版underscore中执行:

var a = _.debounce((a)=>{
      console.log(a);
  }, 5000);
  a(1);
  a(2);
  a(3);
  a(4);
  a(5);
  a(6);

我是否可以这样理解:

JavaScript优先执行完执行队列中的同步代码(以上所有代码)之后,再去执行事件队列中的异步代码。上方程序在执行所有同步代码时,每次a函数被调用,都会clearTimeout取消事件队列中的异步任务,导致前文a函数设置的异步任务被取消,直到最后一个a函数被执行时,才会开始计时,最终执行的也会是最后一个a函数。

两者相比较而言,后者使用变量更少,递归调用更少,数据计算更少;利用了JavaScript的异步机制,使用较少的代码较为自然的实现了去抖功能。

@zhongdeming428

This comment has been minimized.

zhongdeming428 commented Mar 5, 2018

@hanzichi 韩老师您的文中还有一处小小的笔误:

// 设置 wait seconds 后触发 later 方法
    // 无论是否 callNow(如果是 callNow,也进入 later 方法,去 later 方法中判断是否执行相应回调函数)
    // 在某一段的连续触发中,只会在第一次触发时进入这个 if 分支中
    if (!timeout)
      // 设置了 timeout,所以以后不会进入这个 if 分支了
      timeout = setTimeout(later, wait);

第一行注释中,wait seconds是否应该改为wait milliseconds?
冒昧啦!

@sqfbeijing

This comment has been minimized.

sqfbeijing commented Mar 10, 2018

@gdh1995 请问为什么匿名函数timer不会被清理?区别在哪里呢?
/分割线/
因为每次执行throttle 都会创建一个新的匿名函数, 匿名函数身上没有tId.

@Trendymen

This comment has been minimized.

Trendymen commented May 29, 2018

underscore版虽然不用每次触发时都清除计时器,但是每次触发时也使用Date对象重新生成了一个时间戳呀。

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