2020年7月

原文地址:

Debouncing and Throttling Explained Through Examples | CSS-Tricks

本文中斜体部分为阅读时的个人理解说明,请注意区分。并且代码中使用的debounce函数和throtte函数均来自于lodash库。

Debounce(bounce意为弹跳,de前缀表示”否定“,所以被称为防抖)和Throttle(作名词讲意为”节流阀“,指控制油料进入发动机量大小的装置)是两种极其相似却又大不相同的技术,用于控制一个函数随着时间的流失执行多少次。

将业务函数的防抖版本或节流版本用在处理DOM事件上是非常有用的。为什么?因为这样可以在事件机制和业务函数执行之间进行控制。毕竟无法那些被动触发的DOM事件的评率不在我们的控制范围内。

举个例子:滚动事件

var i = 0;
var $counter = $('#counter');
$(document).ready(function(){
  $(document).on('scroll', function(){
    $counter.html(i);
    i++; 
  });
});

当使用触控板,鼠标滚轮或者拖拽滚动条进行滚动操作是,每秒将会触发30个滚动事件。但是在手机上,即使是缓慢的滚动(滑动屏幕),每秒也会触发多达100个事件。你的滚动处理函数能够应对这样的执行速率吗?

2011年,推特的网站上出现一个问题:当你不断滚动信息流,网页会变得越来越慢无法响应。John Resig在文章《a blog post about the problem》中阐释了,将一个执行代价很高的函数直接绑定到scroll事件,是一件多么愚蠢的做法。

在那篇文章中,John建议的解决方案是:在onScroll event以外使用一个每250ms执行一次的循环。这种方式实现handler和event的解耦合。通过这种简单的技术,可以避免给用户带来的糟糕体验。

最近又出现了一些稍微复杂一点的方式处理事件。让我来介绍一下Debounce,Throttle和rAF。我们还将研究具体的使用场景

Debounce

防抖技术允许我们将多个顺序的调用,聚合到单次调用。

https://i.loli.net/2020/07/28/DN6pv2uiQ1hGCxT.png

想象你在一个电梯里。电梯门开始关闭,突然另一个人想要进入电梯。电梯不能进行工作去别的楼层,电梯门重新打开。又有另一个人要上电梯,这样的事情还会发生。虽然电梯的行动(上下楼)被突然上人延迟了,但是明显优化了资源利用。

这是一个mousemove事件的例子:

https://codepen.io/dcorb/pen/KVxGqN

你能看到单个防抖事件,是如何代替快速发生的顺序事件的。如果事件发生的间隔事件,超过防抖设置的延迟,那么就不会进行(也没必要进行)防抖处理。

在进行下面的文章之前,要插入介绍一下lodash中debounce函数:

*_.debounce(func, [wait=0], [options={}])*

options的三个配置说明如下:

  • leading:默认false,指定在延迟开始前调用
  • maxWait:允许被延迟的最大值。也就是说抖动持续超过这个时间,函数还是要被触发的
  • trailing:默认true,指定在延迟结束后调用

Leading属性(或者“立即”)

你会发现令人头疼的一点,在触发函数执行之前,防抖事件总是等待,直到事件不再快速的重复发生。为何不立即触发让函数执行,让它的行为和非防抖handler一样?而是在快速的重复调用过程中不会触发,直到出现一个较长的时间间隔再触发。

你当然可以值么做!设置leading选项:

https://i.loli.net/2020/07/28/XKYeLf1H7qdMhBy.png

上面的例子可以理解成防抖处理的函数,在抖动开始时就执行一次,后续的抖动过程中不再执行。没有leading选项(默认trailing选项)时,就相当于抖动结束了后再执行。而抖动结束的判断条件是第二个参数决定的延迟。

https://codepen.io/dcorb/pen/GZWqNV

防抖的实现

我(原文作者)第一次看到JavaScript版本防抖的实现,是在2009年John Hann的博文中(原文已经找不到了),防抖的概念也是从这篇文章开始的。之后很快,Ben Alman开发了一个jQuery插件,再然后,Jeremy Ashkenas将其添加的underscore.js库中。再之后又被加入到lodash库中。这三种实现(jQuery插件,underscore.js和lodash)内部有细微的不同,但是对外暴露的接口几乎一致。

之前有一段时间underscore.js采纳了lodash的防抖/节流实现,但是在2013年,我发现了_.debounce的一个bug(这个bug也无从查证了),从那之后,两个库的实现方式彻底分开。

lodash的debounce函数和throttle函数增加了很多特性,比如leading属性和trailing属性联合取代了immediate属性,默认是trailing模式,但也可以设置成头尾都执行。另外增加了maxWait选项,很有用,实际上节流函数就是借助debounce+maxWait实现的。

源码分析单独写一篇文章,这里不做过多的介绍

防抖的应用场景

resize事件

当调整浏览器窗口尺寸时,会频繁触发resize事件,用于处理拖动过程。

$(window).on('resize', function(){
    display_info($left_panel);
  });
  
$(window).on('resize', _.debounce(function() {
  display_info($right_panel);
}, 400));

使用了默认的trailing模式,因为只关心resize的最终结果。从打印结果上不难发现,不使用防抖,会打印出很多拖拽过程中的尺寸信息,假如有一个业务逻辑需要根据尺寸重新布局,或者进行复杂的计算,一方面可能造成卡顿,一方面执行的结果可能只会展示一瞬间, 对用户来说也许并没有意义。

键入信息与异步请求自动补全

用户持续输入时,为何还要每50ms发送一次异步请求?防抖避免了输入过程中不必要的网络工作,当用户结束输入延迟一段时间后(认为是抖动结束)才请求数据。在这个业务中,使用leading模式将导致无法按照预期工作,因为我们关注的是用户输入结束后输入的内容,而不是输入的过程。

如何使用防抖和节流,以及常见错误

作者推荐使用lodash或者underscore.js库提供的相关工具函数,这里略过

常见的错误:重复调用debounce函数。debounce函数用于产生一个防抖版的handler,将这个防抖版的handler绑定到事件处理,而不是在事件处理中调用debounce。

// WRONG
$(window).on('scroll', function() {
   _.debounce(doSomething, 300); 
});

// RIGHT
$(window).on('scroll', _.debounce(doSomething, 200));

根据lodash的文档说明,debounce创建的函数带有cancel函数,可以用于取消防抖限制:

var debounced_version = _.debounce(doSomething, 200);
$(window).on('scroll', debounced_version);

// If you need it
debounced_version.cancel();

Throttle

使用节流技术,我们不允许我们的函数每X毫秒执行超过1次。节流和防抖的主要区别,是节流保证了函数是周期执行的。(同样是应对”抖动“场景,防抖意味着不抖动了在执行,忽略所有抖动过程;节流意味着通过自己定义一个频率,让无规律的抖动变成有规律的”抖动“,即可以关注抖动过程中的某些状态,也可以减少多余的工作量

节流的应用场景

无极滚动

无极滚动是一个很常见的场景。用户在你的页面上会不断的向下滚动。你需要检查用户滚动离底部的距离。如果用户滚动接近了底部,我们就需要请求更多的数据追加到页面上。这个时候防抖显然不那么合适。防抖只会在用户停止滚动后触发一次,但需求是在到达底部之前就要准备好更多数据。使用节流我们可以保证是在持续不断的检查到底部的距离。

// Very simple example.
// Probably you would want to use a 
// full-featured plugin like
// https://github.com/infinite-scroll/infinite-scroll/blob/master/jquery.infinitescroll.js
$(document).ready(function(){
  
  // Check every 300ms the scroll position
  $(document).on('scroll', _.throttle(function(){
    check_if_needs_more_content();
  }, 300));

  function check_if_needs_more_content() {     
    pixelsFromWindowBottomToBottom = 0 + $(document).height() - $(window).scrollTop() -$(window).height();
    
  // console.log($(document).height());
  // console.log($(window).scrollTop());
  // console.log($(window).height());
  //console.log(pixelsFromWindowBottomToBottom);
    
    
    if (pixelsFromWindowBottomToBottom < 200){
      // Here it would go an ajax request
      $('body').append($('.item').clone()); 
      
    }
  }

requestAnimationFrame (rAF)

requestAnimationFrame是另一种限制函数执行频率的方式。它可以被认为是_.throttle(dosomething, 16) 。因为它是浏览器为了更好的准确性而提供的原生的API,所以保真度更高。我们可以使用rAF作为节流方式,它具有以下优缺点:

优点

  • 针对浏览器60fps渲染频率,每一帧16ms,理想情况下是每一帧执行一次,但实际上浏览器还是会选择合适的时机执行。
  • 相当简单且标准的API,未来也不一定会有大的变更,可以减少维护量。

缺点

  • rAF的启停都必须手动处理,而防抖和节流都在内部通过定时器进行管理。
  • 如果浏览器标签页处于未激活状态,rAF是不会执行的。对于滚动或者鼠标移动等行为,这一点并不影响。
  • IE9,Opera Mini和老版本的安卓不提供该API。可能需要polyfill。
  • rAF仅限于浏览器环境,node.js无法使用,无法使用rAF来节流服务端文件系统事件。

根据经验,如果你的JavaScript函数是用于绘制或者动画,那么我推荐使用rAF,对于那些包含重新计算元素位置的操作都很适用。而为了进行Ajax请求,或者决定添加/移除样式名(这可能会引起CSS动画),我会考虑使用debounce和throttle,这样你可以设置一个比较低的执行频率。

rAF的应用场景

https://codepen.io/dcorb/pen/pgOKKw

通过一个滚动的例子,对比节流和rAF的效果。由于场景比较简单,所以感觉不出有什么差异,但是在复杂的场景下,可能rAF有更加精确地表现。

总结

使用防抖,节流和rAF都可以优化你的事件处理。每一个技术都有些许差异,但是他们都很有用,并且可以相互补充。

总而言之:

  • 防抖:将一系列突发的事件爆发(比如连击按键)聚合成一个单独的事件。
  • 节流:保证了一个周期为X毫秒的持续的执行流。比如每200ms检查一次滚动位置。
  • rAF:可选的节流方案。当你的函数涉及到重绘和渲染,且想要一定的流畅性或者动画,可以使用rAF。注意不支持IE9。

写在最后:当有人问你一个问题,你回答到一半又问了你一个问题,你停下来回答新的问题,到一半时又被问了第三个问题...每个问题可能都没有答完,这样你整个人的精神状态就可以被称为”抖动“。防抖,也就是拒绝抖动,那我可以在回答问题之前稍微等一下,看你会不会再问下一个问题,要是问了我就干脆不回答了,等到我发现你不会突然打断我了,我再回答问题。这样就不会出现话说一半的尴尬。节流,降低抖动的频率,一个问题都不回答好像也有一些不礼貌,那我就自己心里默念,每过10个数进行一次回答。简单而形象的理解,具体实现可能还有细节,至少这样不会搞得太混。Peace