滚动事件: requestAnimationFrame VS requestIdleCallback VS 被动事件侦听器

我们知道,通常建议去除滚动侦听器,这样用户在滚动时用户体验会更好。

然而,我经常发现像保罗刘易斯这样有影响力的人推荐使用 requestAnimationFrame图书馆物品。然而,随着 Web 平台的快速发展,随着时间的推移,一些建议可能会被弃用。

我看到的问题是,处理滚动事件的用例非常不同,比如建立视差网站,或者处理无限滚动和分页。

我认为有3个主要的工具可以改变用户体验:

所以,我想知道,每个用例(我只有2个,但你可以拿出其他的) ,什么样的工具,我应该使用现在有一个非常好的滚动体验?

更准确地说,我的主要问题将更多地涉及无限滚动视图和分页(通常不必触发视觉动画,但我们想要一个良好的滚动体验) ,是否更好地取代 requestAnimationFramerequestIdleCallback + 被动滚动事件处理程序的组合?我还想知道什么时候使用 requestIdleCallback来调用 API 或者处理 API 响应来让滚动执行得更好,或者浏览器可能已经为我们处理过了?

37005 次浏览

Although this question is a little bit older, I want to answer it because I often see scripts, where a lot of these techniques are misused.

In general all your asked tools (rAF, rIC and passive listeners) are great tools and won't vanish soon. But you have to know why to use them.

Before I start: In case you generate scroll synced/scroll linked effects like parallax effects/sticky elements, throttling using rIC, setTimeout doesn't make sense because you want to react immediately.

requestAnimationFrame

rAF gives you the point inside the frame life cycle right before the browser wants to calculate the new style and layout of the document. This is why it is perfect to use for animations. First it won't be called more often or less often than the browser calculates layout (right frequency). Second it is called right before the browser does calculate the layout (right timing). In fact using rAF for any layout changes (DOM or CSSOM changes) makes a lot of sense. rAF is synced with the V-SYNC as any other layout rendering related stuff in the browser.

using rAF for throttle/debounce

The default example of Paul Lewis looks like this:

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);

This pattern is very often used/copied, although it makes little till no sense in practice. (And I'm asking myself why no developer sees this obvious problem.) In general, theoretically it makes a lot of sense to throttle everything to at least the rAF, because it doesn't make sense to request layout changes from the browser more often than the browser renders the layout.

However the scroll event is triggered every time the browser renders a scroll position change. This means a scroll event is synchronized with the rendering of the page. Literally the same thing that rAF is giving you. This means it doesn't make any sense to throttle something by something, that is already throttled by the exact same thing per definition.

In practice you can check what I just said by adding a console.log and check how often this pattern "prevents multiple rAF callbacks" (answer is none, otherwise it would be a browser bug).

  // Prevent multiple rAF callbacks.
if (scheduledAnimationFrame){
console.log('prevented rAF callback');
return;
}

As you will see this code is never executed, it is simply dead code.

But there is a very similar pattern that make sense for a different reason. It looks like this:

//declare box, element, pos
function writeLayout(){
element.classList.add('is-foo');
}


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


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

With this pattern you can successfully reduce or even remove layout thrashing. The idea is simple: inside of your scroll listener you read layout and decide wether you need to modify the DOM and then you call the function that modifies the DOM using rAF. Why is this helpful? The rAF makes sure that you move your layout invalidation (at the ende of the frame). This means any other code that is called inside the same frame works on a valid layout and can operate with super fast layout read methods.

This pattern is in fact so great, that I would suggest the following helper method (written in ES5):

/**
* From https://stackoverflow.com/a/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

Is from the API similar to rAF but gives something totally different. It gives you some idle periods inside of a frame. (Normally the point after the browser has calculated layout and done paint, but there is still some time left until the v-sync happens.) Even if the page is laggy from the users view, there might be some frames, where the browser is idling. Although rIC can give you max. 50ms. Most of the time you only have between 0.5 and 10ms to fulfill your task. Due to the fact at which point in the frame life cycle rIC callbacks are called you should not alter the DOM (use rAF for this).

At the end it makes a lot of sense to throttle the scroll listener for lazyloading, infinite scrolling and such using rIC. For these kinds of user interfaces you can even throttle more and add a setTimeout in front of it. (so you do 100ms wait and then a rIC)

Live examples for debounce and throttle.)

Here is also an article about rAF, that includes two diagrams which might help to understand the different points inside of a "frame lifecycle".

Passive event listener

Passive event listeners were invented to improve scroll performance. Modern browsers moved page scrolling (scroll rendering) from the main thread to the composition thread. (see https://hacks.mozilla.org/2016/02/smoother-scrolling-in-firefox-46-with-apz/)

But there are events that produce scrolling, which can be prevented by script (which happens in the main thread and therefore can revert the performance improvement).

Which means as soon as one of these events listeners are bound, the browser has to wait for these listener to be executed before the browser can compute the scroll. These events are mainly touchstart, touchmove, touchend, wheel and in theory to some degree keypress and keydown. The scroll event itself is not one of these events. The scroll event has no default action, that can be prevented by script.

This means if you don't use preventDefault in your touchstart, touchmove, touchend and/or wheel, always use passive event listeners and you should be fine.

In case you use preventDefault, check wether you can substitute it with the CSS touch-action property or lower it at least in your DOM tree (for example no event delegation for these events). In case of wheel listeners you might be able to bind/unbind them on mouseenter/mouseleave.

In case of any other event: It does not make sense to use passive event listeners to improve performance. Most important to note: The scroll event can't be canceled and therefore it never makes sense to use passive event listeners for scroll.

In case of an infinite scrolling view you don't need touchmove, you only need scroll, so passive event listeners do not even apply.

Resume

To answer your question

  • for lazyloading, infinite view use a combination of setTimeout + requestIdleCallback for your event listeners and use rAF for any layout writes (DOM mutations).
  • for instant effects still use rAF for any layout writes (DOM mutations).