Scroll que dispara cientos de veces
El evento scroll se emite decenas de veces por segundo. Cómo cortarlo bien para trabajo visual.
- throttle
- requestAnimationFrame
- scroll
El problema
Una barra de progreso de lectura. Cada vez que el usuario hace scroll, calculas qué porcentaje del artículo ha visto y pintas el ancho de la barra. Simple.
El problema no es el cálculo, es la frecuencia. El evento scroll se
dispara a ritmo del sistema — decenas o cientos de veces por segundo —
y cada disparo lee scrollHeight, que fuerza al navegador a recalcular
layout, y escribe a style.width, que fuerza repintado. Sumado, roba
suficiente tiempo del hilo principal como para que el propio scroll
deje de ser suave.
El código “roto”
const barra = document.getElementById("progreso");
window.addEventListener("scroll", () => {
const total = document.documentElement.scrollHeight - window.innerHeight;
const leido = window.scrollY;
const porcentaje = (leido / total) * 100;
barra.style.width = porcentaje + "%";
});
Las pistas
- Aquí debounce no vale: la barra tiene que moverse durante el scroll, no al parar.
- Throttle con bandera +
setTimeoutes la versión más clásica. requestAnimationFramees la versión fina para trabajo visual.
Ver la solución
const barra = document.getElementById("progreso");
let stop = false;
window.addEventListener("scroll", () => {
if (stop) return;
stop = true;
const total = document.documentElement.scrollHeight - window.innerHeight;
const leido = window.scrollY;
const porcentaje = (leido / total) * 100;
barra.style.width = porcentaje + "%";
setTimeout(() => {
stop = false;
}, 200);
});Este es el patrón throttle clásico: una bandera bloquea el listener
tras cada ejecución, y un setTimeout la libera pasados 200ms.
El resultado es que, como mucho, se ejecuta cinco veces por segundo,
independientemente de cuánto haga scroll el usuario. Funciona, y es
fácil de leer, pero tiene un pequeño defecto: si el usuario para
justo entre disparos, la barra puede quedarse un pelín por debajo del
porcentaje real. Para una barra de progreso no es dramático; para
guardar una posición exacta, sí.
La versión fina para trabajo visual es requestAnimationFrame:
const barra = document.getElementById("progreso");
let pendiente = false;
window.addEventListener("scroll", () => {
if (pendiente) return;
pendiente = true;
requestAnimationFrame(() => {
const total = document.documentElement.scrollHeight - window.innerHeight;
const porcentaje = (window.scrollY / total) * 100;
barra.style.width = porcentaje + "%";
pendiente = false;
});
});requestAnimationFrame le dice al navegador: “ejecuta esto justo antes
del próximo repintado”. El callback corre una sola vez por frame, ni
una de más, y si la pestaña no es visible se pausa automáticamente.
Para cualquier cosa que afecte al pintado, es la opción correcta.
Este es el script que gobierna la barra de progreso real del sitio, si te fijas en la parte de arriba.
Scroll sin control vs con requestAnimationFrame
Dos contenedores con las mismas 200 líneas. El modo sin throttle llama al listener en cada evento scroll — decenas o cientos de veces por segundo — y cada llamada lee scrollHeight (forzando layout) y escribe style.width. El modo con rAF deja al navegador marcar el ritmo: una ejecución por frame, ni una de más. Scrollea con el ratón o con la rueda y mira la stat "Updates a la barra": es la métrica que revela la diferencia.
Comprueba lo que has aprendido
1. ¿Por qué debounce no vale para actualizar una barra de progreso de scroll?
2. ¿Cuál es la diferencia entre throttle y `requestAnimationFrame` para este caso?
3. ¿Qué gana usar `requestAnimationFrame` frente a `setTimeout(cb, 16)` para trabajo visual?
4. ¿Qué problema tiene la versión throttle con bandera y `setTimeout(fn, 200)` para una barra de progreso?
5. El listener de scroll del código roto puede dispararse cien veces por segundo. ¿Qué implica en la práctica?