【译】以案例阐述 Debounce 和 Throttle
JChehe opened this issue · 0 comments
原文:Debouncing and Throttling Explained Through Examples
Debounce 和 Throttle 两者很类似(但不同!),均用于控制函数在一定时间范围内的执行频率。
将 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 技术让多次序列调用“结合”为一次。
假如你在电梯里,门开始关闭,突然有人想进来。此时,电梯不会开始执行改变楼层的功能,门再次打开。当再有另一个进来则会重复这个步骤。尽管电梯延迟了上下移动的行为,但却优化了电梯资源。
亲自尝试一下吧,点击或在按钮上移动:
See the Pen Debounce. Trailing by Corbacho (@dcorb) on CodePen.
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>你可以看到快速连续触发的事件是如何结合为一个的 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
标识被替换成 leading
和 trailing
可选项。该两个选项可开启一项或同时开启。默认情况下,仅 trailing
开启。
新可选项 maxWait
(当时仅 Lodash 支持)并未在本文涵盖,但它十分有用。实际上,throttle 函数是通过 _.debounce
和 maxWait
实现的,详情可查看 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/throttle
和 lodash/debounce
或者 lodash.throttle
和 lodash.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 不支持。