通过例子来解释防抖和节流(译)

前言

通过例子来解释防抖和节流(译)。

原文地址:Debouncing and Throttling Explained Through Examples

找到这篇文章的契机是看 Lodash 文档的时候看到的,觉得不错,就写写翻译。

正文

本文来自一位伦敦的前端工程师 David Corbacho 。在之前我们已经讨论过这个话题了,但是这次 David 会通过交互的例子来帮助你深入理解这些概念,使其变得简单清晰。

Debounce(防抖)和 Throttle(节流)是两个相似的技术(其实是不同的),他们用来控制一个函数随时间的移动而允许执行的次数。

当我们对一个 DOM 事件绑定一个函数的时候,使用一个具有防抖或者节流功能版本的函数是特别有用的。为什么?因为我们在事件和函数的执行间增加了一个控制层。记住,我们不是控制抛出 DOM 事件的频率,这和前面提到的方式是各不相同的。

举个例子,比如 scroll(滚动)事件,如下:

当通过使用触控板,滚轮或者拖动滚动条来滚动的时候,每秒会触发大约 30 次事件。但在我的测试中,在智能手机中缓慢地滚动会触发多达 100 次事件。你的滚动处理程序能很好地应对这些不同速率的滚动吗?

在 2011 年,Twitter 的 web 站点上出现了一个问题:当在 Twitter 页面内不断向下滚动时,页面会变慢,丢失响应。 John Resig 发布了一篇关于该问题的文章,解释了直接给滚动事件绑定一个昂贵(耗时长)的函数是多么糟糕的一种方式。

John 在当时(5年前,PS:这篇文章在 2016 年发布, 5 年前也就是 2011 年)提出了一个建议的解决方案,通过在 onscroll 事件的外部启动一个 250 毫秒执行一次的定时器。这种方式下事件的处理程序不会和事件耦合。通过这种简单的技术,我们就可以避免影响用户的体验。

如今,处理事件的方式略微复杂了一些。接下来我会介绍 Debounce(防抖)、 Throttle(节流)和 requestAnimationFrame 函数,同时我们会通过相应的用例来说明。

Debounce(防抖)

Debounce(防抖)技术允许我们将多个顺序调用“分组”为一个调用。

想象一下你现在正在电梯内,电梯门开始关闭,突然另一个人想要进来,这时电梯并不会开始楼层的移动,而是打开电梯门,现在,又突然有一个人想要进来,电梯会延后执行楼层移动的过程,虽然楼层移动的过程被延迟了,但却很好地利用了资源。

可以使用下面的例子来测试,在顶部的按钮内点击或者移动鼠标:

你会看到一个防抖事件是如何来表示连续快速的事件的。但如果一个事件触发的间隔很大,那么防抖就不会发生。

leading 前置执行(或者叫立即执行)

防抖事件会在触发函数执行前进行等待,直到事件触发地不是那么快的时候再执行,这种方式可能会让你觉得恼火。为什么不立即触发函数的执行过程,这样不就和原始的,没有防抖的执行过程具有一致的行为?只是在一段快速的触发后不再执行事件。

完全没问题,这是可以实现,下面是一个有着 leading 标志的例子:

在 underscore 库中,该配置的参数名为 immediate 而不是 leading

可以自己尝试一下:

Debounce(防抖)的实现

我第一次看到防抖在 JavaScript 中的实现是 2009 年 John Hann 的发布的帖子中(这个“防抖”的术语也是他创造的)。

之后很快, Ben Alman 写了一个 JQuery 的插件(已不再维护),一年之后, Jeremy Ashkenas 把防抖加入到了 underscore 库中。后来 underscore 的替代品 Lodash 中也添加了这个实现。

这三个实现在内部有些许的不同,但他们暴露的接口几乎是完全一样的。

曾经有一段时间, underscore 采用了 Lodash 的 debouncethrottle 的实现。2013 年的时候我发现 _.debounce 函数有一个 bug 。至此,两者的实现就开始区分开了。

Lodash 为 _.debounce_.throttle 添加了许多的特性。原始的 immediate 标志替换成了 leadingtrailing 选项。你可以选择一个或者两个都使用。默认情况下,只会开启后置执行(trailing edge)。

新的 maxWait 选项(目前只有 Lodash 有这个选项)不在本文的提及范内,但是它却是非常有用的。实际上, throttle (节流)函数是通过使用了 maxWait 参数的 _.debounce 来定义的,你可以在 Lodash 的源码中查看它。

Debounce(防抖)例子

resize 事件的例子

当改变一个浏览器窗口的大小的时候,拖动窗口边缘的缩放句柄会抛出很多的 resize 事件。

可以通过下面的例子来查看:

正如你所看到的,我们为 resize 事件使用了默认的 trailing 参数,因为我们只关心在用户停止缩放窗口后的最后的值。

带有 Ajax 请求的自动完成表单的 keypress 事件的例子

当用户处于键盘键入的状态的时候,为什么要每隔 50ms 去发送一次 Ajax 数据呢? _.debounce 可以帮我避免额外的工作,并且只在用户结束键盘键入之后去发送请求。

在下面的例子中,使用 leading (前置执行)标志是没有意义的。我们只是希望等待直到键入最后一个字母。

还有一个相似的用例,对于验证用户的输入,我们需要等待直到用户结束键盘键入,然后去展示类似“你的密码太短”之类的消息。

如何使用 debounce 和 throttle 以及常见的陷阱

构建你自己的或者从其他各种博客中复制的 debouncethrottle 函数,这种方式看起来非常吸引人。我的推荐是直接使用 underscore 库或者 Lodash 库。如果你只是需要 _.debounce_.throttle 函数,你可以使用 Lodash 自定义的构建流程来输出一个自定义的压缩过后只有 2KB 的库。使用如下的简单命令来构建:

1
2
npm i -g lodash-cli
lodash include = debounce, throttle

即大多数时候配合 webpack , browserify , rollup 构建工具来使用诸如 lodash/throttle 和 lodash/debounce 或者 lodash.throttle 和 lodash.debounce 这样模块化的方式。。

调用 _.debounce 函数可能很容易掉入一个常见的陷阱 —— 多次调用:

1
2
3
4
5
6
7
// 错误
$(window).on('scroll', function() {
_.debounce(doSomething, 300);
});

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

用一个变量来存放防抖的函数可以让我们调用其 cancel 方法,在 Lodash 和 underscore 中都包含这个特性,如果你需要的话,可以使用它。

1
2
3
4
5
var debounced_version = _.debounce(doSomething, 200);
$(window).on('scroll', debounced_version);

// 如果你需要这么操作
debounced_version.cancel();

Throttle(节流)

通过使用 _.throttle ,我们可以让函数在每 X 毫秒内只执行一次。

节流和防抖的不同之处就是节流保证函数至少在 X 毫秒内执行一次。

和防抖相同, Ben 编写的插件, underscore 库, Lodash 库也实现了这个特性。

Throttle(节流)例子

无限滚动例子

一个相当常见的例子。用户在可以无线滚动的页面上向下滚动。你需要检测用户到底部的距离。如果用户靠近底部了,我们应该通过 Ajax 来请求更多的内容,然后把这些内容更新到页面上。

在这里使用我们“亲爱”的 _.debounce 函数带来的帮助不大。它只会在用户停止滚动之后触发。而我们需要的是在用户到达底部之前就开始获取内容数据。

使用 _.throttle 可以保证我们时刻检测我们离底部的距离。

requestAnimationFrame(rAF)

requestAnimationFrame 是另一种限制函数执行频率的方法。

它可以当作一个 _.throttle(dosomething, 16) 的节流函数,但是由于它是一个浏览器的原生 API ,可用性上会更好。

考虑如下的优点或者缺点,我们可以使用 rAF 作为节流函数的一个可选替代方案。

优点

  • 保持 60 帧(即每 16 毫秒 1 帧),但内部会以最好的时机来执行渲染。
  • API 相当的简单并且符合标准,在未来基本不改变,这意味着更少的维护。

缺点

  • rAF 的启动和取消需要我们手动管理,不像 _.debounce 或者 _.throttle 在内部已经处理好了。
  • 如果浏览器标签处于非活动状态, rAF 不会执行。尽管对于滚动,鼠标操作或者键盘操作来说,这并不会造成什么问题。
  • 尽管所有的现代浏览器都提供了 rAF 函数,但 IE9 ,Opera Mini 和其他老旧的安卓浏览器仍然不支持 rAF ,你可能还是需要一个垫片
  • node 中并不支持 rAF 。所以你无法在服务器中通过它来节流文件系统的事件。

根据我的经验,如果函数跟“绘制”或者改变属性来触发动画的话,我会使用 requestAnimationFrame ,即在所有涉及元素位置重计算的地方使用它。

对于发起 Ajax 请求,是否添加或者删除一个类(可能会触发一个 css 动画)的情况下,我会使用 _.debounce 或者 _.throttle ,相比 rAF ,你可以设置更低的执行频率(比如 200 毫秒,而不是 16 毫秒)。

你可能会想在 underscore 或者 Lodash 中实现 rAF ,不过这两者都拒绝了这个要求,因为这是一个专门的函数,而且很容易调用它。

rAF 例子

对于 rAF ,我只介绍这一个例子,即在滚动中使用 requestAnimationFrame ,这里例子受到 Paul Lewis 文章的启发,在这篇帖子中解释了该例子每一步的逻辑。

我把他和 _.throttle 放一起来作比较。下面的例子中可以得到相似的性能,但某些复杂的场景中 rAF 可能会得到更好的结果。

我在 headroom.js 库中看到关于该技术的一个更高级的例子,其中的逻辑被解耦然后封装到对象内部。

结论

使用 debouncethrottlerequestAnimationFrame 来优化你的事件处理程序。每个技术都有些许的不同,但他们三个都很有作用并且彼此间相辅相成。

总之:

  • debounce:将突发的事件(比如键盘点击)分组为一个事件。
  • throttle:保证每 X 毫秒内执行一次。比如每 200 毫秒检查滚动的位置,以此来触发一个 css 动画。
  • requestAnimationFrame:可选的 throttle 的一个替代。当你的函数重新计算以及重新渲染屏幕上的元素时,你需要保证平滑的变化和动画效果。请注意: IE9 下不支持该 API 。