滚动事件:requestAnimationFrame、requestIdleCallback和被动事件监听器的比较。

98
作为我们所知,通常建议去抖动滚动监听器以便在用户滚动时提高用户体验。
然而,我经常发现文章中有一些有影响力的人如Paul Lewis推荐使用requestAnimationFrame。然而,随着网络平台的快速发展,某些建议可能会随时间而过时。
我看到的问题是处理滚动事件有非常不同的用例,比如构建视差网站或处理无限滚动和分页。
我看到有3个主要工具可以在用户体验方面产生巨大的差异:

所以,我想知道,根据用例(我只有两个,但您可以提出其他用例),现在应该使用什么样的工具来获得非常好的滚动体验?

更准确地说,我的主要问题与无限滚动视图和分页有关(通常不必触发视觉动画,但我们希望有良好的滚动体验),将requestAnimationFrame替换为requestIdleCallback+被动滚动事件处理程序组合是否更好?我还想知道何时使用requestIdleCallback调用API或处理API响应,以使滚动效果更好,或者这是浏览器可能已经为我们处理的东西?


3
我喜欢这个问题,但我担心如果没有一些代码片段,大多数人会害怕开始一个主观的辩论......你有时间提供一些例子吗? - deblocker
@op,你能提供一个示例片段来演示你想要的滚动功能吗?这样人们就可以在答案中使用它。如果可以的话,我可能会考虑设置悬赏。现在这个问题太广泛了。 - Tschallacka
@Tschallacka 如上所述,我不是特别寻找一个用例,而是想知道如何根据每个用例做出决策。你可以想象的最简单和常见的用例可能是一些类似于Instagram、Twitter或Facebook的无限滚动视图。 - Sebastien Lorber
@SebastienLorber,这使得你的问题变得非常广泛,因为不同的浏览器都有不同的实现和优化。如果你的问题没有具体化,它可能会因为过于宽泛而被关闭。 - Tschallacka
1个回答

265
虽然这个问题有点老了,但我还是想回答一下,因为我经常看到很多脚本滥用这些技术。
总的来说,你提到的工具(rAF、rIC和被动监听器)都是非常好的工具,并且不会很快消失。但你必须知道为什么要使用它们。
在开始之前,如果你生成了与滚动同步/滚动链接效果相关的特效,比如视差效果/粘性元素,使用rIC、setTimeout进行节流是没有意义的,因为你希望立即做出反应。

requestAnimationFrame

rAF在浏览器计算文档的新样式和布局之前,提供了帧生命周期内的一个时间点。这就是为什么它非常适合用于动画的原因。首先,它不会比浏览器计算布局的频率更频繁或更少频繁(正确的频率)。其次,它在浏览器计算布局之前被调用(正确的时机)。事实上,对于任何布局更改(DOM或CSSOM更改),使用rAF都是有意义的。与浏览器中其他与布局渲染相关的内容一样,rAFV-SYNC同步。

使用rAF进行节流/防抖

Paul Lewis的默认示例如下:

var scheduledAnimationFrame;
function readAndUpdatePage(){
  console.log('read and update');
  scheduledAnimationFrame = false;
}

function onScroll (evt) {

  // Store the scroll value for laterz.
  lastScrollY = window.scrollY;

  // Prevent multiple rAF callbacks.
  if (scheduledAnimationFrame){
    return;
  }

  scheduledAnimationFrame = true;
  requestAnimationFrame(readAndUpdatePage);
}

window.addEventListener('scroll', onScroll);

这个模式在实践中很常用/复制,尽管它在实际中几乎没有意义。 (我在想为什么没有开发者看到这个明显的问题。)从理论上讲,将所有内容至少限制到rAF是有道理的,因为请求浏览器进行布局更改的频率不应该超过浏览器渲染布局的频率。
然而,每次浏览器渲染滚动位置变化时,都会触发scroll事件。这意味着scroll事件与页面的渲染同步。这与rAF提供的功能完全相同。这意味着通过已经被定义为相同事物进行限制是没有任何意义的。
实际上,您可以通过添加console.log来验证我刚才说的话,并检查这个模式如何“防止多个rAF回调”(答案是没有,否则这将是浏览器的错误)。
  // Prevent multiple rAF callbacks.
  if (scheduledAnimationFrame){
    console.log('prevented rAF callback');
    return;
  }

如您所见,这段代码从未被执行过,它只是死代码。
但是有一个非常相似的模式,出于不同的原因而有意义。它看起来像这样:
//declare box, element, pos
function writeLayout(){
    element.classList.add('is-foo');
}

window.addEventListener('scroll', ()=> {
    box = element.getBoundingClientRect();

    if(box.top > pos){
        requestAnimationFrame(writeLayout);
    }
});

使用这种模式,您可以成功地减少甚至消除布局抖动。其思想很简单:在滚动监听器中,您读取布局并决定是否需要修改DOM,然后使用rAF调用修改DOM的函数。为什么这有帮助呢?rAF确保您将布局无效化(在帧末)。这意味着同一帧内调用的任何其他代码都可以在有效的布局上运行,并且可以使用超快的布局读取方法进行操作。
事实上,这种模式非常好,我建议使用以下辅助方法(使用ES5编写):
/**
 * From https://dev59.com/yVgR5IYBdhLWcg3wGaEK#44779316
 *
 * @param {Function} fn Callback function
 * @param {Boolean|undefined} [throttle] Optionally throttle callback
 * @return {Function} Bound function
 *
 * @example
 * //generate rAFed function
 * jQuery.fn.addClassRaf = bindRaf(jQuery.fn.addClass);
 *
 * //use rAFed function
 * $('div').addClassRaf('is-stuck');
 */
function bindRaf(fn, throttle) {
  var isRunning;
  var that;
  var args;

  var run = function() {
    isRunning = false;
    fn.apply(that, args);
  };

  return function() {
    that = this;
    args = arguments;

    if (isRunning && throttle) {
      return;
    }

    isRunning = true;
    requestAnimationFrame(run);
  };
}

requestIdleCallback

这个API类似于rAF,但提供了完全不同的功能。它在帧内部提供了一些空闲时间。(通常是在浏览器计算布局和完成绘制之后,但在垂直同步之前还有一些时间。)即使页面在用户视图中出现延迟,也可能存在浏览器处于空闲状态的帧。尽管rIC可以提供最多50毫秒的时间,但大部分时间你只有0.5到10毫秒来完成任务。由于rIC回调函数在帧生命周期的哪个阶段被调用,你不应该修改DOM(使用rAF来实现)。

最后,使用rIC来限制scroll监听器对于懒加载、无限滚动等功能非常有意义。对于这些类型的用户界面,甚至可以更加限制并在其前面添加一个setTimeout。(即等待100毫秒,然后再执行rIC

这里有debouncethrottle的实际例子。
还有{{link3:一篇关于rAF的文章},其中包含两个图表,可以帮助理解“帧生命周期”中的不同点。
被动事件监听器。
被动事件监听器的发明是为了提高滚动性能。现代浏览器将页面滚动(滚动渲染)从主线程移动到合成线程。(参见https://hacks.mozilla.org/2016/02/smoother-scrolling-in-firefox-46-with-apz/
但是,有些事件会产生滚动,可以通过脚本来阻止(这在主线程中发生,因此可能会逆转性能改进)。
这意味着一旦绑定了其中一个事件监听器,浏览器就必须等待这些监听器执行完毕,然后才能计算滚动。这些事件主要包括touchstarttouchmovetouchendwheel,理论上还包括keypresskeydown。而scroll事件本身不是这些事件之一。scroll事件没有默认的可以被脚本阻止的动作。
这意味着如果你在touchstarttouchmovetouchend和/或wheel事件中不使用preventDefault,请始终使用被动事件监听器,那么一切都应该没问题。
如果你使用了preventDefault,请检查是否可以用CSS的touch-action属性替代它,或者至少在DOM树中降低它的优先级(例如对于这些事件不进行事件委托)。对于wheel事件监听器,你可能可以在mouseenter / mouseleave上绑定/解绑它们。
对于其他任何事件:使用被动事件监听器来提高性能是没有意义的。最重要的是要注意:scroll事件无法被取消,因此使用被动事件监听器来处理scroll事件从来都没有意义。 对于无限滚动视图,你不需要touchmove,只需要scroll,所以被动事件监听器甚至不适用。

总结

回答你的问题:

  • 对于懒加载和无限滚动,使用setTimeout + requestIdleCallback的组合来处理事件监听器,并使用rAF来处理任何布局写入(DOM变动)。
  • 对于即时效果,仍然使用rAF来处理任何布局写入(DOM变动)。

33
哇,这是一个非常棒的回答。也许它本应该成为一篇博客文章。 - powerbuoy
1
谢谢,非常好的答案。我对“每次浏览器渲染滚动位置更改时都会触发滚动事件”这一点不是完全确定:对我来说,很可能rAF回调总是在下一个滚动事件之前被触发,因此不会产生日志。 - Sebastien Lorber
4
我认为这种模式经常存在是因为它可以在MDN上找到:MDN:scroll, Scroll optimization with window.requestAnimationFrame,而MDN通常被认为是可靠的来源。如果你的说法是正确的,那么应该在MDN页面上提出修改建议。 - t.niese
9
MDN页面实际上指出,这种优化通常是“不必要的”,因为rAF和Scroll以“大约相同的速率”触发。 - blackmamba
1
我正在与同事辩论是否将事件处理程序设置为被动会影响其调用速度(即影响处理程序的“优先级”)。我认为它不会。我的理解正确吗? - sming
显示剩余7条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接