lessfish / underscore-analysis

【NO LONGER UPDATE】underscore-1.8.3.js 源码解读 & 系列文章(完)

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

underscore 函数去抖的实现

lessfish opened this issue · comments

前文 我们对 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)

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

@joesonw 请教下 side effect 具体是?

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

commented

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

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

commented

哦哦,我弄混了

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

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

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

commented

@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 隔离,最近返回这个函数。结果就会和原来的效果是一样的。

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

commented

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);

// 这里的 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/

话说您用的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比较好),已知用户打字够快且有时会连续输入大段文本,请问是否该每次清理计时器呢?
commented

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

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

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

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

@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的异步机制,使用较少的代码较为自然的实现了去抖功能。

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

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

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

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

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