cancelAnimationFrame y el patrón de limpieza
El bucle duplicado, la fuga que sobrevive al desmontaje del componente, y una envoltura que hace imposible ambas cosas.
Un bucle de render mal apagado no da error: sigue corriendo, escribiendo estilos sobre nodos que ya nadie ve, consumiendo un frame de trabajo cada dieciséis milisegundos y reteniendo en memoria todo lo que su clausura tenga capturado. Los dos fallos son casi universales y tienen la misma raíz: requestAnimationFrame devuelve un identificador que hay que guardar, y no hay nada en la API que te obligue a hacerlo bien. La solución es no escribir bucles a pelo nunca más.
- Cancelar un bucle correctamente con
cancelAnimationFrame. - Detectar y evitar el bucle duplicado por arranque doble.
- Escribir una envoltura que garantice arranque idempotente y parada limpia.
- Integrar la limpieza con el ciclo de vida de un componente.
El identificador y su cancelación
let id = requestAnimationFrame(frame);
cancelAnimationFrame(id);
El identificador es un entero mayor que cero, único dentro del documento. cancelAnimationFrame con un identificador que ya se ejecutó, o con uno inventado, no hace nada y no lanza error. Esa tolerancia es cómoda y también parte del problema: cancelar mal no se nota.
El identificador es válido para una sola petición. En un bucle hay uno nuevo por frame, así que hay que reasignar la variable en cada vuelta:
let id = null;
function frame(ts) {
dibujar(ts);
id = requestAnimationFrame(frame); // el identificador cambia cada frame
}
function arrancar() { id = requestAnimationFrame(frame); }
function parar() { cancelAnimationFrame(id); id = null; }
Cancelar desde dentro del propio callback también funciona, pero es más simple no volver a pedir: si el callback no llama a requestAnimationFrame, el bucle se acaba solo.
El bucle duplicado
Este es el bug. Llamar dos veces a arrancar() crea dos cadenas de peticiones. La variable id solo guarda la última, así que parar() cancela una y la otra sigue viva para siempre, sin referencia que la detenga.
arrancar();
arrancar(); // ahora hay dos bucles
parar(); // solo se detiene uno
Los síntomas dependen de qué haga el bucle. Si suma un incremento fijo por frame, la animación va al doble de velocidad, y como es exactamente el mismo síntoma que el del delta time mal aplicado, se diagnostica mal con frecuencia. Si el bucle está bien escrito con delta time, la velocidad no cambia pero el trabajo se duplica, y el síntoma es un consumo de CPU que no se corresponde con lo que se ve.
La causa habitual es un componente que arranca su bucle en un manejador que se ejecuta más de una vez: un resize, una actualización de datos, una entrada en el viewport. El arreglo es hacer que arrancar sea idempotente:
function arrancar() {
if (id !== null) return; // ya esta corriendo
id = requestAnimationFrame(frame);
}
Cuatro caracteres que eliminan una familia entera de bugs.
La envoltura
En vez de repetir ese patrón en cada bucle, conviene tener una función que lo encapsule. Esta cabe en veinte líneas y resuelve el delta, el primer frame, el tope, la idempotencia y la parada:
function crearBucle(callback, { dtMax = 0.25 } = {}) {
let id = null;
let anterior = null;
function paso(ts) {
if (anterior === null) anterior = ts;
const dt = Math.min((ts - anterior) / 1000, dtMax);
anterior = ts;
if (callback(dt, ts) === false) { parar(); return; }
id = requestAnimationFrame(paso);
}
function arrancar() {
if (id !== null) return;
anterior = null;
id = requestAnimationFrame(paso);
}
function parar() {
if (id === null) return;
cancelAnimationFrame(id);
id = null;
anterior = null;
}
return { arrancar, parar, get activo() { return id !== null; } };
}
Uso:
const bucle = crearBucle((dt) => {
x += 120 * dt;
caja.style.transform = `translateX(${x}px)`;
if (x > 600) return false; // el bucle se detiene solo
});
bucle.arrancar();
El anterior = null dentro de arrancar() es lo que hace que reanudar tras una parada no produzca un delta gigante. El return false da una condición de terminación sin que quien la escribe tenga que saber nada de identificadores. Y parar() es seguro llamarlo dos veces.
Limpieza en un componente
Cualquier componente que arranque un bucle tiene que pararlo al desmontarse, sin excepciones. Un bucle huérfano retiene por clausura el elemento, sus datos y todo lo que hayan capturado, así que la fuga no es solo de CPU sino de memoria.
function montarParalaje(contenedor) {
const capas = [...contenedor.querySelectorAll('.capa')];
let objetivo = 0, actual = 0;
const onScroll = () => { objetivo = contenedor.scrollTop; };
contenedor.addEventListener('scroll', onScroll, { passive: true });
const bucle = crearBucle((dt) => {
actual += (objetivo - actual) * (1 - Math.exp(-8 * dt));
for (const [i, capa] of capas.entries()) {
capa.style.transform = `translate3d(0, ${(actual * (i + 1) * -0.1).toFixed(2)}px, 0)`;
}
});
bucle.arrancar();
return function desmontar() {
bucle.parar();
contenedor.removeEventListener('scroll', onScroll);
};
}
La función devuelve su propia limpieza. Es el contrato mínimo que debería cumplir cualquier cosa que arranque un bucle, y encaja directamente con el useEffect de React, el onCleanup de Solid, el onDestroy de Svelte o un disconnectedCallback de un elemento personalizado.
Cuando hay varias cosas que limpiar, un AbortController agrupa los escuchadores y deja una sola llamada:
function montar(contenedor) {
const ac = new AbortController();
const { signal } = ac;
contenedor.addEventListener('scroll', onScroll, { passive: true, signal });
addEventListener('resize', onResize, { signal });
document.addEventListener('visibilitychange', onVisibilidad, { signal });
const bucle = crearBucle(paso);
bucle.arrancar();
signal.addEventListener('abort', () => bucle.parar());
return () => ac.abort();
}
Un solo abort() retira los tres escuchadores y detiene el bucle. La opción signal de addEventListener está en todos los motores desde hace años y hace innecesario guardar referencias a las funciones para poder quitarlas después.
Detener el bucle cuando el elemento no se ve es una optimización que casi nadie aplica y que suele ser la más rentable de todas. El navegador ya detiene los callbacks con la pestaña en segundo plano, pero no hace nada si el elemento está fuera del viewport: un bucle que anima un gráfico al pie de una página larga sigue consumiendo un frame de trabajo mientras el usuario lee la cabecera. Con un IntersectionObserver de dos líneas eso se acaba: new IntersectionObserver(([e]) => e.isIntersecting ? bucle.arrancar() : bucle.parar()).observe(contenedor). Y funciona precisamente porque el arranque es idempotente y la parada es segura, que son las dos propiedades de la envoltura. En una página con cuatro o cinco bloques animados, esto pasa de cinco bucles simultáneos a uno, y la diferencia se ve en el consumo de batería de un portátil sin necesidad de instrumentar nada. El detalle a cuidar es el reajuste del reloj: al reanudar, el anterior = null de arrancar() evita que el primer frame traiga el delta de todo el rato que el elemento estuvo oculto.
Coge un bucle existente y llama a su función de arranque tres veces seguidas. Instrumenta el callback con un contador y registra en consola cuántas veces se ejecuta por segundo: verás el triple de lo esperado. Añade la comprobación de idempotencia y verifica que vuelve a la normalidad. Después envuélvelo con crearBucle y añade el IntersectionObserver de la nota.