Tres mil fotos en una página: el problema del DOM y cómo lo resuelve pig.js
Poner tres mil imágenes en una página web con una etiqueta img por foto es una idea que parece razonable hasta que el navegador intenta ejecutarla. Aquí cuento qué pasa exactamente, cómo lo resuelve pig.js con un truco de renderizado virtual, y qué añadí yo encima para tener un visor con animación y navegación.

La sección de «momentos aleatorios» de este sitio tiene algo más de tres mil fotografías. Cuando decidí rescatarlas de Instagram y alojarlas aquí, el primer problema no fue dónde ponerlas ni cómo organizarlas. El primer problema fue más elemental: ¿cómo enseña el navegador tres mil imágenes sin convertirse en un desastre?
Por qué tres mil imágenes son un problema
La respuesta intuitiva es poner tres mil etiquetas <img> en el HTML y dejar que el navegador haga su trabajo. El navegador tiene buena reputación gestionando carga diferida, cachés, y ese tipo de cosas. ¿Por qué no funciona aquí?
El problema no es la descarga de las imágenes. Incluso con loading="lazy", el navegador solo descarga las que están cerca del viewport. El problema es el DOM en sí. Cada elemento <img> existe en el árbol de nodos aunque la imagen no se haya descargado todavía, y ese árbol tiene un coste. El navegador necesita calcular su posición y tamaño en relación con todos los demás elementos. Cuando el usuario hace scroll, recalcula. Cuando cambia el tamaño de la ventana, recalcula todo. Con trescientos elementos este proceso es imperceptible. Con tres mil empieza a notarse. Con treinta mil el navegador directamente no puede.
Hay un segundo problema más específico de las galerías de fotos: el layout. Si quiero mostrar imágenes de diferentes proporciones en una cuadrícula razonablemente compacta y visualmente equilibrada, necesito conocer las dimensiones de cada imagen antes de colocarla. Si espero a que carguen para calcular dónde va cada una, la página va dando saltos a medida que las fotos aparecen. Si hardcodeo posiciones fijas, las imágenes apaisadas quedan mal junto a las verticales. El layout de cuadrícula justificada —ese que usa Google Fotos donde todas las filas tienen exactamente la misma altura y las imágenes se estiran horizontalmente para llenar el ancho— requiere un cálculo previo de proporciones que no sale gratis.
Lo que hace pig.js
Pig.js (abre en ventana nueva) de Dan Schlosser (abre en ventana nueva) resuelve los dos problemas a la vez.
El primero lo resuelve con renderizado virtual: en lugar de poner tres mil elementos en el DOM, pig.js mantiene en memoria los datos de todas las imágenes —nombre de fichero, proporciones— pero solo inserta en el DOM las que están en el viewport o en una zona de amortiguación cerca de él. Cuando el usuario hace scroll hacia abajo, los elementos que salen por arriba se eliminan del DOM y los que entran por abajo se insertan. En cualquier momento hay activos solo unas pocas docenas de nodos, independientemente de cuántas fotos tenga la galería en total.
El segundo lo resuelve calculando el layout en JavaScript antes de renderizar nada. Pig.js recibe las proporciones de todas las imágenes, decide cuántas caben en cada fila para que todas tengan la misma altura objetivo, y asigna posiciones absolutas a cada elemento. El resultado es esa cuadrícula compacta y justificada sin saltos ni espacio desperdiciado.
Para la carga progresiva usa un truco habitual: primero carga una versión diminuta de cada imagen —unos pocos kilobytes— que se muestra desenfocada mientras descarga la versión de mayor resolución. Cuando la buena llega, se sustituye con una transición suave. El efecto es el de una fotografía que va tomando detalle ante tus ojos, que es más agradable que ver un hueco y luego de golpe la imagen.
Lo que añadí: visor con animación y navegación
Pig.js no incluye visor de imágenes. Tiene un onClickHandler que puedes usar para hacer lo que quieras cuando el usuario toca una foto, pero el qué hacer queda fuera de la librería. Yo escribí un módulo propio —imgmodal.js— que se encarga de eso.
Lo más interesante de la implementación es la animación de apertura. Cuando haces clic en una foto, el visor no aparece de la nada sobre un fondo negro. La imagen empieza exactamente en la posición y el tamaño que ocupa en la cuadrícula —recojo las coordenadas del thumbnail en el momento del clic— y se expande con una transición CSS hasta ocupar el centro de la pantalla. Al cerrar, hace el recorrido inverso y encoge de vuelta a su hueco en la cuadrícula. Es lo que en el mundo de las aplicaciones nativas se llama shared element transition: la sensación de que el elemento que pulsas y el elemento que aparece son el mismo objeto moviéndose, no dos cosas distintas apareciendo y desapareciendo.
La carga también es progresiva: mientras dura la animación de posición, se muestra la versión de menor resolución que pig.js ya tiene en memoria. Cuando la animación termina, se sustituye por la imagen a tamaño completo. Para detectar ese momento uso el evento transitionend del DOM en lugar de adivinar el tiempo con un setTimeout, que es la trampa habitual en este tipo de animaciones:
// Frágil: si la animación tarda diferente, va desincronizado
setTimeout(() => { img.src = fullUrl; }, 300);
// Correcto: espera a que la transición CSS termine de verdad
img.addEventListener('transitionend', function onEnd(e) {
if (e.propertyName !== 'width') return;
img.removeEventListener('transitionend', onEnd);
img.src = fullUrl;
});
El filtro por e.propertyName es necesario porque una transición sobre cuatro propiedades —top, left, width, height— dispara cuatro eventos transitionend. Sin el filtro el callback se ejecutaría cuatro veces.
Para la navegación entre fotos añadí botones laterales y soporte de teclado: las flechas izquierda y derecha se mueven entre imágenes, Escape cierra el visor. Cuando navegas, la imagen anterior se desvanece mientras carga la siguiente en segundo plano. Si el usuario pulsa una flecha antes de que termine la carga, la petición obsoleta se descarta con un contador de versión:
const myNavId = ++navId;
const temp = new Image();
temp.onload = function () {
if (myNavId !== navId) return; // una navegación más reciente canceló esta
// mostrar la imagen
};
El cierre con animación de retorno al thumbnail solo funciona cuando no has navegado a otra foto —si lo has hecho, el thumbnail original ya no está necesariamente visible en pantalla, así que simplemente se hace un fundido de salida.
Una nota sobre el código
El módulo funciona como un IIFE que expone solo dos funciones al exterior: Modal.init(datos, cdn, album) para inicializar con los datos de la galería, y Modal.abrir(indice, figura) para abrir el visor en una imagen concreta. Todo el estado interno —índice actual, coordenadas de cierre, dimensiones naturales de la imagen— vive en el closure del módulo y no contamina el espacio global. La URL de cada imagen se construye combinando el CDN, el nombre del álbum y el tamaño dentro del módulo, en lugar de manipular la URL de la imagen ya cargada por pig.js.
El resultado es una galería que puede crecer sin límite práctico de imágenes sin que el rendimiento se degrade, con un visor que se siente integrado en lugar de superpuesto. El código completo está disponible en el repositorio del blog en Codeberg (abre en ventana nueva).


