setTimeout, setInterval y requestAnimationFrame en JavaScript: cancelar, repetir y dibujar

En esta página
Enterate del próximo post
Un mail cuando publico algo nuevo. Sin spam y te podés dar de baja cuando quieras.

Un mail cuando publico algo nuevo. Sin spam y te podés dar de baja cuando quieras.
El cierre del post anterior dejó pendiente volver sobre setTimeout para mirar de cerca cómo funciona por dentro, junto con su parienta pensada para animaciones, requestAnimationFrame. Antes de llegar ahí falta una pieza que el ejemplo del Event Loop nunca necesitó: cómo cancelar un temporizador antes de que se dispare.
Programar una función con setTimeout no obliga a que se ejecute. Todavía se la puede cancelar mientras espera.
const aviso = setTimeout(() => {
console.log("esto no se imprime");
}, 100);
clearTimeout(aviso);
console.log("cancelado");
// canceladosetTimeout devuelve un identificador del temporizador que acaba de programar, guardado acá en aviso. Pasarle ese identificador a clearTimeout cancela la función antes de que el entorno de ejecución la mueva a la cola de tareas, así que nunca llega a correr. La ventana para cancelar dura exactamente lo que dura la espera: si clearTimeout se llama después de que la función ya se ejecutó, no pasa nada, ni error ni efecto. Cancelar algo que ya corrió es una operación válida que simplemente no tiene nada para hacer.
setTimeout programa una función una única vez. Para repetirla a intervalos regulares existe setInterval.
let vueltas = 0;
function contar() {
vueltas = vueltas + 1;
console.log("vuelta", vueltas);
if (vueltas < 3) {
return;
}
clearInterval(id);
}
const id = setInterval(contar, 50);
// vuelta 1
// vuelta 2
// vuelta 3setInterval recibe la misma pareja de argumentos que setTimeout, una función y un tiempo en milisegundos, pero en vez de ejecutarla una vez la vuelve a programar automáticamente cada vez que se cumple el plazo. contar lee y modifica vueltas por closure, igual que incrementar leía y modificaba cuenta en el post de closures. El if de guarda corta la ejecución mientras todavía no se llegó a la tercera vuelta; una vez que se llega, el código sigue de largo y llama a clearInterval(id), que cancela las repeticiones futuras del mismo modo en que clearTimeout cancela un setTimeout.
Ese clearInterval no es un detalle opcional. Un setInterval sin ningún clearInterval en ningún lado sigue disparando su función para siempre, incluso si el resto del programa ya no tiene ningún uso para ella. Es una de las formas más comunes de dejar código corriendo de fondo sin que nadie lo haya pedido.
setTimeout(fn, 0) sugiere que la función corre apenas termina el código síncrono, y así fue en el post anterior. Pero si un setTimeout de retraso 0 programa a su vez otro setTimeout de retraso 0, y ese otro hace lo mismo, el patrón deja de sostenerse.
const inicio = performance.now();
function anidar(nivel) {
if (nivel > 7) {
return;
}
const transcurrido = (performance.now() - inicio).toFixed(1);
console.log(`nivel ${nivel} -> ${transcurrido} ms`);
setTimeout(() => anidar(nivel + 1), 0);
}
anidar(0);
// nivel 0 -> 0.1 ms
// nivel 1 -> 0.4 ms
// nivel 2 -> 0.4 ms
// nivel 3 -> 0.5 ms
// nivel 4 -> 0.5 ms
// nivel 5 -> 0.5 ms
// nivel 6 -> 0.5 ms
// nivel 7 -> 4.7 msCada llamada programa la siguiente con retraso 0, y hasta el nivel 6 el tiempo transcurrido casi no se mueve: cada temporizador se cumple prácticamente de inmediato. En el nivel 7 aparece un salto que no tiene nada que ver con el 0 que se le pidió: el tiempo transcurrido pasa a rondar los 4 milisegundos. A partir de cierta cantidad de temporizadores anidados con retraso mínimo, el navegador deja de respetar el 0 y le impone un piso cercano a esos 4 milisegundos, sin importar cuántas veces más se lo pida con retraso 0. La regla evita que una cadena de temporizadores encadenados como esta acapare el procesador fingiendo que no espera nada.
Este ejemplo corre en una pestaña de Chrome real, no en la terminal. El piso de los 4 milisegundos es una regla del navegador, no del lenguaje JavaScript en sí, así que no tiene por qué reproducirse igual en cualquier entorno de ejecución.
Para animar algo en pantalla es tentador usar setTimeout con un número que se acerque a la duración de un cuadro, algo como 16 milisegundos para apuntar a 60 cuadros por segundo. El problema es que ese número es una suposición: la pantalla real puede refrescar a otro ritmo, y ya quedó claro que el navegador ni siquiera garantiza el retraso exacto que se le pide. requestAnimationFrame resuelve esto de otra forma: en vez de pedir un tiempo, pide que la función corra justo antes de que el navegador dibuje el próximo cuadro, sea cual sea el ritmo real de la pantalla.
const inicio = performance.now();
let anterior = inicio;
let cuadro = 0;
function dibujar(marca) {
if (cuadro > 4) {
return;
}
const delta = (marca - anterior).toFixed(1);
console.log(`cuadro ${cuadro} -> +${delta} ms`);
anterior = marca;
cuadro = cuadro + 1;
requestAnimationFrame(dibujar);
}
requestAnimationFrame(dibujar);
// cuadro 0 -> +8.6 ms
// cuadro 1 -> +16.6 ms
// cuadro 2 -> +16.6 ms
// cuadro 3 -> +16.7 ms
// cuadro 4 -> +16.7 msrequestAnimationFrame le pasa a dibujar una marca de tiempo propia, marca, distinta del reloj que se usó en el ejemplo anterior. El primer cuadro llega en un momento cualquiera del ciclo de dibujo ya en marcha, por eso su diferencia con inicio no vale lo mismo que las demás. De ahí en adelante, la diferencia entre un cuadro y el siguiente se mantiene estable alrededor de 16.7 milisegundos, que es exactamente un cuadro a 60 por segundo. Ningún número se lo pidió: es el ritmo real al que esa pantalla dibuja.
También existe su cancelación, simétrica a las otras dos.
const id = requestAnimationFrame(() => {
console.log("esto no se imprime");
});
cancelAnimationFrame(id);
console.log("cancelado");
// canceladorequestAnimationFrame no existe en Node: es una función que expone el navegador, ligada al ciclo real de dibujo de una pantalla, así que fuera de un contexto con pantalla no hay nada que sincronizar. Además de seguir el ritmo real del dispositivo en lugar de un número supuesto, el navegador deja de llamar a la función programada con requestAnimationFrame mientras la pestaña no está visible, algo que ni setTimeout ni setInterval hacen por su cuenta.
Cancelar antes de que dispare (
clearTimeout), repetir con cuidado de frenar en algún momento (setIntervaljunto conclearInterval), o dejar que el navegador decida el instante exacto de dibujar (requestAnimationFrame): tres formas de programar código para después, cada una resolviendo un problema distinto. El piso de los 4 milisegundos es la prueba de que ni siquierasetTimeoutcon retraso0es realmente inmediato, y es justo esa imprecisión la querequestAnimationFrameevita para lo que sí necesita precisión, dibujar a tiempo.
El próximo post de la serie deja los temporizadores y pasa a otra estructura: qué es un objeto, cómo se accede a sus propiedades con punto y con corchetes, y cómo se modifican o se agregan nuevas.