【译】以案例阐述 Debounce 和 Throttle

@JChehe 2018-11-06 08:16:51发表于 JChehe/blog

原文:Debouncing and Throttling Explained Through Examples

DebounceThrottle 两者很类似(但不同!),均用于控制函数在一定时间范围内的执行频率。

将 debounce 或 throttle 后的函数用于 DOM 事件绑定是非常有用的。为什么?因为这让我们在事件和函数调用之间拥有了控制权。毕竟我们不能控制 DOM 事件的触发频率,却可以控制回调函数的执行频率。

例如,以下是 scroll 事件:

See the Pen Scroll events counter by Corbacho (@dcorb) on CodePen.

<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

当通过触摸板、鼠标滚轮或拖拽滚动条时,事件在 1 秒内的触发次数能轻松达到 30 次。智能手机就更甚了,在我们的测试中,缓慢滚动也能在 1 秒内触发事件次数到 100 次。而你的滚动回调函数是否已对此执行频率做好准备呢?

在 2011 年,Twitter 网站出现了一个问题:当往下滚动信息流时,网站的响应速度会变慢,甚至是拒绝响应。John Resig 写了一篇 关于该问题的文章,其阐述了直接为 scroll 事件绑定耗时函数的严重性。

John 的建议(五年前)是:onScroll 事件的回调函数应该每 250ms 执行一次。这样回调函数就不会直接耦合到事件。使用这种简单的技术就可以避免破坏用户体验。

如今,处理事件的方式需要变得更复杂一些。接下来,我会结合案例向大家介绍 Debounce、Throttle 和 requestAnimationFrame。

Debounce

Debounce 技术让多次序列调用“结合”为一次。

debounce

假如你在电梯里,门开始关闭,突然有人想进来。此时,电梯不会开始执行改变楼层的功能,门再次打开。当再有另一个进来则会重复这个步骤。尽管电梯延迟了上下移动的行为,但却优化了电梯资源。

亲自尝试一下吧,点击或在按钮上移动:

See the Pen Debounce. Trailing by Corbacho (@dcorb) on CodePen.

<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

你可以看到快速连续触发的事件是如何结合为一个的 debounce 事件呈现。但如果事件的触发间隔较大,则呈现不出 debounce 的效果。

提前(或“立刻”)

提前执行 debounce 的案例
提前(或称为立刻)执行 debounce 的案例

在 underscore.js,该选项叫 immediate 而不是 leading

亲自尝试一下:

See the Pen Debounce. Leading by Corbacho (@dcorb) on CodePen.

<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

Debounce 的实现

我第一次看到 debounce 的 JavaScript 实现是在 2009 年的 John Hann 文章(他也是该术语的创造者)。

不久之后,Ben Alman 开发了一个 jQuery 插件(不再维护)。一年后,Jeremy Ashkenas 将 其添加到了 underscore.js。而 underscore 的替代方案 Lodash 也随后添加。

这 3 种实现均有一些不同,但接口几乎一致。

有一段时间,underscore 采用了 Lodash 的 debounce/throttle 的实现,但随后我在 2013 年发现了 _.debounce 的一个 Bug。从那时起,两者就分开各自实现了。

Lodash 为 _.debounce_.throttle 函数 添加了更多特性。原来的 immediate 标识被替换成 leadingtrailing 可选项。该两个选项可开启一项或同时开启。默认情况下,仅 trailing 开启。

新可选项 maxWait(当时仅 Lodash 支持)并未在本文涵盖,但它十分有用。实际上,throttle 函数是通过 _.debouncemaxWait 实现的,详情可查看 Lodash 源码

Debounce 案例

Resize 案例

当拖拽改变浏览器窗口尺寸时,会触发非常多次 resize 事件。

如以下案例:

See the Pen Debounce Resize Event Example by Corbacho (@dcorb) on CodePen.

<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

如你所见,我们为 resize 事件使用了默认的 trailing 选项。毕竟,我们只对最后的值感兴趣(用户停止调整浏览器尺寸)。

用 Ajax 自动完成键入

有什么理由在用户仍在输入时每隔 50ms 发起 Ajax 请求呢?_.debounce 能帮助我们避免额外的操作,仅在用户停止输入时发起请求。

对于这个案例,leading 标识是没意义的,毕竟我们只想等到输入的最后一个字母结束。

See the Pen Debouncing keystrokes Example by Corbacho (@dcorb) on CodePen.

<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

类似的案例是等到用户停止键入时进行校验,然后弹出诸如“您的密码太短”的消息提示。

如何使用 debounce 和 throttle 并避免常见陷阱

编写属于自己的 debounce/throttle 函数看似很诱人,或者随便从博客文章中复制使用。而我个人的推荐是直接使用 underscore 或 Lodash。如果你仅需要 _.debounce_.throttle 函数,那么可以使用 Lodash 的自定义构建方式生成 2KB 的库。通过以下简单的命令行构建:

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

结合 webpack/browserify/rollup 构建工具,引入相应模块: loadsh/throttlelodash/debounce 或者 lodash.throttlelodash.debounce

一个常见的陷阱是多次调用 _.debounce 函数:

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

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

将 debounce 后的函数赋值到一个变量,即可在需要的时候调用私有方法 debounced_version.cancel()。这适用于 lodash 和 underscore.js。

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

// 需要的时候
debounced_version.cancel();

Throttle

通过使用 _.throttle,我们可以避免函数的执行频率过高(即每 X 秒大于一次)。

这与 debounce 的最大区别是:throttle 能保证函数能定期执行。即 X 毫秒内至少一次,而对于 debounce,只要一直保持高频繁触发事件,那么回调函数就一直不会被执行。

与 debounce 相同的是,throttle 技术均在 Ben 的插件、underscore.js 和 lodash 上提供。

Throttle 案例

无限滚动

这是一个十分常见的案例。用户在可无限滚动的页面中往下滚动时,你需要检测用户当前距离底部的距离。如果接近底部,那么就应该通过 Ajax 请求更多的内容,并将内容插入到页面中。

对于这种情况,_.debounce 并不能帮上忙,这是因为它只能等到用户停止滚动时才能调用回调函数。而我们这里需要在用户到达底部前就开始获取内容了。

通过 _.throttle,我们能保证不间断地检查用户到底部的距离。

See the Pen Infinite scrolling throttled by Corbacho (@dcorb) on CodePen.

<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

requestAnimationFrame (raF)

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

它可以被看作为 _.throttle(dosomething, 16)。但其拥有更高的精确性,毕竟它是旨在提供更高精度的浏览器原生 API。

综合其优缺点,我们可以使用 rAF API 作为 throttle 的替代方案:

优点:

  • 目标是达到 60fps(每帧 16ms),但浏览器内部会安排好渲染的最佳时机。
  • 相当简单的标准 API,未来不会更改,减少维护成本。

缺点:

  • .debounce.throttle 不同的是,我们只能对 rAF 发出 启动/取消的指令,但其终归浏览器内部管理。
  • 如果浏览器标签不处于激活状态,那它将不会执行。尽管这对滚动、鼠标和键盘事件来说并不重要。
  • 尽管所有现代浏览器都提供 rAF,但 IE9、Opera Mini 和老旧的 Android 并不支持。在今天仍 可能需要 polyfill
  • Node.js 不支持 rAF,因此不能在服务器对文件系统事件 进行 throttle 优化。

根据经验,如果 JavaScript 函数是用于“绘制”或直接过渡动画属性,那么就用 requestAnimationFrame。总之,在涉及重新计算元素位置的时候就该使用它。

对于 Ajax 请求或决定是否添加/删除类名(用于触发 CSS 动画)时,我会偏向于 _.debounce_.throttle,毕竟能设置更低的执行频率(比如 200ms,而不是 16ms)。

你可能会想到:rAF 应该集成到 underscore 或 lodash 中,但他们均拒绝了这个想法。毕竟它更多是作为一个特定案例,并且很容易被直接调用。

rAF 案例

我仅讨论以下这个案例:在滚动时使用 requestAnimationframe。这个案例的灵感来自 Paul Lewis 的文章,这篇文章细致地解释了这个案例的逻辑。

我将 rAF 与 16ms 的 _.throttle 并排比较。尽管性能看似相近,但 rAF 能在更复杂的场景中为你提供更佳的性能。

我见过使用该技术的一个更高级的例子是:headroom.js 库。它的实现 逻辑被解耦 包装在一个对象中。

总结

使用 debounce、throttle 和 requestAnimationFrame 能优化事件回调函数。尽管三种技术略有不同,但它们都十分有用并相互补充。

总的来说:

  • debounce:将一堆突发事件(如键入)结合为一个事件。
  • throttle:保证每 X 毫秒执行一次固定流程。比如滚动时每 200ms 检查滚动位置来决定是否触发 CSS 动画。
  • requestAnimationFrame:throttle 的替代方案。当函数涉及重新计算或渲染元素时要保证动画和更改的流畅性,那么就适合使用它。注意:IE9 不支持。