weixin_39863161
weixin_39863161
2021-01-10 02:39

underscore 函数去抖的实现

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

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

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

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

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

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

 javascript
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 在其基础上进行了扩充,直接看代码,含大量注释:

 javascript
// 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

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

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

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

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

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

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

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

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

该提问来源于开源项目:lessfish/underscore-analysis

  • 点赞
  • 写回答
  • 关注问题
  • 收藏
  • 复制链接分享
  • 邀请回答

20条回答

  • weixin_39684960 weixin_39684960 3月前

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

    点赞 评论 复制链接分享
  • weixin_39800112 weixin_39800112 3月前

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

    点赞 评论 复制链接分享
  • weixin_39902345 weixin_39902345 3月前

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

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

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

    点赞 评论 复制链接分享
  • weixin_39902345 weixin_39902345 3月前

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

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

    点赞 评论 复制链接分享
  • weixin_39943799 weixin_39943799 3月前

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

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

    点赞 评论 复制链接分享
  • weixin_39884412 weixin_39884412 3月前

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

    点赞 评论 复制链接分享
  • weixin_39870700 weixin_39870700 3月前

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

    点赞 评论 复制链接分享
  • weixin_39884412 weixin_39884412 3月前

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

    点赞 评论 复制链接分享
  • weixin_39884412 weixin_39884412 3月前

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

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

     javascript
    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被提前释放:~~

     javascript
    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/~~

    点赞 评论 复制链接分享
  • weixin_39572972 weixin_39572972 3月前

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

     javascript
    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);
    
    点赞 评论 复制链接分享
  • weixin_39610722 weixin_39610722 3月前

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

    点赞 评论 复制链接分享
  • weixin_39572972 weixin_39572972 3月前

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

     javascript
    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 放入函数中,成为一个局部变量,所以上面的写法可以改写成下面的方式:

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

    点赞 评论 复制链接分享
  • weixin_39863161 weixin_39863161 3月前

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

    点赞 评论 复制链接分享
  • weixin_39915210 weixin_39915210 3月前

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

    点赞 评论 复制链接分享
  • weixin_39938935 weixin_39938935 3月前

    哦哦,我弄混了

    点赞 评论 复制链接分享
  • weixin_39863161 weixin_39863161 3月前

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

    点赞 评论 复制链接分享
  • weixin_39938935 weixin_39938935 3月前

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

    点赞 评论 复制链接分享
  • weixin_39610722 weixin_39610722 3月前

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

    点赞 评论 复制链接分享
  • weixin_39863161 weixin_39863161 3月前

    请教下 side effect 具体是?

    点赞 评论 复制链接分享
  • weixin_39610722 weixin_39610722 3月前

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

    点赞 评论 复制链接分享