wandres.dev
LISTAS GRANDES · Virtualización y paginación

Alturas variables y el scroll que salta

Cómo mantener las posiciones de una lista virtual cuando cada fila mide distinto: estimar y corregir, sumas parciales con un árbol de Fenwick, y la compensación del scroll.

⏱ 21 min

Con alturas fijas, la posición de la fila número cuatro mil es una multiplicación. Con alturas variables es la suma de las cuatro mil anteriores, y no puedes conocerlas sin haberlas renderizado. Toda la complejidad de una lista virtual de verdad sale de ese hueco, y las soluciones que existen se diferencian en dónde meten el error y con qué rapidez lo corrigen. Esta es la parte que las librerías esconden y que hay que entender antes de elegir una.

🎯 Al terminar esta lección sabrás
  • Explicar por qué la posición de un índice deja de ser calculable con alturas variables.
  • Implementar un índice de sumas parciales con actualización y búsqueda logarítmicas.
  • Medir alturas reales sin provocar layout thrashing ni bucles de observador.
  • Compensar la posición de scroll cuando se corrige la altura de algo que está por encima del punto de vista.

Por qué se rompe el modelo

La lista virtual de altura fija se apoya en dos funciones triviales: dado un índice, su posición vertical es indice * alto; dada una posición vertical, el índice es Math.floor(y / alto). Ambas son constantes en tiempo y exactas.

Con alturas variables, la primera se convierte en la suma de las alturas de todos los índices anteriores, y la segunda en una búsqueda sobre esa suma acumulada. Ninguna de las dos es exacta, porque las alturas de las filas que nunca se han renderizado no se conocen. Y no se pueden precalcular: la altura de una fila depende de la fuente cargada, del ancho disponible, de si la imagen resolvió, del idioma y de la longitud del texto tras el ajuste de línea.

La consecuencia inmediata es que la altura total del contenido —y por tanto la longitud de la barra de scroll— es una estimación que cambia mientras el usuario se desplaza. Es el mismo problema que en contain-intrinsic-size, con una diferencia crucial: allí lo mantenía el navegador y aquí lo mantienes tú, sin el amortiguador del anclaje de scroll, porque las filas posicionadas en absoluto están explícitamente excluidas de poder ser ancla. Si vas a virtualizar con posición absoluta, la compensación del scroll es tuya, obligatoriamente.

El esquema de trabajo es siempre el mismo, cambian los detalles:

  1. Empiezas con una estimación por fila.
  2. Cuando una fila se monta, mides su altura real y la guardas.
  3. Actualizas la altura total y las posiciones de las siguientes.
  4. Si lo que corregiste estaba por encima del punto de vista, compensas scrollTop para que el usuario no vea moverse lo que está mirando.

El índice de sumas parciales

El paso 3 es el que decide el rendimiento. Con un array plano de alturas, calcular la posición del índice i es sumar i valores, y actualizar una altura invalida todas las posiciones posteriores: cuadrático si lo haces en cada scroll de una lista de cincuenta mil.

La estructura adecuada es un árbol de Fenwick, también llamado árbol binario indexado: mantiene sumas parciales con actualización y consulta en tiempo logarítmico, ocupa un solo array de números y se construye en tiempo lineal. Es la estructura correcta para este problema y cabe en cuarenta líneas.

// Indice de alturas con sumas parciales. Todas las operaciones que
// importan son O(log n).
export class IndiceDeAlturas {
  constructor(n, estimacion) {
    this.n = n;
    this.estimacion = estimacion;
    this.alto = new Float64Array(n).fill(estimacion);
    this.medida = new Uint8Array(n);       // 1 si la altura es real
    this.arbol = new Float64Array(n + 1);

    // Construccion en O(n): propagar cada nodo a su padre.
    for (let i = 1; i <= n; i++) {
      this.arbol[i] += estimacion;
      const padre = i + (i & -i);
      if (padre <= n) this.arbol[padre] += this.arbol[i];
    }
  }

  // Suma de las alturas de los indices [0, i). Posicion superior de i.
  offset(i) {
    let suma = 0;
    for (let k = i; k > 0; k -= k & -k) suma += this.arbol[k];
    return suma;
  }

  total() {
    return this.offset(this.n);
  }

  fijar(i, nuevoAlto) {
    const delta = nuevoAlto - this.alto[i];
    this.medida[i] = 1;
    if (delta === 0) return 0;
    this.alto[i] = nuevoAlto;
    for (let k = i + 1; k <= this.n; k += k & -k) this.arbol[k] += delta;
    return delta;
  }

  // Mayor indice cuya posicion superior es <= y. Busqueda binaria
  // sobre el propio arbol, sin recorrer el array de alturas.
  indiceEn(y) {
    if (this.n === 0) return 0;
    let pos = 0;
    let resto = y;
    let paso = 1 << (31 - Math.clz32(this.n));
    for (; paso > 0; paso >>= 1) {
      const siguiente = pos + paso;
      if (siguiente <= this.n && this.arbol[siguiente] <= resto) {
        pos = siguiente;
        resto -= this.arbol[siguiente];
      }
    }
    return Math.min(pos, this.n - 1);
  }

  // Cuantas filas siguen siendo estimaciones. Util para saber cuanto
  // te puedes fiar del total.
  sinMedir() {
    let c = 0;
    for (let i = 0; i < this.n; i++) if (!this.medida[i]) c++;
    return c;
  }
}

Con esto, el ciclo de una lista virtual de alturas variables es el mismo que el de alturas fijas, sustituyendo dos líneas: el rango visible sale de indiceEn(scrollTop) y la posición de cada fila de offset(i).

function calcularRango() {
  const arriba = contenedor.scrollTop;
  const alto = contenedor.clientHeight;
  const primera = indice.indiceEn(arriba);
  let ultima = primera;
  let acumulado = indice.offset(primera);
  while (ultima < indice.n - 1 && acumulado < arriba + alto) {
    acumulado += indice.alto[ultima];
    ultima++;
  }
  return {
    inicio: Math.max(0, primera - margen),
    fin: Math.min(indice.n - 1, ultima + margen),
  };
}

Medir sin provocar thrashing

Medir la altura real de cada fila montada es una lectura geométrica por fila, y montarlas es una escritura. Hecho ingenuamente es exactamente el patrón del layout síncrono forzado: montar, medir, montar, medir.

Hay dos formas correctas.

Dos fases dentro del mismo fotograma. Monta todas las filas del rango, y solo después recorre las montadas leyendo offsetHeight. Un layout forzado para todo el lote en vez de uno por fila.

Un ResizeObserver por fila. Es la opción mejor, y no solo por el coste: captura los cambios de altura que ocurren después del montaje, que son los que más daño hacen. Una imagen que resuelve, una fuente que carga, un texto que se traduce, un acordeón que el usuario abre. Con la medición en dos fases te enteras una vez; con el observador te enteras siempre.

const observador = new ResizeObserver((entradas) => {
  let compensacion = 0;
  const arriba = contenedor.scrollTop;

  for (const entrada of entradas) {
    const i = Number(entrada.target.dataset.indice);
    // borderBoxSize incluye borde y padding, que es lo que ocupa
    // realmente en el flujo. contentRect no los incluye.
    const alto = entrada.borderBoxSize?.[0]?.blockSize
              ?? entrada.contentRect.height;
    if (Math.abs(alto - indice.alto[i]) < 0.5) continue;

    const posicion = indice.offset(i);
    const delta = indice.fijar(i, alto);
    // Si la fila corregida esta por encima de lo que el usuario mira,
    // todo lo visible acaba de desplazarse ese delta.
    if (posicion < arriba) compensacion += delta;
  }

  espaciador.style.height = indice.total() + 'px';
  if (compensacion !== 0) contenedor.scrollTop = arriba + compensacion;
  reposicionar();
});

Nótese que el observador acumula la compensación de todas las entradas del lote y la aplica una vez. Aplicarla dentro del bucle produce lecturas y escrituras alternadas de scrollTop, que fuerzan layout cada una.

Y una advertencia que ahorra una tarde: si dentro del callback escribes algo que cambia la altura de las filas observadas, entras en un bucle y el navegador emite ResizeObserver loop completed with undelivered notifications. Escribir top no cambia la altura; escribir height sí. Reposiciona con top, nunca con height.

El scroll que salta

Este es el síntoma que define una lista virtual mal hecha: el usuario baja, el contenido se corrige, y lo que estaba mirando se mueve solo. En casos malos entra en una realimentación —la corrección mueve el contenido, el movimiento monta filas nuevas, las filas nuevas se corrigen— y la lista se vuelve loca.

La causa es siempre la misma: corregiste la altura de algo que está por encima del punto de vista y no compensaste. La posición de scroll es una distancia desde el principio del contenido; si el contenido de arriba crece cien píxeles y scrollTop no cambia, lo que se ve baja cien píxeles.

La compensación es la de la función de arriba y hay que aplicarla siempre, con tres precauciones.

Desactiva el anclaje del navegador si tus filas están en flujo. Si en vez de posición absoluta usas filas en flujo normal con un relleno superior, el navegador intentará anclar por su cuenta y tu compensación se sumará a la suya. overflow-anchor: none en el contenedor deja el control en un solo sitio.

#lista { overflow-anchor: none; }

Cuidado con el tope. scrollTop se recorta al máximo disponible. Si compensas hacia arriba cuando el usuario está en el fondo de la lista y la altura total acaba de encoger, el navegador recorta y la compensación se pierde. Comprueba el máximo antes de escribir.

No compensas si la corrección está por debajo. Solo importan las filas cuya posición superior es menor que scrollTop. Corregir una fila que está debajo del punto de vista cambia la altura total y por tanto la barra de scroll, pero no mueve nada de lo que el usuario ve, y compensar en ese caso produce el salto en vez de evitarlo.

Queda un problema sin solución limpia: saltar a un índice concreto. Con alturas fijas, scrollTop = i * alto y ya está. Con alturas variables, indice.offset(i) devuelve una posición basada en estimaciones para todo lo que no se ha medido, así que aterrizas cerca pero no encima, y al montar las filas reales la posición se corrige y el usuario ve un ajuste. La mitigación estándar es en dos pasos: salta a la estimación, deja que se monte y se mida el entorno, y en el fotograma siguiente ajusta a la posición ya corregida. Sigue siendo visible, pero es un ajuste pequeño en vez de un salto.

La altura total no tiene que ser correcta; solo tiene que ser estable donde el usuario está mirando

Toda la complejidad de esta lección viene de intentar mantener una magnitud global —la posición absoluta de cada índice— que el usuario nunca observa directamente. Lo que el usuario observa es dos cosas: que lo que está mirando no se mueva solo, y que la barra de scroll no dé tirones absurdos. La primera es local; la segunda tolera bastante error. Ese desajuste entre lo que mantienes y lo que se observa es la pista de que existe un diseño mejor, y existe: las implementaciones modernas de listas virtuales no mantienen posiciones absolutas. Mantienen un ancla —un índice y su desplazamiento respecto al borde superior del contenedor— y colocan todo lo demás relativo a ella. La fila ancla está en pantalla, así que su altura es real, medida, no estimada. Las filas que la rodean también están montadas y también son reales. Y las que están a tres mil índices de distancia no se colocan en ningún sitio, porque no hace falta: no existen. La altura total se sigue estimando para dar longitud a la barra, pero se convierte en un dato meramente cosmético que puede estar equivocado sin consecuencias, en vez de ser la base sobre la que se calcula todo lo demás. La diferencia práctica es enorme y no está en el rendimiento: está en que el error deja de propagarse hacia lo que el usuario ve. Con posiciones absolutas, cualquier corrección en un índice remoto se transmite a la posición de la fila que el usuario está leyendo, y hay que cancelarla con compensaciones. Con un ancla, una corrección remota no puede afectar a lo visible porque lo visible no depende de ella. Esta es una lección de diseño de sistemas que va mucho más allá de las listas: cuando te encuentres compensando un error para que no se propague, pregúntate por qué el error estaba en el camino de lo que importa. Casi siempre la respuesta es que elegiste un sistema de coordenadas global para un problema que era local.

⚔️ Alturas variables de verdad
  1. Implementa IndiceDeAlturas y verifica con un test que indiceEn(offset(i)) devuelve i para mil índices al azar.
  2. Monta una lista con alturas aleatorias entre 40 y 200 píxeles y comprueba que la barra de scroll se estabiliza a medida que se miden filas.
  3. Provoca el salto a propósito: corrige la altura de una fila lejana por encima del punto de vista sin compensar. Documenta lo que ves.
  4. Añade una imagen sin aspect-ratio en cada fila y observa cómo el ResizeObserver capta las correcciones cuando cargan.
  5. Implementa el salto a un índice en dos pasos y mide cuánto se desvía el primer aterrizaje en función del porcentaje de filas ya medidas.