-
-
Save jakearchibald/cb03f15670817001b1157e62a076fe95 to your computer and use it in GitHub Desktop.
export function animationInterval(ms, signal, callback) { | |
// Prefer currentTime, as it'll better sync animtions queued in the | |
// same frame, but if it isn't supported, performance.now() is fine. | |
const start = document.timeline ? document.timeline.currentTime : performance.now(); | |
function frame(time) { | |
if (signal.aborted) return; | |
callback(time); | |
scheduleFrame(time); | |
} | |
function scheduleFrame(time) { | |
const elapsed = time - start; | |
const roundedElapsed = Math.round(elapsed / ms) * ms; | |
const targetNext = start + roundedElapsed + ms; | |
const delay = targetNext - performance.now(); | |
setTimeout(() => requestAnimationFrame(frame), delay); | |
} | |
scheduleFrame(start); | |
} |
// Usage | |
import { animationInterval } from './1.js'; | |
const controller = new AbortController(); | |
// Create an animation callback every second: | |
animationInterval(1000, controller.signal, time => { | |
console.log('tick!', time); | |
}); | |
// And to stop it: | |
controller.abort(); |
For the record, I'm not saying this is a bad approach, just wanted to make sure I understood the tradeoffs correctly. I think calling requestAnimationFrame
without the setTimeout
avoids the delayed event loop problem (though I could be wrong there as well), but as you pointed out in the video, unnecessarily ticks at 60hz.
What's the delayed event loop problem? rAF is also blocked by other main thread JS. I mean, it's JS.
By delayed event loop problem, I meant introducing the possibility of a delay that would prevent the callback from being fired long enough to create an unexpected experience for the user. My thinking was that in the gist, scheduling the next update is a two step process:
- scheduling invocation of
requestAnimationFrame
by callingsetTimeout
, to be run after the desired delay - scheduling the invocation of
frame
by passing it torequestAnimationFrame
, to be run before the next paint
Each of these steps is asynchronous, and (I think) yields back to the main thread, scheduling a task to be picked up by the event loop at some later point. If that's true, each of these steps introduces the risk of the update being delayed long enough to be noticeable. If requestAnimationFrame
was called immediately after the callback without setTimeout
, that would essentially remove the first step, cutting the number of opportunities for the update to be blocked in half. As you point out though, the possibility still exists, so it seems like a reasonable tradeoff to add 1 more potential block point in exchange for the gains in efficiency. I just didn't hear that point addressed specifically in the video so I figured I'd ask to see if I was understanding correctly.
Hey, thx for the snippet. I want the time to keep running if the device is in sleep mode. Would it be dumb/usless to return the currentTime - startTime with Date.now() in the callback? I mean the function call should be still consistent, or have I overlooked something? Alternatively I could check if the times are to far apart and correct the time. But first feels better. Would be really thankful for help.
I understand that this snippet is really meant to update something visible with a relatively big interval (in the order of seconds), but I think it might not be clear to everyone. Maybe there should be a note that this timer should NOT be used to set the FPS of an animation? I can already see people coming asking why their animation flickers when they try to call this function with an interval of 16.7
ms on a 60Hz monitor.
Also, the fact that callback
is called as part of the animation callback makes it late by a frame every time.
So for the ones who need ms precision, and don't necessarily want their timer to update something painted on screen, but still want to enjoy the aggressive "throttle-on-blur" feature that rAF offers, it might be better to lose the painting-time
parameter in the callback and only call scheduleTimer
in the rAF callback.
export function treeFriendlyInterval(ms, signal, callback) {
// This version is not especially made for visual animations
// so performance.now() is better for us
const start = performance.now();
function frame() {
if (signal.aborted) return;
requestAnimationFrame(()=> scheduleFrame());
// We call 'callback' after we scheduled the frame
// in case 'callback' throws, this is consistent with 'setInterval()'.
callback();
}
function scheduleFrame() {
const time = performance.now();
const elapsed = time - start;
const roundedElapsed = Math.round(elapsed / ms) * ms;
const targetNext = start + roundedElapsed + ms;
const delay = targetNext - performance.now();
setTimeout(frame, delay);
}
scheduleFrame(start);
}
Also, the fact that callback is called as part of the animation callback makes it late by a frame every time.
That isn't how raf works. raf queues the callback before the next display update, unless it's being called within the raf callback steps (or later) as part of the render steps of the event loop.
Also, the fact that callback is called as part of the animation callback makes it late by a frame every time.
That isn't how raf works. raf queues the callback before the next display update, unless it's being called within the raf callback steps (or later) as part of the render steps of the event loop.
I am well aware of how rAF works, btw, I did link in the video's comments to the bug report I opened about the bug you "found" but you might miss it, so here it is again: https://crbug.com/1018120 .
So, my point is that if one wants to have their callback fire precisely at the time specified, having it in the rAF callback will make it always* at least a few ms late, at the next painting frame.
If you are lucky, it can be a couple of ms only, if you are unlucky it can be a full 16.6ms on a 60Hz monitor, e.g if your timer callback is called as part of the event-loop iteration that directly follows the painting frame.
*(except in Chrome's buggy implementation if the document is not animated)
It isn't a few ms late, it's in sync with the display. Which, given that the point is to update something visual, is the more accurate time.
I guess you misread my comment, I acknowledged that your point was to update something visual:
I understand that this snippet is really meant to update something visible
I also clearly explain (at least I thought I did) that my version is ...
for the ones who need ms precision, and don't necessarily want their timer to update something painted on screen
So yes, for those who just want an interval with a drift correction that's still battery friendly, but who want to have their timer fire every 100ms and not randomly between 100ms and 116.7ms when they ask for a 100ms interval, calling the callback in the timer callback is better.
And no, the current version is not really "in sync with the display".
See this fiddle on a 60Hz monitor or change the FPS
variable value.
On my Firefox and Chrome on macOs with a 60Hz monitor, I reach about 250 missed frames in 900 frames (3 loops of the top rect).
Safari will behave even worse after I activate the "result" iframe (i.e after I click in it), while until then it's a flat 0 missed frame since they do throttle rAF even in visible iframes...
To overcome this, we need to set the timeout to some time before the next "update the rendering".
Using flooredElapsed
instead of roundedElapsed
will already help a little, but nothing really convincing.
The best I can think of is to use an ugly magic number out of my hat hoping no user will be on their fancy 480Hz prototype monitor.
// we assume 240Hz max (above that, the delay might not be enough...)
const magic_safe_delay = 1000 / 240;
function scheduleFrame(time) {
const elapsed = time - start;
// floor because setTimeout is never too early
const flooredElapsed = Math.floor(elapsed / ms) * ms;
const targetNext = start + flooredElapsed + ms;
const delay = targetNext - performance.now();
setTimeout(() => requestAnimationFrame(frame), delay - magic_safe_delay);
}
But even with this improved version,
This function should not be used to set an animation's target FPS.
And here I talk about "animations", yes for a 1 second tick interval, even the original version is fine.
First, even this version will miss frames sometimes, but more importantly, in order to be efficient, an animation's target FPS should be set to a multiple of the monitor's refresh-rate. And we can't know it reliably (there are ways to make very good educated guesses by analyzing rAF callbacks, but to use these in the wild is a whole story).
For instance if we target 60FPS on a 75Hz monitor we'll end up with an effective 37.5FPS because only one of every two frames will be seen as paint-able. (related SO answer of mine)
Unfortunately, until Jake's proposal comes to life, we have no way to do this correctly.
Depending on the situation, time-delta everywhere might unfortunately be the best, even with its jumps on hiccups...
Hey all, I think I might have found a slight bug (or at least a gotcha) with this technique. In my testing, I'm never seeing a delay
of 0
, which means that the first frame
callback execution is delayed by at least ms
. This is especially noticeable with larger ms
values, where your callback won't fire for several seconds. You can work around this by doing something like:
+ let first = true;
function scheduleFrame(time) {
const elapsed = time - start;
const roundedElapsed = Math.round(elapsed / ms) * ms;
const targetNext = start + roundedElapsed + ms;
+ const delay = first ? 0 : targetNext - performance.now();
+ first = false;
setTimeout(() => requestAnimationFrame(frame), delay);
}
Nice one Jake!
As much as you dislike the react hooks API, I've converted your util into a react hook for my convenience and hopefully useful for others too.
import { animationInterval } from './animationInterval' const useAnimationFrame = (ms, callback) => { const callbackRef = useRef(callback) useEffect(() => { callbackRef.current = callback }, [callback]) useEffect(() => { const controller = new AbortController() animationInterval(ms, controller.signal, callbackRef.current) return () => controller.abort() }, [ms]) } // usage useAnimationFrame(1000, () => setVisible(x => !x))I actually have the perfect use case for this code and for the complementary youtube video. This is what I was implementing the other day
My original implementation of the above animating
:
usedsetInterval
+requestAnimationFrame
. Yesterday I migrated to pure cssinfinite
on the premise that it would be off the main thread and therefore more efficient. Will be migrating once again today!
@mfbx9da4 I would like to use it in my react application 😍 but missing some puzzles (like the animationInterval
) that you are using in your example. Would you mind sharing an example (codesandbox, codepen, what-ever-works-best-for-you) that works?
@Norfeldt animationInterval is jake's function
export function animationInterval(ms, signal, callback) {
const start = document?.timeline?.currentTime || performance.now()
function frame(time) {
if (signal.aborted) return
callback(time)
scheduleFrame(time)
}
function scheduleFrame(time) {
const elapsed = time - start
const roundedElapsed = Math.round(elapsed / ms) * ms
const targetNext = start + roundedElapsed + ms
const delay = targetNext - performance.now()
setTimeout(() => requestAnimationFrame(frame), delay)
}
scheduleFrame(start)
}
🤦♂️ (me)
thanks @mfbx9da4 - have no good excuse for why I don't catch that.
this is my typescript version of it @mfbx9da4
useAnimationInterval.ts
import React from 'react'
function animationInterval(ms: number, signal: AbortSignal, callback: (time: number) => void) {
const start = document?.timeline?.currentTime || performance.now()
function frame(time: number) {
if (signal.aborted) return
callback(time)
scheduleFrame(time)
}
function scheduleFrame(time: number) {
const elapsed = time - start
const roundedElapsed = Math.round(elapsed / ms) * ms
const targetNext = start + roundedElapsed + ms
const delay = targetNext - performance.now()
setTimeout(() => requestAnimationFrame(frame), delay)
}
scheduleFrame(start)
}
export function useAnimationInterval(ms: number, callback: (time: number) => void) {
const callbackRef = React.useRef(callback)
React.useEffect(() => {
callbackRef.current = callback
}, [callback])
React.useEffect(() => {
const controller = new AbortController()
animationInterval(ms, controller.signal, callbackRef.current)
return () => controller.abort()
}, [ms])
}
// usage
// useAnimationFrame(1000, (time) => {
// console.log(time)
// setVisible((x) => !x)
// })
I am new to these core JS features can someone help me understand what exactly is a AbortController and controller.signal does?
Or at least provide a resource to learn Thanks
@mfbx9da4 Thanks for the React hooks version! Just ran into a subtle bug with it that might be worth flagging:
animationInterval(ms, controller.signal, callbackRef.current)
Here the current callbackRef
is passed by reference to animationInterval
, so animationInterval
will keep using the callback that was current
when it was called, rather than updating when the ref changes as desired.
To ensure the latest callback is always used, we can call the current
ref like this instead:
animationInterval(ms, controller.signal, (time) => callbackRef.current(time))
Ooo great point - good catch! Will update
I was wondering how Jake's function could be modified such that it could run in the background
still have issue of double frame
so if I just want the counter for second level precision, I just need set setInterval delay
less than 1000ms, should be working fine?
const start = Date.now();
setInterval(() => {
const gaps = (Date.now() - start);
const seconds = Math.floor(gaps / 1000);
updateUI(seconds)
}, 900); // change less than 1000ms
yeah finally I did it like this
Is this gist something i can use to create a countdown timer with? like to a specific date. Or would this not be for something like that?
@zizifn no, that still drifts. That's why the code in this gist is more complicated.
The video is very interesting and I benefited a lot from it. 👍
Now the time obtained each time requestAnimationFrame(frame)
is executed is accurate, but due to the use of setTimeout
, the main thread will still be blocked and the number of seconds will jump.
Suppose there is a time-consuming synchronization task:
function animationInterval(ms, signal, callback) {
// Prefer currentTime, as it'll better sync animtions queued in the
// same frame, but if it isn't supported, performance.now() is fine.
const start = document.timeline ? document.timeline.currentTime : performance.now()
function frame(time) {
if (signal.aborted) return
callback(time)
scheduleFrame(time)
}
function scheduleFrame(time) {
const elapsed = time - start
const roundedElapsed = Math.round(elapsed / ms) * ms
const targetNext = start + roundedElapsed + ms
const delay = targetNext - performance.now()
setTimeout(() => requestAnimationFrame(frame), delay)
}
scheduleFrame(start)
}
let count = 0
function longRunningTask() {
console.time('long task')
for (let i = 0; i < 1500000000; i++) {
count += 1
}
console.timeEnd('long task')
}
const controller = new AbortController()
// 👇
setTimeout(() => longRunningTask(), 5000)
// Create an animation callback every second:
animationInterval(1000, controller.signal, time => {
console.log('tick!', time)
})
// And to stop it:
// controller.abort()
The actual performance is as follows:
tick! 1161.4
tick! 2144.9
tick! 3161.7
tick! 4161.3
tick! 5161.4
long task: 8215.17578125 ms
tick! 13381.1
tick! 14148.2
tick! 15148.4
tick! 16148.6
tick! 17148.8
...
Assuming there is a time-consuming task, it seems that the only way is to put this time-consuming task into a Worker or adopt a time-slicing solution to divide it into several small tasks so that the main thread has time to update the UI, right?
If your thread is blocked, then both setTimeout
and requestAnimationFrame
callbacks will be delayed. Avoid running long tasks on the UI thread.
I came up with a similar algo that compensates clock skew as it goes. It will compensate end to end for slight delays in setTimeout
, requestAnimationFrame
, and of course callback
executions. So if any of these skew the clock, it will tell the next run to execute a bit earlier. As a bonus, I also made it compatible with useEffect
for react users.
type ExecutionControl = { stopped?: true; animationId?: number; timeoutId?: NodeJS.Timeout };
export function interval(cb: () => void, ms: number, exeCtl: ExecutionControl = {}) {
if (exeCtl.stopped) return;
const start = performance.now();
exeCtl.animationId = requestAnimationFrame(() => {
exeCtl.timeoutId !== undefined && cb();
const end = performance.now();
const diff = Math.round(end - start);
const timeout = Math.max(0, ms - diff);
exeCtl.timeoutId = setTimeout(() => {
return interval(cb, ms, exeCtl);
}, timeout);
});
return () => {
exeCtl.stopped = true;
exeCtl.timeoutId && clearTimeout(exeCtl.timeoutId);
exeCtl.animationId && cancelAnimationFrame(exeCtl.animationId);
};
}
Fwiw, the OP solution isn't incompatible with useEffect
.
useEffect(() => {
const controller = new AbortController();
animationInterval(1000, controller.signal, () => {
// Runs every second
});
return () => controller.abort();
}, [...whatever]);
@dev-nicolaos what's the alternative? The only animations that avoid the main thread are ones based on transforms and opacity. You could create an element like this:
…and translate that using a
steps()
animation. That would avoid the main thread, but it'd tick at 60hz (except in Safari, who have optimised this case), so it'd be worse for battery.