10月31日
浅谈 Underscore.js 中 _.throttle 和 _.debounce 的差异

Underscore.js是一个很精干的库,压缩后只有5.2KB。它提供了几十种函数式编程的方法,弥补了标准库的不足,大大方便了JavaScript的编程。

本文仅探讨Underscore.js的两个函数方法 _.throttle_.debounce 的原理、效果和用途。

通常的函数(或方法)调用过程分为三个部分:请求、执行和响应。(文中“请求”与“调用”同义,“响应”与“返回”同义,为了更好的表述,刻意采用请求和响应的说法。)

某些场景下,比如响应鼠标移动或者窗口大小调整的事件,触发频率比较高。若稍处理函数微复杂,需要较多的运算执行时间,响应速度跟不上触发频率,往往会出现延迟,导致假死或者卡顿感。

在运算资源不够的时候,最直观的解决办法就是升级硬件,诚然通过购买更好的硬件可以解决部分问题,但是也需要为此付出高额的成本。特别是客户端和服务器模式,要求客户端统一升级硬件基本不可能。

在资源有限的前提下,处理函数无法即时响应高频调用。退而求其次,只响应部分请求是否可行呢?某些场景下的密集性请求,具备很强的同质和连续性。比如说,鼠标移动的轨迹参数。响应越及时效果越平滑,但是如果响应速度跟不上时,反而会出现卡顿感,如果适当的丢弃一些请求效果更流畅。

throttledebounce 是解决请求和响应速度不匹配问题的两个方案。二者的差异在于选择不同的策略。

电梯超时

想象每天上班大厦底下的电梯。把电梯完成一次运送,类比为一次函数的执行和响应。假设电梯有两种运行策略 throttledebounce ,超时设定为15秒,不考虑容量限制。

  • throttle 策略的电梯。保证如果电梯第一个人进来后,15秒后准时运送一次,不等待。如果没有人,则待机。
  • debounce 策略的电梯。如果电梯里有人进来,等待15秒。如果又人进来,15秒等待重新计时,直到15秒超时,开始运送。

使用示例

_.throttle 使用示例

function log( event ) {
  console.log( $(window).scrollTop(), event.timeStamp );
};

// 控制台记录窗口滚动事件,触发频率比你想象的要快
$(window).scroll( log );

// 控制台记录窗口滚动事件,每250ms最多触发一次
$(window).scroll( _.throttle( log, 250 ) );

_.debounce 使用示例

function ajax_lookup( event ) {
  // 对输入的内容$(this).val()执行 Ajax 查询
};

// 字符输入的频率比你预想的要快,Ajax 请求来不及回复。
$('input:text').keyup( ajax_lookup );

// 当用户停顿250毫秒以后才开始查找
$('input:text').keyup( _.debounce( ajax_lookup, 250 ) );

underscore源码注解

让我们来读读源码,探其究竟。基于开发版本(1.7.0)的源码,加上了一些注释以帮助理解。

_.throttle方法源码

/**
 * 频率控制 返回函数连续调用时,func 执行频率限定为 次 / wait
 * 
 * @param  {function}   func      传入函数
 * @param  {number}     wait      表示时间窗口的间隔
 * @param  {object}     options   如果想忽略开始边界上的调用,传入{leading: false}。
 *                                如果想忽略结尾边界上的调用,传入{trailing: false}
 * @return {function}             返回客户调用函数   
 */
_.throttle = function(func, wait, options) {
  var context, args, result;
  var timeout = null;
  // 上次执行时间点
  var previous = 0;
  if (!options) options = {};
  // 延迟执行函数
  var later = function() {
    // 若设定了开始边界不执行选项,上次执行时间始终为0
    previous = options.leading === false ? 0 : _.now();
    timeout = null;
    result = func.apply(context, args);
    if (!timeout) context = args = null;
  };
  return function() {
    var now = _.now();
    // 首次执行时,如果设定了开始边界不执行选项,将上次执行时间设定为当前时间。
    if (!previous && options.leading === false) previous = now;
    // 延迟执行时间间隔
    var remaining = wait - (now - previous);
    context = this;
    args = arguments;
    // 延迟时间间隔remaining小于等于0,表示上次执行至此所间隔时间已经超过一个时间窗口
    // remaining大于时间窗口wait,表示客户端系统时间被调整过
    if (remaining <= 0 || remaining > wait) {
      clearTimeout(timeout);
      timeout = null;
      previous = now;
      result = func.apply(context, args);
      if (!timeout) context = args = null;
    //如果延迟执行不存在,且没有设定结尾边界不执行选项
    } else if (!timeout && options.trailing !== false) {
      timeout = setTimeout(later, remaining);
    }
    return result;
  };
};

_.debounce方法源码

/**
 * 空闲控制 返回函数连续调用时,空闲时间必须大于或等于 wait,func 才会执行
 *
 * @param  {function} func        传入函数
 * @param  {number}   wait        表示时间窗口的间隔
 * @param  {boolean}  immediate   设置为ture时,调用触发于开始边界而不是结束边界
 * @return {function}             返回客户调用函数
 */
_.debounce = function(func, wait, immediate) {
  var timeout, args, context, timestamp, result;

  var later = function() {
    // 据上一次触发时间间隔
    var last = _.now() - timestamp;

    // 上次被包装函数被调用时间间隔last小于设定时间间隔wait
    if (last < wait && last > 0) {
      timeout = setTimeout(later, wait - last);
    } else {
      timeout = null;
      // 如果设定为immediate===true,因为开始边界已经调用过了此处无需调用
      if (!immediate) {
        result = func.apply(context, args);
        if (!timeout) context = args = null;
      }
    }
  };

  return function() {
    context = this;
    args = arguments;
    timestamp = _.now();
    var callNow = immediate && !timeout;
    // 如果延时不存在,重新设定延时
    if (!timeout) timeout = setTimeout(later, wait);
    if (callNow) {
      result = func.apply(context, args);
      context = args = null;
    }

    return result;
  };
};

可视化演示

Throtte & Debounce 可视化演示

示例中每一行都以30ms的速度绘制时间轴,第一行Mousemove Events是参考基准,以50ms每次的响应频率,在时间轴上输出循环可见ASCII码字符。

当鼠标进入左侧方型区域(mouseenter 事件)所有行开始绘制时间轴, 鼠标晃动(mousemove 事件)会在时间轴上绘制字符块,每个字符块表示事件被触发一次。为了展现延迟触发效果,相邻字符块的演示和文字是不同的。

顶部的两个按钮每100毫秒触发1次每200毫秒触发2次演示以固定频率匀速触发事件的效果。

使用场景

只要牵涉到连续事件或频率控制相关的应用都可以考虑到这两个函数,比如:

  • 游戏射击,keydown 事件
  • 文本输入、自动完成,keyup 事件
  • 鼠标移动,mousemove 事件
  • DOM 元素动态定位,window 对象的 resize 和 scroll 事件

前两者 debounce 和 throttle 都可以按需使用;后两者肯定是用 throttle 了。如果不做过滤处理,每秒种甚至会触发数十次相应的事件。尤其是 mousemove 事件,每移动一像素都可能触发一次事件。如果是在一个画布上做一个鼠标相关的应用,过滤事件处理是必须的,否则肯定会造成糟糕的体验。

参考阅读

  1. UNDERSCORE.JS
  2. 高阶函数 debounce 和 throttle
  3. jQuery throttle / debounce: Sometimes, less is more!
  4. Debounce and Throttle: a visual explanation

Vangie Du

将来的你,一定会感谢现在拼命努力的自己!

杜万763717

17条评论

好详细,长见识了~

szy0syz4 个月前回复

throttle:触发-上次动作执行时间〉大于限制时间->执行动作,记录执行时间
debounce:触发-记录触发时间-上次动作触发时间〉大于限制时间-执行动作

changme9 个月前回复

电梯的比喻很形象。。。

liuzhuang10 个月前回复

好文有用!赞!

liuanto1 年前回复

电梯的比喻很赞,一下就理解了。

IOriens1 年前回复

良心之作

jason19921 年前回复

不能再赞

Bolt2 年前回复
Tip top stfuf. I'll expect more now. http://hlanrni.com [url=http://oqqfkhzec.com]oqqfkhzec[/url] [link=http://yvjpqacw.com]yvjpqacw[/link]
匿名3 年前回复
I could watch Scirdnleh's List and still be happy after reading this.
匿名3 年前回复
I told my kids we'd play after I found what I nedede. Damnit. http://zkhsmclsdnu.com [url=http://bjvwohpecy.com]bjvwohpecy[/url] [link=http://iakosizwg.com]iakosizwg[/link]
匿名3 年前回复