Virtualización por ventana: montar solo lo que se ve
El modelo del espaciador y la ventana deslizante, una implementación completa con reciclado de nodos, y lo que hay que añadir para que sea accesible.
Virtualizar una lista es sustituir diez mil nodos por treinta más una mentira sobre la altura total. La idea cabe en un párrafo y la implementación correcta en cien líneas, pero entre una cosa y la otra hay una docena de decisiones que determinan si el resultado es fluido o produce filas en blanco en cada scroll rápido. Esta lección construye la implementación de alturas fijas de principio a fin, que es la que hay que entender antes de meterse en las variables.
- Describir el modelo de espaciador, ventana y margen de sobredibujado.
- Implementar una lista virtual de altura fija con reciclado de nodos.
- Justificar cada decisión: por qué
passive, por quérequestAnimationFrame, por qué posición absoluta. - Añadir los atributos ARIA que hacen que un lector de pantalla entienda la lista.
El modelo: un hueco, una ventana y un margen
Un contenedor con overflow: auto tiene una barra de scroll cuya longitud sale de la altura de su contenido. Si el contenido son treinta filas, la barra corresponde a treinta filas. Para que el usuario pueda desplazarse por diez mil, hace falta que el contenedor crea que hay diez mil.
De ahí las tres piezas.
El espaciador es un elemento vacío cuya única función es tener la altura total: número de elementos por altura de fila. No contiene nada, no se ve, y su único trabajo es dar longitud a la barra de scroll.
La ventana es el conjunto de filas realmente montadas. Cada una se coloca en su posición absoluta correspondiente a su índice, así que aunque solo existan treinta, están en el sitio exacto donde estarían si existieran las diez mil.
El margen de sobredibujado son las filas de más que montas por encima y por debajo de lo visible. Sin margen, un scroll rápido enseña huecos en blanco durante los milisegundos que tarda el navegador en llegar al siguiente fotograma. Con un margen de tres a cinco filas el hueco desaparece en la práctica y el coste es despreciable.
La cuenta es siempre la misma:
const primerIndiceVisible = Math.floor(scrollTop / altoFila);
const filasVisibles = Math.ceil(altoContenedor / altoFila);
const inicio = Math.max(0, primerIndiceVisible - margen);
const fin = Math.min(total - 1, primerIndiceVisible + filasVisibles + margen);
La implementación completa
Esto funciona pegado tal cual. No depende de ninguna librería.
export function listaVirtual({ contenedor, total, altoFila, margen = 4, render }) {
// El contenedor tiene que ser el bloque contenedor de las filas.
if (getComputedStyle(contenedor).position === 'static') {
contenedor.style.position = 'relative';
}
contenedor.style.overflowY = 'auto';
// 1. El espaciador da altura total y por tanto barra de scroll.
const espaciador = document.createElement('div');
espaciador.setAttribute('aria-hidden', 'true');
espaciador.style.cssText = 'width:1px;pointer-events:none';
espaciador.style.height = total * altoFila + 'px';
contenedor.appendChild(espaciador);
const montadas = new Map(); // indice -> elemento
const reserva = []; // nodos libres para reutilizar
let rango = { inicio: 0, fin: -1 };
let programado = false;
function calcularRango() {
// Estas dos lecturas fuerzan layout. Es una vez por fotograma y
// es inevitable: la posicion de scroll solo la sabe el motor.
const arriba = contenedor.scrollTop;
const alto = contenedor.clientHeight;
const primera = Math.floor(arriba / altoFila);
const cuantas = Math.ceil(alto / altoFila);
return {
inicio: Math.max(0, primera - margen),
fin: Math.min(total - 1, primera + cuantas + margen),
};
}
function crearFila() {
const fila = document.createElement('div');
fila.className = 'fila-virtual';
fila.style.cssText =
'position:absolute;left:0;right:0;height:' + altoFila + 'px';
return fila;
}
function pintar() {
programado = false;
const r = calcularRango();
if (r.inicio === rango.inicio && r.fin === rango.fin) return;
// Desmontar lo que salio del rango y guardarlo para reutilizar.
for (const [i, nodo] of montadas) {
if (i < r.inicio || i > r.fin) {
montadas.delete(i);
nodo.remove();
reserva.push(nodo);
}
}
// Montar lo que entro, reutilizando nodos cuando se pueda.
const frag = document.createDocumentFragment();
for (let i = r.inicio; i <= r.fin; i++) {
if (montadas.has(i)) continue;
const nodo = reserva.pop() || crearFila();
nodo.style.top = i * altoFila + 'px';
nodo.setAttribute('aria-posinset', String(i + 1));
nodo.setAttribute('aria-setsize', String(total));
render(nodo, i);
montadas.set(i, nodo);
frag.appendChild(nodo);
}
if (frag.childNodes.length) contenedor.appendChild(frag);
rango = r;
}
function programar() {
if (programado) return;
programado = true;
requestAnimationFrame(pintar);
}
// passive: le dice al navegador que no vamos a llamar a
// preventDefault, asi no tiene que esperar a nuestro handler
// para empezar a desplazar.
contenedor.addEventListener('scroll', programar, { passive: true });
const ro = new ResizeObserver(programar);
ro.observe(contenedor);
pintar();
return {
refrescar: programar,
cambiarTotal(nuevoTotal) {
total = nuevoTotal;
espaciador.style.height = total * altoFila + 'px';
rango = { inicio: 0, fin: -1 };
for (const [, nodo] of montadas) nodo.remove();
montadas.clear();
programar();
},
irA(indice) {
contenedor.scrollTop = indice * altoFila;
programar();
},
destruir() {
contenedor.removeEventListener('scroll', programar);
ro.disconnect();
for (const [, nodo] of montadas) nodo.remove();
montadas.clear();
reserva.length = 0;
espaciador.remove();
},
};
}
Uso:
const datos = Array.from({ length: 50000 }, (_, i) => ({
id: i,
nombre: 'Registro ' + i,
}));
const lista = listaVirtual({
contenedor: document.querySelector('#lista'),
total: datos.length,
altoFila: 40,
render(nodo, i) {
nodo.textContent = datos[i].id + ' — ' + datos[i].nombre;
nodo.dataset.id = datos[i].id;
},
});
Con el CSS mínimo:
#lista {
height: 600px;
border: 1px solid #ccc;
contain: strict;
}
.fila-virtual {
display: flex;
align-items: center;
padding: 0 12px;
border-bottom: 1px solid #eee;
box-sizing: border-box;
contain: strict;
}
Cuatro decisiones del código merecen justificación explícita, porque son las que la gente cambia sin saber lo que rompe.
{ passive: true } en el listener de scroll. Sin esa opción, el navegador tiene que esperar a que tu manejador termine antes de saber si vas a llamar a preventDefault() y por tanto si puede desplazar. Con ella, el desplazamiento ocurre en el hilo del compositor sin esperarte. En una lista virtual, donde el manejador se dispara constantemente, es la diferencia entre un scroll suave y uno que se engancha con el dedo.
requestAnimationFrame en vez de trabajar en el propio evento. Los eventos de scroll pueden llegar varias veces entre dos fotogramas. Sin agrupar, recalculas y remontas más veces de las que se van a ver. Agrupando, haces como mucho un repintado por fotograma, que es exactamente lo que el usuario puede percibir.
Posición absoluta por índice en vez de desplazar un contenedor con transform. Las dos técnicas funcionan. La absoluta hace que cada fila conozca su sitio de forma independiente, lo que simplifica muchísimo el caso de alturas variables y hace que insertar o quitar filas no obligue a recolocar el resto. La de transform sobre un contenedor interno tiene la ventaja de mover todas las filas con una sola propiedad compuesta; la desventaja es que la posición de cada fila pasa a ser relativa a un origen que se mueve, y eso complica la gestión del foco y del anclaje.
contain: strict en la fila y en el contenedor. Cada fila tiene tamaño conocido y no se sale de sus bordes, así que la promesa es cierta, y a cambio el motor no propaga la invalidación entre filas ni fuera del contenedor. En una lista virtual esto no es una micro-optimización: sin ello, cada remontaje invalida el documento entero.
Reciclar nodos o recrearlos
El código de arriba recicla: cuando una fila sale del rango, su nodo va a una reserva y se reutiliza para la siguiente que entra. La alternativa es crear un nodo nuevo cada vez y dejar que el recolector se lleve los viejos.
Reciclar gana cuando la estructura de la fila es estable y solo cambian los datos: te ahorras la creación de objetos, la asignación de memoria y el trabajo del recolector, que en un scroll continuo puede ser una fuente real de tirones.
Reciclar pierde —y hay que saberlo— cuando las filas tienen estructura heterogénea, cuando llevan estado interno que hay que limpiar, o cuando trabajas con un framework que gestiona el ciclo de vida por su cuenta. En esos casos el reciclado manual introduce una clase de bug repugnante: una fila reutilizada que conserva una clase, un aria-expanded o un checked del registro anterior. Si reciclas, tu función render tiene que escribir todos los atributos que puedan variar, sin excepción, no solo los que cambian respecto al registro que estabas pintando.
Hay un tercer camino que casi nadie considera y que a menudo es el correcto: no reciclar y mantener el margen de sobredibujado alto. Con un margen de veinte filas en vez de cuatro, el número de montajes y desmontajes por segundo baja tanto que el coste de crear nodos deja de importar y te evitas toda la clase de bugs anterior. Cuesta memoria y no cuesta corrección.
El coste real de una lista virtual no son las cien líneas de arriba. Es que a partir de ese momento tú eres el dueño del scroll, y todo lo que el navegador te daba gratis porque los nodos existían deja de funcionar y tienes que reimplementarlo. La búsqueda del navegador ya no encuentra la fila cuatro mil, porque no está en el DOM. La combinación de teclas que va al final del documento va al final del espaciador, no al final de los datos. Un ancla que apunte a una fila concreta no encuentra nada. Imprimir la página imprime treinta filas. Seleccionar texto arrastrando desde la fila diez hasta la fila mil selecciona lo que hay montado y nada más. Un lector de pantalla anuncia “lista de treinta elementos” salvo que le digas explícitamente que son diez mil. El buscador de una extensión del navegador no encuentra nada. Y en cuanto el usuario pulsa el botón de atrás y vuelve, la posición de scroll se restaura sobre un contenedor que todavía no ha montado nada y acaba en un sitio arbitrario. Ninguno de esos problemas es difícil por separado —el ARIA son dos atributos, la búsqueda propia es un campo de texto, la restauración de scroll es un sessionStorage— pero son ocho problemas, y ninguna librería de virtualización te los resuelve todos. Esta es la razón por la que la decisión de virtualizar no es una decisión de rendimiento sino de producto, y por la que el orden correcto de evaluación es: primero intentar no tener diez mil filas, después intentar que el navegador las aguante con containment y content-visibility, y solo entonces virtualizar sabiendo que asumes la deuda entera. He visto equipos virtualizar una tabla de mil doscientas filas que funcionaba perfectamente y perder tres semanas arreglando la accesibilidad, la impresión y el botón de atrás. El ahorro medido era de once milisegundos.
Lo mínimo para que sea usable
Cuatro añadidos que no son opcionales si la lista va a producción.
ARIA de conjunto. Sin ellos, un lector de pantalla anuncia el número de filas montadas, que es una mentira. Con ellos, anuncia la posición real.
<div id="lista" role="listbox" aria-label="Resultados">
<!-- cada fila: role="option" aria-posinset="4501" aria-setsize="50000" -->
</div>
Para tablas el equivalente es role="grid" en el contenedor con aria-rowcount, y role="row" con aria-rowindex en cada fila. El índice es el de los datos, no el de la posición en el DOM.
Gestión del foco. Si el usuario tiene el foco en la fila cuatro mil y el scroll la desmonta, el foco se pierde y vuelve al body, lo que en la práctica manda al usuario al principio del documento. La solución es no desmontar nunca el elemento que tiene el foco: comprueba document.activeElement antes de reciclar y salta esa fila.
Restauración de posición. Guarda scrollTop al salir y restáuralo al volver, después de haber montado. Si lo restauras antes de que el espaciador tenga su altura, el navegador lo recorta al máximo disponible, que en ese momento es cero.
Un buscador propio. Si la lista tiene más filas de las que caben, el usuario va a querer buscar, y la búsqueda del navegador no le va a servir. Un campo de filtro sobre el array de datos es imprescindible, y además es lo correcto: filtrar sobre los datos es más rápido y más completo que buscar sobre el DOM.
El caso de alturas variables, que es donde la implementación se complica de verdad, tiene su propia lección: alturas variables y el scroll que salta.
- Monta la lista virtual con cincuenta mil elementos y comprueba en el inspector que hay menos de cuarenta nodos.
- Quita
{ passive: true }y compara el scroll con el dedo en un móvil real. - Quita el margen de sobredibujado y haz scroll rápido. Documenta lo que ves.
- Recicla un nodo que tenía una clase aplicada por interacción y observa cómo aparece en el registro equivocado. Arregla
renderpara que sea total. - Añade
aria-posinsetyaria-setsizey verifica con un lector de pantalla que anuncia la posición correcta.