/1.js Secret
-
Star
(387)
You must be signed in to star a gist -
Fork
(29)
You must be signed in to fork a gist
-
-
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(); |
🤦♂️ (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]);
@Norfeldt animationInterval is jake's function