Portal Performance
eventos

Scroll infinito

Un listener de scroll, un fetch por disparo, y un innerHTML que reparsea todo. Tres problemas, una solución con IntersectionObserver.

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

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.

Pruébalo

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.

Sin control · scroll + innerHTML += + sin bandera
Disparos: 0 · Fetches: 0 · Duplicados: 0 · Artículos: 10
Cargando siguiente página...
Observer + bandera + insertAdjacentHTML
Disparos: 0 · Fetches: 0 · Duplicados: 0 · Artículos: 10
Cargando siguiente página...

Comprueba lo que has aprendido

  1. 1. ¿Qué ventaja tiene IntersectionObserver frente a un listener de scroll para detectar si un centinela está en pantalla?

  2. 2. ¿Para qué sirve `rootMargin` en IntersectionObserver?

  3. 3. ¿Por qué hace falta la bandera `cargando` aunque uses IntersectionObserver?

  4. 4. ¿Por qué bajar la bandera `cargando` en `.finally()` y no en `.then()`?

  5. 5. ¿Por qué `insertAdjacentHTML("beforeend", ...)` es mejor que `innerHTML += ...` para añadir contenido al final?