El bucle con pausa y reanudación
Escribir el bucle definitivo: arranque idempotente, pausa al ocultarse la pestaña, parada cuando el canvas sale de la vista y dibujo bajo demanda cuando nada cambia.
Arrancar y parar sin duplicar
El fallo número uno de esta parte no es olvidar parar: es arrancar dos veces. Ocurre en cuanto hay un componente que se monta y desmonta, o un evento que llama al arranque por si acaso.
// Mal: dos llamadas dan dos bucles, y todo va al doble de velocidadfunction arrancar() { requestAnimationFrame(marco);}Dos bucles simultáneos no duplican los fotogramas —la pantalla sigue refrescando igual— pero sí duplican las llamadas a actualizar por fotograma, con lo que el mundo avanza al doble. El síntoma es característico: la animación va bien, se vuelve a un componente y ahora va más rápido, y a la tercera vuelta va al triple.
La defensa es guardar el identificador y comprobarlo:
let id = 0;function arrancar() { if (id) return; // ya esta corriendo anterior = null; id = requestAnimationFrame(marco);}function parar() { if (!id) return; cancelAnimationFrame(id); id = 0;}function marco(ahora) { if (anterior === null) anterior = ahora; const dt = Math.min(50, ahora - anterior); anterior = ahora; actualizar(mundo, dt); dibujar(ctx, mundo); id = requestAnimationFrame(marco); // reasignar SIEMPRE}Reasignar id dentro del propio marco es lo que hace que parar funcione: sin eso, cancelas un identificador viejo y el bucle sigue vivo.
Y anterior = null en el arranque es lo que evita el delta gigante de la reanudación. Aunque el acotado ya protege la física, dejarlo limpio hace que el primer fotograma tras reanudar sea exacto en lugar de aproximado.
visibilitychange
Cuando el usuario cambia de pestaña, el navegador deja de llamar a requestAnimationFrame por su cuenta. Eso ya evita el consumo de CPU del dibujo, así que la pausa explícita no es para ahorrar fotogramas: es para todo lo demás.
document.addEventListener('visibilitychange', () => { if (document.hidden) { parar(); pausarSonido(); detenerSondeoDeRed(); } else { arrancar(); }});Lo que sí hace falta manejar a mano es lo que ocurre al volver. Sin pausa explícita, el primer fotograma tras regresar trae un delta igual a todo el tiempo que la pestaña estuvo oculta. El acotado lo salva, pero hay dos efectos que el acotado no cubre: los temporizadores propios que sí siguieron corriendo y quedan desincronizados con el mundo, y cualquier animación basada en un tiempo absoluto, que dará un salto grande.
Hay tres eventos más de la misma familia que conviene conocer porque visibilitychange no cubre todos los casos:
| Evento | Cuándo llega | Qué hacer |
|---|---|---|
visibilitychange |
Cambio de pestaña, minimizar, bloquear la pantalla | Parar y reanudar |
pagehide |
Se abandona la página, incluso hacia la caché de retroceso | Liberar recursos, guardar estado |
pageshow |
Se vuelve a la página, incluso desde la caché de retroceso | Rearrancar el bucle si venía de la caché |
blur en la ventana |
La ventana pierde el foco sin ocultarse | Normalmente nada: la pestaña sigue visible |
La caché de retroceso merece atención: al volver con el botón de atrás, la página se restaura tal cual estaba, con el bucle parado y sin que se dispare ningún load. Si solo escuchas visibilitychange, hay navegadores en los que la animación no rearranca. Escuchar pageshow y comprobar evento.persisted lo cubre.
window.addEventListener('pageshow', e => { if (e.persisted) arrancar(); });window.addEventListener('pagehide', () => parar());El bucle definitivo
Todo lo anterior en una clase reutilizable que cubre los tres modos: parado, animando de forma continua, y dibujando bajo demanda.
export class Bucle { constructor({ elemento, actualizar, dibujar, dtMaximo = 50 }) { this.elemento = elemento; this.actualizar = actualizar; this.dibujar = dibujar; this.dtMaximo = dtMaximo; this.id = 0; this.anterior = null; this.continuo = false; // true: anima; false: solo dibuja al invalidar this.pendiente = false; // hay un dibujo bajo demanda programado this.visible = true; this.enPantalla = true; this.alCambiarVisibilidad = () => { this.visible = !document.hidden; this.revisar(); }; document.addEventListener('visibilitychange', this.alCambiarVisibilidad); this.observador = new IntersectionObserver(entradas => { this.enPantalla = entradas[0].isIntersecting; this.revisar(); }, { threshold: 0 }); this.observador.observe(elemento); } get puedeCorrer() { return this.visible && this.enPantalla; } animar(activo = true) { this.continuo = activo; this.revisar(); } invalidar() { if (this.continuo || this.pendiente || !this.puedeCorrer) return; this.pendiente = true; requestAnimationFrame(() => { this.pendiente = false; this.dibujar(0); // un unico fotograma, sin avanzar el estado }); } revisar() { if (this.continuo && this.puedeCorrer) this.arrancar(); else this.parar(); } arrancar() { if (this.id) return; this.anterior = null; this.id = requestAnimationFrame(this.marco); } parar() { if (!this.id) return; cancelAnimationFrame(this.id); this.id = 0; } marco = ahora => { if (this.anterior === null) this.anterior = ahora; const dt = Math.min(this.dtMaximo, ahora - this.anterior); this.anterior = ahora; this.actualizar(dt); this.dibujar(dt); this.id = requestAnimationFrame(this.marco); }; destruir() { this.parar(); this.observador.disconnect(); document.removeEventListener('visibilitychange', this.alCambiarVisibilidad); }}El uso cubre los dos escenarios con la misma pieza:
const bucle = new Bucle({ elemento: canvas, actualizar: dt => actualizarMundo(mundo, dt), dibujar: () => dibujarMundo(ctx, mundo),});// Editor: quieto salvo que algo cambiecanvas.addEventListener('pointermove', e => { if (actualizarResaltado(e)) bucle.invalidar();});// Hay una transicion viva: animar hasta que terminefunction empezarTransicion() { mundo.transicion = crearTransicion(0, 1, 0.3); bucle.animar(true);}function alTerminarTransicion() { bucle.animar(false); bucle.invalidar(); // un ultimo fotograma con el estado final}Ese último invalidar después de apagar la animación es fácil de olvidar y deja la escena congelada un fotograma antes del final, con un desplazamiento de uno o dos píxeles que no se explica hasta que se mira con lupa.
La lista de comprobación
Antes de dar por terminada cualquier animación de canvas, seis cosas que se olvidan en este orden de frecuencia:
El bucle se para al desmontar. En una aplicación con enrutado en el cliente, un bucle huérfano sigue corriendo sobre un canvas que ya no está en el documento, y su referencia impide liberar tanto el búfer como todo lo que el modelo alcance.
El arranque es idempotente. Llamarlo dos veces no duplica el bucle.
El delta está acotado y se reinicia al reanudar.
Se para cuando el canvas no está en pantalla.
Si la escena no es una animación, no hay bucle continuo.
El estado no se toca en dibujar, de modo que un fotograma extra por redimensionado o por invalidación no altere nada.