Scroll infinito
Un listener de scroll, un fetch por disparo, y un innerHTML que reparsea todo. Tres problemas, una solución con IntersectionObserver.
- IntersectionObserver
- rootMargin
- condición de carrera
- insertAdjacentHTML
El problema
Scroll infinito de artículos. El código roto es un compendio: un
listener de scroll que se dispara sin parar, un fetch por disparo (sin
bandera que evite duplicados), y un innerHTML += que reparsea la
lista entera cada vez que llega una página nueva.
Los tres problemas se resuelven con tres piezas distintas, y merece la pena verlos por separado.
El código “roto”
const lista = document.getElementById("articulos");
const centinela = document.getElementById("cargar-mas");
let siguientePagina = 2;
window.addEventListener("scroll", () => {
const rect = centinela.getBoundingClientRect();
if (rect.top < window.innerHeight) {
fetch("/api/articulos?pagina=" + siguientePagina)
.then((res) => res.json())
.then((articulos) => {
lista.innerHTML += articulos.map((a) => `<article>${a.titulo}</article>`).join("");
siguientePagina++;
});
}
});
Las pistas
IntersectionObserveravisa cuando un elemento cambia de estado de visibilidad; no necesitas escuchar scroll para saber si el centinela está cerca.- Una bandera
cargandoque se levanta al empezar el fetch y se baja al terminar evita peticiones duplicadas. insertAdjacentHTML("beforeend", ...)inserta al final sin reparsear lo que ya hay.
Ver la solución
const lista = document.getElementById("articulos");
const centinela = document.getElementById("cargar-mas");
let siguientePagina = 2;
let cargando = false;
const observer = new IntersectionObserver(
([ele]) => {
if (!ele.isIntersecting) return;
if (cargando) return;
cargando = true;
fetch("/api/articulos?pagina=" + siguientePagina)
.then((res) => res.json())
.then((articulos) => {
lista.insertAdjacentHTML("beforeend", articulos.map((a) => `<article>${a.titulo}</article>`).join(""));
siguientePagina++;
})
.finally(() => {
cargando = false;
});
},
{ rootMargin: "300px" },
);
observer.observe(centinela);Tres piezas:
IntersectionObserver con rootMargin: "300px" observa un elemento
centinela colocado al final de la lista. En vez de escuchar scroll
(que se dispara sin parar), el observer avisa solo cuando el estado de
visibilidad del centinela cambia. El rootMargin expande el área
efectiva del viewport 300px por debajo, así que empezamos a cargar la
página siguiente antes de que el usuario llegue al fondo — cuando llega,
los artículos ya están.
Bandera cargando. IntersectionObserver reduce los disparos, pero
no los elimina: si el usuario hace scroll con el centinela en pantalla,
el observer puede volver a dispararse mientras la primera petición
todavía está en vuelo. La bandera bloquea peticiones concurrentes para
la misma página. Se sube al empezar, se baja en .finally(). En
.finally y no en .then porque si la petición falla (red caída,
500 del servidor) y la bandera queda a true, el scroll infinito
se rompe para siempre. .finally cubre éxito y fallo, que es lo que
quieres.
insertAdjacentHTML("beforeend", ...) añade el HTML nuevo al
final del contenedor sin tocar lo que ya hay. innerHTML += ... es
peor de lo que parece: para calcular el nuevo valor, el navegador
serializa el contenido actual del nodo, lo concatena con el nuevo
trozo, y parsea el resultado entero de vuelta. En una lista con 100
artículos, cargar la página 6 supone re-parsear los 500 anteriores
para pintar los 5 nuevos. insertAdjacentHTML parsea solo el
fragmento nuevo.
Este es el script que gobierna el scroll infinito real de la home.
Scroll infinito: listener + innerHTML += vs Observer + bandera + insertAdjacentHTML
Cada modo tiene su propio scroll container con 10 artículos y un centinela al final. Al scrollear cerca del fondo, arranca la "carga de la siguiente página" (fetch simulado con 300ms de latencia). En sin control: scroll listener sin bandera, cada evento comprueba el centinela congetBoundingClientRect() y lanza un fetch; con el retardo del servidor, terminas con muchos fetches en vuelo para la misma página y artículos duplicados en la lista. En Observer + bandera: IntersectionObserver, bandera cargando que se baja en.finally(), e insertAdjacentHTML.
Comprueba lo que has aprendido
1. ¿Qué ventaja tiene IntersectionObserver frente a un listener de scroll para detectar si un centinela está en pantalla?
2. ¿Para qué sirve `rootMargin` en IntersectionObserver?
3. ¿Por qué hace falta la bandera `cargando` aunque uses IntersectionObserver?
4. ¿Por qué bajar la bandera `cargando` en `.finally()` y no en `.then()`?
5. ¿Por qué `insertAdjacentHTML("beforeend", ...)` es mejor que `innerHTML += ...` para añadir contenido al final?