Quedan dos frentes de la línea base y este es el que domina la tabla: la fila 5 sigue en 310 ms por render y la fila 9 en 7.812 nodos, con el Bottom-Up de 09-01 señalando que 600 llamadas a pintarTarjeta cuestan 186 ms de tiempo propio y que hay 41 recálculos de estilo donde debería haber uno. Ese trabajo no es JavaScript lento —ya lo optimizaste en 09-02— ni memoria retenida —ya la liberaste en 09-03—: es el coste de hablar con el DOM. Esta lección explica por qué ese diálogo es caro: el pipeline de renderizado del navegador y qué operaciones despiertan cada una de sus fases; qué es exactamente un reflow y qué lo fuerza de forma síncrona, cerrando el aviso que quedó abierto en 06-02; cómo se reconoce y se elimina el layout thrashing, que es de donde salen esos 41 recálculos; cuánto aporta de verdad agrupar inserciones con DocumentFragment y replaceChildren (06-05) y actualizar con reconciliar en vez de redibujar (06-06), con una valoración honesta de cada cosa; por qué solo transform y opacity se animan gratis; qué hacen will-change, contain y content-visibility; y, por fin, la virtualización: pintar solo las filas que se ven, implementada dos veces —con IntersectionObserver y con una ventana calculada—, con sus contrapartidas reales sobre la mesa. Al terminar, las filas 5 y 9 estarán cerradas.

Contenido

  1. Por qué el DOM es caro
  2. El pipeline de renderizado, fase a fase
  3. Qué operación despierta qué fase
  4. Reflow, repaint y el cálculo de estilo síncrono forzado
  5. La lista negra: qué lecturas fuerzan un reflow
  6. Layout thrashing: el bucle que alterna lectura y escritura
  7. El patrón read-then-write, medido sobre las 600 tarjetas
  8. Agrupar inserciones: DocumentFragment y replaceChildren
  9. Actualizar en vez de redibujar: qué aporta de verdad reconciliar
  10. Escribir solo lo que cambia
  11. Animar barato: transform y opacity
  12. Aislar el trabajo: will-change, contain y content-visibility
  13. Sincronizar con requestAnimationFrame (y no con setTimeout)
  14. Virtualización I: centinelas con IntersectionObserver
  15. Virtualización II: la ventana calculada con altura fija
  16. Las contrapartidas de virtualizar
  17. Delegación de eventos, ahora con números
  18. Las filas 5 y 9, cerradas
  19. Síntoma → causa probable → solución
  20. Errores Comunes y Consejos
  21. Ejercicios
  22. Conclusión

  1. Por qué el DOM es caro

Hay una frase que se repite mucho y que, dicha así, es falsa: «el DOM es lento». El DOM no es lento; es otra cosa. Y confundir ambas ideas lleva a optimizaciones equivocadas.

Cuando escribes li.textContent = 'Rediseñar la sala polivalente' no estás asignando una propiedad de un objeto JavaScript. Estás cruzando una frontera: tu motor de JavaScript (V8) le está pidiendo algo al motor de renderizado (Blink), que mantiene su propia representación del documento en C++, con sus propios árboles de estilo y de diseño. Esa llamada tiene tres costes distintos que conviene separar:

Coste Qué es Magnitud típica
Cruzar la frontera El salto de JavaScript a la implementación nativa, con conversión de tipos Nanosegundos. Casi siempre irrelevante
Invalidar Marcar que el estilo, la geometría o los píxeles de una zona han quedado obsoletos Muy barato, pero se acumula
Recalcular Rehacer de verdad el estilo, el diseño o el pintado Milisegundos. Aquí está todo

La clave, y es la idea central de la lección, es que invalidar y recalcular están desacoplados. El navegador no recalcula nada cuando tú escribes: apunta que hay trabajo pendiente y sigue ejecutando tu código. Recalculará una sola vez, justo antes de pintar el siguiente fotograma, por muchas escrituras que hayas hecho. Es un mecanismo de agrupación automática, y es excelente.

Todo lo que hace lenta la manipulación del DOM consiste, en el fondo, en romper esa agrupación: obligar al navegador a recalcular en mitad de tu código, una y otra vez, cuando podría haberlo hecho una sola vez al final.

// Esto NO provoca 600 recálculos. Provoca 600 invalidaciones y UN recálculo.
for (const li of tarjetas) {
  li.classList.add('tarea--resaltada');
}
// ← aquí, antes del siguiente fotograma, un único Recalculate Style
// ✗ Esto SÍ provoca 600 recálculos, y es 40× más lento
for (const li of tarjetas) {
  li.classList.add('tarea--resaltada');
  console.log(li.offsetHeight);        // ← lectura: obliga a recalcular AHORA
}

La diferencia entre los dos bucles es una sola línea. Entender por qué esa línea cuesta tanto exige mirar el pipeline.

  1. El pipeline de renderizado, fase a fase

Para convertir HTML, CSS y JavaScript en píxeles, el navegador ejecuta una secuencia fija de fases. Se llama pipeline de renderizado y se ejecuta, como mucho, una vez por fotograma.

flowchart LR
    JS["1 · JavaScript<br/>tu código muta<br/>el DOM y el CSSOM"] --> E["2 · Estilo<br/><i>Recalculate Style</i><br/>qué reglas aplican<br/>a cada elemento"]
    E --> L["3 · Layout<br/><i>Reflow</i><br/>dónde va cada caja<br/>y cuánto mide"]
    L --> P["4 · Pintado<br/><i>Paint</i><br/>rellenar píxeles<br/>en cada capa"]
    P --> C["5 · Composición<br/><i>Composite</i><br/>la GPU apila<br/>las capas"]
    C --> F(["Fotograma<br/>en pantalla"])

Qué hace exactamente cada fase:

1 · JavaScript. Tu código se ejecuta y modifica el árbol del DOM, las clases, los estilos en línea o el contenido. También pueden desencadenar cambios el CSS (una animación, un :hover) o la Web Animations API, sin que haya JavaScript por medio.

2 · Estilo (Recalculate Style). El navegador decide, para cada elemento afectado, qué reglas CSS le aplican y cuál es el valor final de cada propiedad. El coste crece con el número de elementos invalidados y con la complejidad de los selectores. En DevTools aparece en morado.

3 · Layout (también reflow). Con los estilos ya resueltos, el navegador calcula la geometría: posición y tamaño de cada caja. Es la fase más cara porque es global por naturaleza: cambiar el ancho de un elemento puede desplazar a todos sus hermanos, a su padre y a media página. También en morado.

4 · Pintado (Paint). Se rellenan los píxeles: fondos, textos, bordes, sombras. No se dibuja directamente en pantalla, sino en una o varias capas (layers). En DevTools, verde.

5 · Composición (Composite). Las capas se apilan en el orden correcto y se envían a la pantalla. Esta fase la hace la GPU y es baratísima. También verde.

Y aquí está la observación que gobierna la mitad de las decisiones de esta lección: el pipeline se puede entrar por la mitad. No todos los cambios obligan a recorrer las cinco fases.

flowchart TD
    A["Cambias width, top, font-size…"] --> B["Estilo → Layout → Pintado → Composición"]
    C["Cambias color, background-color,<br/>box-shadow…"] --> D["Estilo → Pintado → Composición<br/><b>(se salta Layout)</b>"]
    E["Cambias transform, opacity"] --> F["Estilo → Composición<br/><b>(se salta Layout y Pintado)</b>"]
    style B fill:#fdd,stroke:#c00
    style D fill:#ffd,stroke:#c90
    style F fill:#dfd,stroke:#090

Cuantas menos fases entren en juego, más barato es el cambio. Esa es toda la teoría de la optimización visual, y de ella se derivan las reglas prácticas de los apartados 11 y 12.

  1. Qué operación despierta qué fase

Traducido a operaciones concretas de tu código, la tabla queda así. Vale la pena tenerla a mano.

Lo que haces Estilo Layout Pintado Composición Coste relativo
elemento.remove() / append() Alto
classList.add() que cambia el tamaño Alto
style.width / style.top / style.margin Alto
textContent = otro texto Alto
style.fontSize Alto
classList.add() que solo cambia color Medio
style.backgroundColor / boxShadow Medio
style.visibility Medio
style.transform / style.opacity Bajo
Escribir en un nodo fuera del árbol Casi cero
dataset.x = ... sin selector CSS que lo use Bajo

Tres consecuencias que ya se pueden aplicar a Nómada Tareas hoy mismo:

  • Cambiar el texto de una tarjeta es caro, porque el texto puede cambiar de ancho y eso es geometría. Actualizar .tarea__meta en 600 tarjetas cuando solo han cambiado dos es trabajo tirado.
  • Construir fuera del árbol es gratis. Todo lo que hagas sobre un <li> recién creado y todavía no insertado no cuesta nada: ni estilo, ni layout, ni pintado. Es el argumento de fondo de DocumentFragment (06-05).
  • display: none y visibility: hidden no son lo mismo. El primero saca el elemento del layout (y el cambio es caro, pero luego ese elemento deja de costar); el segundo lo mantiene ocupando sitio y solo evita el pintado.

  1. Reflow, repaint y el cálculo de estilo síncrono forzado

Fijemos el vocabulario, porque se usa mal constantemente:

  • Reflow (o layout): recalcular la geometría. Es la fase 3.
  • Repaint: rellenar píxeles de nuevo sin recalcular geometría. Es la fase 4 sin la 3.
  • Cálculo de estilo síncrono forzado (forced synchronous layout): que el navegador tenga que ejecutar las fases 2 y 3 en mitad de tu JavaScript, porque le has pedido un dato que solo puede darte si están al día.

El tercero es el que nos interesa. Vuelve al mecanismo del apartado 1: cuando escribes, el navegador solo invalida. Pero cuando lees una propiedad geométrica, el navegador tiene un contrato que cumplir: debe devolverte el valor correcto en ese instante. Si hay invalidaciones pendientes, no le queda otro remedio que detenerlo todo, recalcular estilo y diseño, y entonces responderte.

const li = $('[data-id="3"]');

li.style.width = '400px';            // 1 · invalida. No recalcula nada. Barato
console.log(li.offsetWidth);         // 2 · LEE geometría → recálculo forzado AQUÍ, síncrono

DevTools lo señala de forma explícita: en el diagrama de llamas aparece un bloque morado dentro de tu bloque amarillo, con un triángulo de aviso y el texto «Forced reflow is a likely performance bottleneck». Si lo ves, tienes un problema del apartado 6.

Un matiz importante que ahorra falsos diagnósticos: un recálculo forzado aislado no es un problema. Medir una vez la altura de una columna para colocar un menú cuesta unos 1–2 ms en el portátil de referencia con CPU 4×, y no lo vas a notar. El problema aparece cuando ese recálculo se ejecuta dentro de un bucle, porque cada vuelta invalida lo que la vuelta anterior acababa de calcular. Eso tiene nombre propio, y es el apartado 6.

  1. La lista negra: qué lecturas fuerzan un reflow

Esta es la lista que cierra el aviso de 06-02. No hace falta memorizarla entera; hace falta reconocer la familia: todo lo que devuelve una medida, una posición o un estilo ya resuelto.

Categoría Miembros Notas
Geometría del elemento offsetTop, offsetLeft, offsetWidth, offsetHeight, offsetParent Enteros redondeados
Geometría del cliente clientTop, clientLeft, clientWidth, clientHeight Sin bordes ni barras
Desplazamiento scrollTop, scrollLeft, scrollWidth, scrollHeight Escribir scrollTop también fuerza
Rectángulos getBoundingClientRect(), getClientRects() Decimales, incluye transform
Estilo resuelto getComputedStyle(el) y leer cualquiera de sus propiedades El más traicionero
Texto renderizado innerText textContent no fuerza; innerText
Foco y desplazamiento focus(), scrollIntoView(), scrollBy(), scrollTo() Necesitan saber dónde está el elemento
Ventana window.scrollY, innerWidth, innerHeight, getComputedStyle
Otros elemento.checkVisibility(), Range.getBoundingClientRect()

Tres avisos que casi nadie tiene presentes:

getComputedStyle es peor de lo que parece. No fuerza el reflow al llamarlo, sino al leer una propiedad del objeto devuelto, y fuerza tanto el recálculo de estilo como el de diseño si la propiedad depende de la geometría. Peor aún: en la práctica es imposible predecir qué propiedades lo necesitan, así que la regla segura es tratarlo entero como una lectura cara.

focus() es una lectura disfrazada de escritura. El navegador necesita saber si el elemento es visible y dónde está para poder desplazar hasta él. En 06-06 se restauraba el foco tras el render; si eso ocurre en mitad de un bucle de escrituras, tienes un recálculo forzado por vuelta.

textContent no fuerza; innerText sí. innerText devuelve el texto tal como se ve, así que necesita saber qué está oculto por CSS: eso es estilo, y a veces diseño. textContent devuelve el texto del árbol y no consulta nada. Es un motivo más, además de los de 06-02, para usar textContent por defecto.

  1. Layout thrashing: el bucle que alterna lectura y escritura

Ya tenemos el mecanismo. Vamos al caso real de Nómada Tareas, que es de donde salen los 41 recálculos de estilo y los 38 layout del Bottom-Up de 09-01.

En el tablero de 600 tareas hay 40 vencidas. La tarjeta de una tarea vencida lleva una insignia con el texto «vencida hace N días», y si el título es largo la insignia se sale de la caja. La solución que se puso en su día fue medir el título y, si no cabía, reducir la tarjeta a una línea. Este es el código, tal cual, en la TableroVista de 09-03:

// ✗ js/vista/tablero-vista.js — el bucle tóxico
#ajustarAlturas(visibles) {
  for (const tarea of visibles) {
    if (!tarea.estaVencida(this.#estado.hoy)) continue;    // solo las 40 vencidas

    const li = this.#nodosPorId.get(tarea.id);
    if (li === undefined) continue;

    const alto = li.querySelector('.tarea__titulo').offsetHeight;   // ← LECTURA
    li.style.setProperty('--alto-titulo', `${alto}px`);             // ← ESCRITURA
    li.classList.toggle('tarea--titulo-largo', alto > 22);          // ← ESCRITURA
  }
}

Míralo con el modelo del apartado 4. Vuelta 1: se lee offsetHeight, el navegador recalcula (no hay nada pendiente la primera vez, así que es barato) y responde. Después se escribe una variable CSS y se cambia una clase: invalidado. Vuelta 2: se lee offsetHeight otra vez, pero ahora sí hay trabajo pendiente, así que el navegador tiene que recalcular estilo y diseño de todo el subárbol invalidado antes de responder. Y así cuarenta veces.

flowchart TD
    subgraph MAL["✗ Alternando: 40 recálculos"]
        direction TB
        R1["leer offsetHeight"] --> W1["escribir clase"]
        W1 -->|"invalidado"| R2["leer offsetHeight<br/><b>→ recálculo forzado</b>"]
        R2 --> W2["escribir clase"]
        W2 -->|"invalidado"| R3["leer offsetHeight<br/><b>→ recálculo forzado</b>"]
        R3 --> D1["… × 40"]
    end
    subgraph BIEN["✓ Agrupando: 1 recálculo"]
        direction TB
        RR["leer las 40 alturas<br/><b>→ 1 recálculo</b>"] --> WW["escribir las 40 clases"]
        WW --> FF["el navegador recalcula<br/>una vez, antes de pintar"]
    end
    style R2 fill:#fdd,stroke:#c00
    style R3 fill:#fdd,stroke:#c00
    style RR fill:#dfd,stroke:#090

Cuarenta lecturas forzadas, más una del scrollTop que se guarda para conservar el desplazamiento (06-06): 41 recálculos de estilo. Y 38 layout, tres menos porque en tres vueltas la escritura anterior no llegó a invalidar la geometría. Los números del Bottom-Up encajan exactamente:

Entrada del Bottom-Up Apariciones Tiempo Por aparición
Recalculate Style 41 74 ms 1,80 ms
Layout 38 63 ms 1,66 ms
Total del thrashing 137 ms de los 310 ms del render

Casi la mitad del render se va en cuarenta lecturas de offsetHeight. Y lo más importante: no es un problema de cuánto código ejecutas, sino de en qué orden lo ejecutas. La misma cantidad de trabajo, reordenada, cuesta una fracción.

Este es el patrón que en 06-02 se llamó layout thrashing y se remitió aquí. Ya sabes reconocerlo: una lectura de la lista negra del apartado 5 dentro de un bucle que también escribe.

  1. El patrón read-then-write, medido sobre las 600 tarjetas

La solución no es leer menos ni escribir menos: es separar las dos fases. Primero todas las lecturas, después todas las escrituras. El navegador recalcula una vez al principio del bloque de lecturas y una vez, por su cuenta, antes de pintar.

// ✓ js/vista/tablero-vista.js — read-then-write
#ajustarAlturas(visibles) {
  const vencidas = visibles.filter((t) => t.estaVencida(this.#estado.hoy));

  // ── FASE 1 · LEER. Ninguna escritura aquí dentro. Un solo recálculo forzado ──
  const medidas = vencidas.map((tarea) => {
    const li = this.#nodosPorId.get(tarea.id);
    return li === undefined
      ? null
      : { li, alto: li.querySelector('.tarea__titulo').offsetHeight };
  }).filter(Boolean);

  // ── FASE 2 · ESCRIBIR. Ninguna lectura aquí dentro. Solo invalidaciones ──
  for (const { li, alto } of medidas) {
    li.style.setProperty('--alto-titulo', `${alto}px`);
    li.classList.toggle('tarea--titulo-largo', alto > 22);
  }
}

El cambio es puramente estructural: mismas lecturas, mismas escrituras, mismo resultado visual. La medición, con banco() de 09-01 (5 de calentamiento, 15 repeticiones, CPU 4×, 600 tareas de las cuales 40 vencidas):

Variante Recálculos forzados Recalculate Style Layout #ajustarAlturas
Alternando lectura y escritura 41 74 ms 63 ms 141 ms
Read-then-write 1 2,4 ms 1,7 ms 4,3 ms

Treinta y tres veces más rápido sin cambiar una sola operación. Y el efecto sobre la fila 5:

Medida Antes Después
render() completo, 600 tareas 310 ms 179 ms

De 310 a 179 ms con una reordenación. Es, con diferencia, la mejor relación entre beneficio y esfuerzo de toda la lección.

Una advertencia honesta sobre este patrón: es frágil. Nada impide que dentro de seis meses alguien meta una lectura en la fase de escritura, y el rendimiento se desplomará sin que ninguna prueba falle. Hay tres defensas razonables:

Primera: un comentario que explique la razón, no lo que hace el código. // FASE 1 · LEER. Ninguna escritura aquí dentro. es exactamente eso.

Segunda: encapsular el patrón para que la separación no dependa de la disciplina de quien edite.

// js/vista/dom.js

/**
 * Ejecuta primero TODAS las lecturas y después TODAS las escrituras,
 * para que el navegador haga como mucho un recálculo forzado.
 *
 * @param {Function} leer      Debe devolver los datos medidos. No debe escribir.
 * @param {Function} escribir  Recibe lo devuelto por `leer`. No debe leer geometría.
 */
export function leerYEscribir(leer, escribir) {
  const medidas = leer();
  escribir(medidas);
  return medidas;
}

Tercera: una prueba que cuente los recálculos. No se puede contar directamente desde Jest con jsdom (que no hace layout), pero sí desde Cypress o Puppeteer, observando las entradas de rendimiento del navegador. Es materia del apartado 20, en los consejos.

  1. Agrupar inserciones: DocumentFragment y replaceChildren

En 06-05 aprendiste DocumentFragment con una promesa: «la medición seria es el tema de 09-04». Toca cumplirla, y la respuesta va a ser menos espectacular de lo que la mayoría de los tutoriales sugiere.

El argumento clásico es que insertar 600 elementos uno a uno provoca 600 reflows. Eso era cierto en 2008 y hoy es falso, por el mecanismo del apartado 1: el navegador invalida y agrupa. Midámoslo, con las tres variantes, sobre el pintado inicial de las 600 tarjetas:

// A · Inserción directa, una a una
for (const tarea of visibles) lista.append(pintarTarjeta(tarea, null, hoy));

// B · DocumentFragment
const fragmento = document.createDocumentFragment();
for (const tarea of visibles) fragmento.append(pintarTarjeta(tarea, null, hoy));
lista.append(fragmento);

// C · replaceChildren con spread (la forma adoptada en 06-05)
lista.replaceChildren(...visibles.map((t) => pintarTarjeta(t, null, hoy)));

// D · Inserción directa CON una lectura de geometría en medio
for (const tarea of visibles) {
  lista.append(pintarTarjeta(tarea, null, hoy));
  lista.scrollHeight;                    // ← una sola línea lo cambia todo
}
Variante 600 tarjetas Frente a A
A · append uno a uno 131 ms
B · DocumentFragment 120 ms 1,09×
C · replaceChildren(...) 119 ms 1,10×
D · append + lectura por vuelta 2.140 ms 0,06×

Léelo con cuidado, porque las conclusiones son dos y van en direcciones distintas.

El fragmento aporta un 9 %. Real, medible y gratis, pero no es lo que arregla nada. Los navegadores modernos ya agrupan el trabajo de diseño, así que la diferencia entre 600 inserciones y una se reduce al coste de las propias llamadas y a algunas invalidaciones que se pueden fusionar mejor. Nueve por ciento no es despreciable, pero quien crea que DocumentFragment es «la» optimización del DOM está mirando al sitio equivocado.

La lectura en el bucle multiplica por dieciséis. La variante D es el mismo trabajo con una línea de más, y cuesta 2,1 segundos. Ahí está el orden de magnitud, y es el mismo del apartado 6: el enemigo no es tocar el DOM muchas veces, es intercalar lecturas.

Dicho todo esto, sigue habiendo dos motivos sólidos para usar replaceChildren(...):

  • Legibilidad. Una línea que dice «el contenido de esta lista es exactamente esto» se lee mejor que un bucle con un append.
  • Atomicidad visual. Vaciar y rellenar en dos operaciones separadas puede dejar, en casos raros con trabajo asíncrono en medio, un fotograma con la lista vacía. replaceChildren no.

Y un tercer motivo que sí es de rendimiento puro, aunque menos conocido: cuando el contenedor está fuera del árbol o dentro de un content-visibility: hidden (apartado 12), todo el trabajo de construcción es gratis, y ahí el fragmento sí es la herramienta natural.

Técnica Beneficio real Cuándo usarla
DocumentFragment ~9 % en inserciones masivas Construcción compleja fuera del árbol
replaceChildren(...) ~10 % y mucha legibilidad Reemplazo completo de una lista
innerHTML = cadena Rápido pero inseguro (06-05) Nunca con datos de usuario
reconciliar() Depende del cambio (apartado 9) Actualizaciones parciales
No tocar el DOM 100 % Apartado 10

  1. Actualizar en vez de redibujar: qué aporta de verdad reconciliar

En 06-06 construiste reconciliar(contenedor, datos, clave, pintar), que reutiliza los nodos existentes en lugar de destruirlos y recrearlos. Se justificó por corrección: conservar el foco, el desplazamiento y las transiciones. Ahora toca la pregunta de rendimiento: ¿cuánto ahorra?

La respuesta honesta es: depende enteramente de cuánto cambie, y la variabilidad es enorme.

Medición sobre las 600 tareas, comparando replaceChildren(...) (recrear todo) con reconciliar (reutilizar), en cuatro escenarios reales de Nómada Tareas:

Escenario Cambios replaceChildren reconciliar Mejora
Marta marca una tarea como hecha 1 tarjeta se mueve de columna 119 ms 3,1 ms 38×
Llega un lote de 12 actualizaciones por WebSocket 12 tarjetas cambian 119 ms 9,4 ms 13×
Iván escribe «seri» en el buscador quedan 47 tarjetas de 600 119 ms 21 ms 5,7×
Lucía cambia el orden a «por fecha» 600 tarjetas se reordenan 119 ms 142 ms 0,84×

Fíjate en la última fila: reconciliar puede ser más lento. Cuando cambia el orden de todos los elementos, la implementación sencilla de 06-06 hace un insertBefore por cada nodo —600 movimientos en el árbol vivo— más 600 actualizaciones de contenido, y eso cuesta más que construir la lista entera de cero fuera del árbol. Es exactamente el límite que 06-06 anunció: «no detecta movimientos de forma óptima».

La conclusión práctica no es «no uses reconciliar», sino algo más matizado:

reconciliar gana por goleada en el caso frecuente —cambios pequeños sobre una lista estable— y pierde en el caso raro de una reordenación completa. Como el caso frecuente es el que domina la experiencia percibida, sigue siendo la elección correcta. Y el caso raro deja de importar en cuanto solo haya 48 nodos en pantalla, que es lo que consigue la virtualización del apartado 15.

Ese último matiz es importante: las optimizaciones interactúan. Con virtualización, la reordenación completa afecta a 48 tarjetas en lugar de a 600, y la fila mala de la tabla desaparece sola.

  1. Escribir solo lo que cambia

Volvamos a la entrada que encabeza el Bottom-Up: pintarTarjeta, 600 llamadas, 186 ms de tiempo propio. Ya no queda thrashing, así que ese tiempo es trabajo real. ¿En qué se va?

Mira la función de 06-06 con ojos de rendimiento. Por cada tarjeta hace, sin condiciones:

  • 4 escrituras en dataset
  • 1 classList.remove(...) con tres clases y 3 classList.add/toggle
  • 3 asignaciones de textContent
  • 1 replaceChildren de la lista de etiquetas, que destruye y recrea los <li> de etiqueta
  • 2 escrituras de disabled y 2 setAttribute('aria-label', ...)

Son 16 escrituras por tarjeta, 9.600 en total, para actualizar una lista en la que normalmente no ha cambiado nada. Y aunque el navegador agrupe, cada escritura invalida algo, y cada invalidación amplía el subárbol que habrá que recalcular después.

La corrección es la más aburrida y la más eficaz de la lección: comprobar antes de escribir.

// js/vista/dom.js

/** Asigna solo si el valor es distinto. Evita invalidar sin necesidad. */
export function ponerTexto(nodo, valor) {
  if (nodo.textContent !== valor) nodo.textContent = valor;
}

/** Igual para atributos, con el caso de borrado incluido. */
export function ponerAtributo(nodo, nombre, valor) {
  if (valor === null || valor === false) {
    if (nodo.hasAttribute(nombre)) nodo.removeAttribute(nombre);
  } else if (nodo.getAttribute(nombre) !== String(valor)) {
    nodo.setAttribute(nombre, valor);
  }
}

/** Igual para dataset. */
export function ponerDato(nodo, clave, valor) {
  const texto = String(valor);
  if (nodo.dataset[clave] !== texto) nodo.dataset[clave] = texto;
}

Y pintarTarjeta reescrita con ellas, más una salida temprana que es la que de verdad manda:

// js/vista/tarjeta.js
import { $ } from './dom.js';
import { ponerTexto, ponerAtributo, ponerDato } from './dom.js';
import { HOY } from '../util/fechas.js';
import { marcaDeEstado } from '../util/formato.js';

const CLASES_PRIORIDAD = ['tarea--alta', 'tarea--media', 'tarea--baja'];
const SIGUIENTE = Object.freeze({ pendiente: 'en-curso', 'en-curso': 'hecha', hecha: null });
const ETIQUETA  = Object.freeze({
  pendiente: 'Empezar', 'en-curso': 'Marcar hecha', hecha: 'Completada'
});

/** Huella de lo que se ve de una tarea. Si no cambia, la tarjeta ya es correcta. */
function huella(tarea, hoy) {
  return `${tarea.estado}|${tarea.prioridad}|${tarea.titulo}|${tarea.responsable}` +
         `|${tarea.horasEstimadas}|${tarea.etiquetas.join(',')}|${tarea.estaVencida(hoy)}`;
}

export function pintarTarjeta(tarea, existente = null, hoy = HOY) {
  const li = existente ?? $('#plantilla-tarea').content.firstElementChild.cloneNode(true);
  const nueva = huella(tarea, hoy);

  // ── Salida temprana: nada visible ha cambiado. Cero escrituras en el DOM ──
  if (existente !== null && li.dataset.huella === nueva) return li;
  li.dataset.huella = nueva;

  const vencida = tarea.estaVencida(hoy);

  ponerDato(li, 'id', tarea.id);
  ponerDato(li, 'estado', tarea.estado);
  ponerDato(li, 'responsable', tarea.responsable ?? '');
  ponerDato(li, 'prioridad', tarea.prioridad);

  // classList ya comprueba por sí mismo: add de una clase presente no invalida
  li.classList.remove(...CLASES_PRIORIDAD);
  li.classList.add(`tarea--${tarea.prioridad}`);
  li.classList.toggle('tarea--hecha', tarea.estado === 'hecha');
  li.classList.toggle('tarea--vencida', vencida);

  ponerTexto($('.tarea__titulo', li), tarea.titulo);
  ponerTexto($('.tarea__meta', li),
    `${marcaDeEstado(tarea.estado)} ${tarea.responsable ?? 'sin asignar'} · ` +
    `${tarea.horasEstimadas} h` + (vencida ? ` · vencida hace ${Math.abs(tarea.diasRestantes)} d` : ''));

  // Las etiquetas casi nunca cambian: reconciliar en vez de recrear
  const contenedor = $('.tarea__etiquetas', li);
  if (contenedor.dataset.valor !== tarea.etiquetas.join(',')) {
    contenedor.dataset.valor = tarea.etiquetas.join(',');
    contenedor.replaceChildren(...tarea.etiquetas.map((texto) => {
      const item = document.createElement('li');
      item.className = 'etiqueta';
      item.textContent = texto;
      return item;
    }));
  }

  const avanzar = $('[data-accion="avanzar"]', li);
  ponerTexto(avanzar, ETIQUETA[tarea.estado]);
  avanzar.disabled = SIGUIENTE[tarea.estado] === null;
  ponerAtributo(avanzar, 'aria-label', `${ETIQUETA[tarea.estado]}: ${tarea.titulo}`);

  const reabrir = $('[data-accion="reabrir"]', li);
  reabrir.disabled = tarea.estado !== 'en-curso';
  ponerAtributo(reabrir, 'aria-label', `Devolver a pendiente: ${tarea.titulo}`);

  return li;
}

Tres comentarios sobre este código:

  • La huella es la clave. Es una cadena barata de construir (unos 0,004 ms) que resume todo lo que se ve. Compararla cuesta una fracción de lo que cuesta escribir dieciséis propiedades. Es, conceptualmente, la misma idea que el contador de versión de 09-02: una forma barata de saber si hace falta trabajar.
  • disabled se asigna sin comprobar a propósito: asignar una propiedad booleana que ya tiene ese valor no invalida nada en los motores actuales, y la comprobación costaría más que el ahorro. La regla es proteger lo que invalida —texto, atributos, dataset con selectores CSS asociados—, no todo por sistema.
  • classList.add de una clase ya presente tampoco invalida. classList es un DOMTokenList y comprueba la pertenencia antes de modificar. Esa es la razón de que en 06-02 se insistiera en usar classList en lugar de machacar className: además de más legible, es más barato.

Medición sobre el render completo, tras aplicar el apartado 7:

Medida Tras read-then-write Con salida temprana y escritura condicional
pintarTarjeta, tiempo propio (600) 149 ms 101 ms
Escrituras en el DOM por render sin cambios 9.600 0
render() completo 179 ms 142 ms

Y una fila que no está en la línea base pero que es la que más se nota en el uso diario: el render tras un cambio de una sola tarea pasa de 3,1 ms a 0,8 ms, porque 599 tarjetas salen por la puerta temprana.

  1. Animar barato: transform y opacity

Cambiemos de terreno. Hasta aquí hemos hablado de actualizar contenido; ahora, de moverlo. Nómada Tareas anima la entrada de una tarjeta nueva y el deslizamiento de una tarjeta al cambiar de columna. Así estaba hecha la entrada:

/* ✗ css/estilos.css — anima geometría: layout en cada fotograma */
.tarea--entrando {
  animation: entrar 240ms ease-out;
}

@keyframes entrar {
  from { height: 0;   margin-top: -12px; opacity: 0; }
  to   { height: 108px; margin-top: 0;   opacity: 1; }
}

Cada fotograma de esa animación cambia height y margin-top, que están en la fila roja de la tabla del apartado 3: estilo, layout, pintado y composición. Y como el <li> está en un contenedor con display: grid, ese layout no afecta solo a la tarjeta: recoloca toda la columna.

La versión correcta expresa el mismo efecto visual con las dos únicas propiedades que la GPU puede animar por su cuenta:

/* ✓ Solo transform y opacity: se salta layout y pintado */
.tarea--entrando {
  animation: entrar 240ms cubic-bezier(0.2, 0, 0.2, 1);
}

@keyframes entrar {
  from { transform: translateY(-12px) scaleY(0.96); opacity: 0; }
  to   { transform: none;                            opacity: 1; }
}

/* Accesibilidad: respetar la preferencia del sistema (07-06) */
@media (prefers-reduced-motion: reduce) {
  .tarea--entrando { animation: none; }
}

La tabla de referencia, ampliando la del apartado 3 con las propiedades que más se animan:

Propiedad animada Estilo Layout Pintado Composición Veredicto
width, height Evítala
top, left, right, bottom Evítala
margin, padding Evítala
font-size, line-height Evítala
border-width Evítala
color, background-color Aceptable
box-shadow, border-radius Aceptable, pero el pintado de sombras es caro
visibility Aceptable
transform Úsala
opacity Úsala
filter Compuesta, pero la GPU puede sufrir con blur grandes

Medición: animar la entrada simultánea de 24 tarjetas (lo que ocurre al quitar un filtro), grabando con el carril Frames de DevTools y CPU 4×:

Animación Fotogramas perdidos FPS medio Trabajo por fotograma
height + margin-top 38 de 58 22 fps Estilo + layout + pintado + composición
transform + opacity 0 de 58 60 fps Solo composición

La explicación de por qué la segunda sale gratis merece un párrafo. Cuando una animación afecta solo a transform y opacity, el navegador puede promover el elemento a su propia capa de composición: lo pinta una vez, y en cada fotograma se limita a decirle a la GPU «esta capa, desplazada 8 píxeles y con opacidad 0,7». No hay estilo que recalcular, no hay geometría que rehacer, no hay píxeles que rellenar. El hilo principal ni se entera: la animación sigue funcionando aunque tu JavaScript esté ocupado.

De ahí una consecuencia práctica que sorprende: una animación CSS bien hecha sobrevive a una tarea larga de JavaScript; una hecha con requestAnimationFrame, no. Si puedes expresar el efecto en CSS o con la Web Animations API (elemento.animate(...), de 07-06), hazlo, y reserva requestAnimationFrame para lo que exige cálculo por fotograma.

Y el truco que resuelve el 90 % de los casos en los que «necesito animar la posición y no puedo usar transform»: la técnica FLIP (First, Last, Invert, Play). Se mide la posición inicial, se aplica el cambio de golpe, se mide la final, y se anima con transform desde la diferencia hasta cero.

// js/vista/animar.js

/**
 * Anima el movimiento de un nodo entre dos posiciones usando solo transform.
 * @param {HTMLElement} nodo
 * @param {Function} cambiar  Función que aplica el cambio de layout de golpe.
 */
export function flip(nodo, cambiar) {
  const primera = nodo.getBoundingClientRect();     // F · una lectura, fuera de bucles
  cambiar();                                         // L · el cambio real, sin animar
  const ultima = nodo.getBoundingClientRect();       // L · una lectura más

  const dx = primera.left - ultima.left;             // I · invertir
  const dy = primera.top - ultima.top;
  if (dx === 0 && dy === 0) return null;

  return nodo.animate(                               // P · reproducir, solo composición
    [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
    { duration: 220, easing: 'cubic-bezier(0.2, 0, 0.2, 1)' }
  );
}

Ojo con una cosa: flip hace dos lecturas de geometría. Está bien para un nodo, y es exactamente el error del apartado 6 si lo llamas dentro de un bucle sobre 600 tarjetas. Para animar varias, primero se leen todas las posiciones iniciales, luego se aplica el cambio, luego se leen todas las finales, y solo entonces se anima. Read-then-write otra vez.

  1. Aislar el trabajo: will-change, contain y content-visibility

Estas tres propiedades CSS son herramientas de rendimiento puro: no cambian el aspecto de nada, sino cuánto trabajo puede ahorrarse el navegador.

12.1 will-change

Le avisa al navegador de que una propiedad va a cambiar pronto, para que prepare lo necesario —típicamente, promover el elemento a su propia capa— antes de que empiece la animación y no en el primer fotograma.

.tarea--arrastrando { will-change: transform; }

Y ahora las tres reglas que hacen que will-change ayude en lugar de perjudicar:

  1. Úsalo con moderación extrema. Cada capa consume memoria de GPU. Poner will-change: transform en las 600 tarjetas puede consumir cientos de megabytes de vídeo y hacer la aplicación más lenta. Es el mal uso más frecuente.
  2. Ponlo y quítalo. Lo correcto es añadirlo justo antes (con una clase, o al hacer :hover sobre el ancestro) y retirarlo al acabar. Dejarlo permanentemente en el CSS es exactamente lo que no hay que hacer.
  3. No lo uses para «arreglar» animaciones lentas. Si tu animación toca height, will-change: height no la va a salvar: el problema es la propiedad, no la preparación.
// Patrón correcto: activar justo antes, retirar al terminar
tarjeta.style.willChange = 'transform';
const animacion = flip(tarjeta, () => columna.append(tarjeta));
animacion?.finished.finally(() => { tarjeta.style.willChange = 'auto'; });

12.2 contain

Le promete al navegador que lo que pase dentro de un elemento no afecta a lo de fuera. Con esa promesa, el navegador puede limitar el alcance de un recálculo al subárbol en lugar de propagarlo a toda la página.

Valor Qué promete
layout La geometría interna no afecta a la externa
paint Nada se dibuja fuera de los límites del elemento
size El tamaño del elemento no depende de sus hijos (hay que darle tamaño explícito)
style Ciertos efectos de estilo (contadores) no salen del subárbol
content Atajo de layout + paint + style
strict Atajo de content + size
/* Cada tarjeta es una isla: cambiar su interior no recalcula la columna entera */
.tarea { contain: layout paint; }

Con esto, actualizar el .tarea__meta de una tarjeta ya no puede desplazar a las 599 restantes, y el navegador lo sabe de antemano. En la medición del render tras un cambio de una tarea, contain: layout paint baja el Layout de 1,9 ms a 0,3 ms.

El aviso: contain: size es peligroso si no le das dimensiones al elemento, porque entonces el navegador lo tratará como si midiera cero y la tarjeta desaparecerá. Es el error clásico con esta propiedad.

12.3 content-visibility

Es la más potente de las tres y la que más se acerca a la virtualización sin escribir JavaScript. content-visibility: auto le dice al navegador: «si este elemento no está cerca del área visible, no hagas su estilo, su layout ni su pintado».

.tarea {
  content-visibility: auto;
  contain-intrinsic-size: auto 108px;   /* tamaño estimado mientras está omitido */
}

contain-intrinsic-size es obligatorio en la práctica: sin él, el navegador trata el elemento omitido como si midiera 0 px de alto, y la barra de desplazamiento salta y se encoge de forma grotesca a medida que desplazas. Con la palabra clave auto, el navegador recuerda el tamaño real una vez lo ha calculado y usa ese valor a partir de entonces, lo que elimina casi por completo el salto.

Medición sobre el render completo:

Medida Sin content-visibility Con content-visibility: auto
render() completo, 600 tareas 142 ms 78 ms
Layout del pintado inicial 41 ms 6 ms
Paint del pintado inicial 33 ms 5 ms
Nodos del documento 7.812 7.812

Dos lecturas, y la segunda es la importante.

Es un ahorro enorme por dos líneas de CSS. De 142 a 78 ms sin tocar JavaScript, sin romper nada, sin contrapartidas de accesibilidad. Si solo pudieras aplicar una técnica de esta lección, sería esta.

Pero no cierra la fila 9. Los 7.812 nodos siguen ahí: content-visibility evita trabajar con ellos, no los elimina. Y los nodos cuestan memoria (unos 2,4 MB en el montículo, medido con el panel Memory de 09-03), coste de recolección, y coste en cada querySelectorAll que los recorra. Para bajar de 7.812 a 1.500 hay que no crearlos, y eso ya es virtualización.

Comparación honesta de las tres, para tenerlas claras:

Herramienta Qué ahorra Coste Riesgo
will-change El primer fotograma de una animación Memoria de GPU por capa Abusar y empeorar
contain Propagación de recálculos Ninguno si no usas size size sin dimensiones rompe el diseño
content-visibility Estilo, layout y pintado de lo no visible Ninguno relevante El salto del scroll sin contain-intrinsic-size

  1. Sincronizar con requestAnimationFrame (y no con setTimeout)

En 07-06 usaste requestAnimationFrame para animar. Aquí interesa por otro motivo: es el momento correcto para hacer trabajo visual, y usar setTimeout en su lugar produce dos defectos distintos.

requestAnimationFrame(callback) programa el callback para que se ejecute justo antes del siguiente pintado. Eso significa tres garantías que setTimeout no da:

setTimeout(fn, 16) requestAnimationFrame(fn)
Cuándo se ejecuta Cuando el bucle de eventos llegue, ~16 ms después Justo antes del siguiente pintado
¿Se sincroniza con la pantalla? No. En 120 Hz o 144 Hz va desincronizado Sí, sea cual sea la frecuencia
Pestaña oculta Sigue ejecutándose y gastando batería Se pausa
Varias llamadas en la misma tarea Se ejecutan por separado Se agrupan en el mismo fotograma
Riesgo Fotogramas dobles o perdidos Ninguno

El defecto más sutil de setTimeout para trabajo visual se llama fotograma doble: si tu callback se ejecuta a mitad de camino entre dos pintados, el navegador puede pintar el mismo estado dos veces y saltarse el siguiente cambio, produciendo el tirón característico de las animaciones hechas con temporizadores.

En Nómada Tareas hay un caso donde esto importa de verdad: el manejador de scroll que decide qué tarjetas hay que pintar en la ventana virtual del apartado 15. Con throttle(fn, 100) de 09-02 el desplazamiento va a saltos; con requestAnimationFrame, va suave. El patrón correcto es el llamado rAF coalescido:

// js/util/tiempo.js

/**
 * Ejecuta `fn` como mucho UNA vez por fotograma, con los últimos argumentos.
 * Es el `throttle` correcto para trabajo visual: se sincroniza con la pantalla
 * en lugar de con un número fijo de milisegundos.
 */
export function porFotograma(fn) {
  let pendiente = 0;
  let ultimos = null;

  const envuelta = function (...args) {
    ultimos = args;
    if (pendiente !== 0) return;                 // ya hay uno programado: no acumular
    pendiente = requestAnimationFrame(() => {
      pendiente = 0;
      fn.apply(this, ultimos);
    });
  };

  envuelta.cancelar = () => {                    // limpieza obligatoria (09-03)
    cancelAnimationFrame(pendiente);
    pendiente = 0;
  };

  return envuelta;
}
// js/vista/tablero-vista.js
import { porFotograma } from '../util/tiempo.js';

#alDesplazar = porFotograma(() => this.#recalcularVentana());

constructor({ contenedor, ... }) {
  // …
  this.#contenedor.addEventListener('scroll', this.#alDesplazar, {
    passive: true,                          // no vamos a llamar a preventDefault (09-02)
    signal: this.#controlador.signal        // limpieza con un solo abort() (09-03)
  });
}

Medición del desplazamiento de la columna de pendientes con 600 tareas, 5 segundos de scroll continuo, CPU 4×:

Manejador de scroll Ejecuciones Fotogramas perdidos FPS medio
Sin estrangular 312 71 34 fps
throttle(fn, 100) 51 24 48 fps
porFotograma(fn) 58 2 59 fps

Fíjate en que porFotograma ejecuta más veces que el throttle de 100 ms y aun así pierde doce veces menos fotogramas. No es cuestión de ejecutar poco: es cuestión de ejecutar en el momento correcto.

Y la regla que resume el apartado, con su matiz:

requestAnimationFrame para trabajo visual, setTimeout/debounce para trabajo no visual. Filtrar el tablero al escribir es no visual (el resultado es un cálculo): debounce. Recolocar la ventana virtual al desplazar es visual: requestAnimationFrame.

  1. Virtualización I: centinelas con IntersectionObserver

Llegamos al asunto de fondo. Con 600 tareas hay 7.812 nodos en el documento, y en pantalla caben seis tarjetas por columna. Estamos manteniendo casi ocho mil nodos para mostrar dieciocho.

Virtualizar es pintar solo lo que se ve (más un pequeño margen) y fingir el resto. Hay dos familias de implementación, y conviene ver las dos porque resuelven problemas distintos.

La primera, más sencilla, es el desplazamiento infinito con centinela: se pintan las primeras N tarjetas y, cuando el usuario llega al final, se pintan otras N. Se implementa con el IntersectionObserver de 07-06, que avisa cuando un elemento entra en el área visible sin necesidad de escuchar scroll.

// js/vista/lista-progresiva.js
import { $ } from './dom.js';
import { crearElemento } from './dom.js';

const LOTE = 30;                  // cuántas tarjetas se añaden cada vez

/**
 * Pinta una lista larga por lotes: los primeros LOTE elementos, y el resto
 * a medida que el usuario se acerca al final.
 */
export class ListaProgresiva {
  #contenedor;
  #pintar;
  #datos = [];
  #pintadas = 0;
  #centinela;
  #observador;

  constructor({ contenedor, pintar }) {
    this.#contenedor = contenedor;
    this.#pintar = pintar;

    // El centinela es un elemento vacío al final de la lista
    this.#centinela = crearElemento('li', {
      clases: 'centinela', 'aria-hidden': 'true'
    });

    this.#observador = new IntersectionObserver((entradas) => {
      if (entradas[0].isIntersecting) this.#siguienteLote();
    }, {
      root: contenedor.closest('.columna'),
      rootMargin: '400px'          // pintar ANTES de que el hueco se vea (07-06)
    });
  }

  establecer(datos) {
    this.#datos = datos;
    this.#pintadas = 0;
    this.#contenedor.replaceChildren(this.#centinela);
    this.#observador.observe(this.#centinela);
    this.#siguienteLote();
  }

  #siguienteLote() {
    const hasta = Math.min(this.#pintadas + LOTE, this.#datos.length);
    if (hasta === this.#pintadas) {
      this.#observador.unobserve(this.#centinela);   // ya no queda nada
      this.#centinela.remove();
      return;
    }

    const fragmento = document.createDocumentFragment();
    for (let i = this.#pintadas; i < hasta; i += 1) {
      fragmento.append(this.#pintar(this.#datos[i], null));
    }
    this.#centinela.before(fragmento);               // insertar ANTES del centinela
    this.#pintadas = hasta;
  }

  /** Limpieza obligatoria: un observador vivo retiene sus nodos (09-03). */
  destruir() {
    this.#observador.disconnect();
    this.#centinela.remove();
    this.#datos = [];
  }
}

Cuatro detalles del código que merecen atención:

  • rootMargin: '400px' hace que el centinela «entre» cuatrocientos píxeles antes de ser visible. Sin ese margen, el usuario ve el hueco vacío durante un instante. Es el mismo truco de precarga de 07-06.
  • El centinela va al final y se inserta antes de él, no después: así siempre queda el último y el observador puede seguir usándolo.
  • unobserve + remove al agotar los datos evita que el observador siga vivo sin motivo, y evita el nodo desprendido de 09-03.
  • destruir() con disconnect() es obligatorio: un IntersectionObserver observando un nodo lo mantiene alcanzable.

Medición del pintado inicial con esta técnica:

Medida Todo de golpe Progresiva por lotes de 30
Tiempo hasta la primera tarjeta visible 142 ms 11 ms
Nodos al arrancar 7.812 972
Nodos tras desplazarse hasta el final 7.812 7.812
Complejidad añadida Baja

La primera fila es excelente: la percepción mejora radicalmente porque las primeras tarjetas aparecen casi de inmediato. Pero mira la tercera: los nodos se acumulan. La lista progresiva reduce el coste inicial, no el coste sostenido. Para Marta, que se pasa la mañana desplazándose por el tablero, acabaremos otra vez en 7.812 nodos.

La fila 9 pide otra cosa.

  1. Virtualización II: la ventana calculada con altura fija

La virtualización de verdad mantiene en el DOM solo los elementos de la ventana visible, creando los que entran y eliminando los que salen. El número de nodos se vuelve constante, independiente del tamaño de la lista.

La idea, cuando todas las filas miden lo mismo, es puramente aritmética:

flowchart TB
    subgraph V["Contenedor con scroll · alto 640 px"]
        direction TB
        E1["Espaciador superior<br/>alto = primero × 116 px"]
        W["<b>Ventana pintada</b><br/>16 tarjetas reales<br/>(6 visibles + 5 de margen arriba y abajo)"]
        E2["Espaciador inferior<br/>alto = (600 − último) × 116 px"]
        E1 --> W --> E2
    end
    S["scrollTop"] -->|"primero = floor(scrollTop / 116) − 5"| W

Los dos espaciadores son la pieza clave: un <li> vacío arriba y otro abajo, con la altura exacta que ocuparían las tarjetas que no se han pintado. Gracias a ellos, la barra de desplazamiento tiene el tamaño correcto y la posición dentro de la lista es fiel, aunque solo existan dieciséis tarjetas.

// js/vista/lista-virtual.js
import { crearElemento } from './dom.js';
import { porFotograma } from '../util/tiempo.js';

/**
 * Mantiene en el DOM solo las filas visibles de una lista larga.
 * Requiere que TODAS las filas midan lo mismo (`alto`).
 */
export class ListaVirtual {
  #contenedor;                       // el <ul class="lista-tareas">
  #ventanilla;                       // el elemento con scroll (la columna)
  #pintar;                           // (dato, nodoExistente) => HTMLElement
  #clave;                            // (dato) => string|number
  #alto;                             // altura de una fila, en px, con su separación
  #margen;                           // filas extra arriba y abajo
  #datos = [];
  #arriba;                           // espaciador superior
  #abajo;                            // espaciador inferior
  #rango = { desde: -1, hasta: -1 };
  #controlador = new AbortController();
  #alDesplazar;

  constructor({ contenedor, ventanilla, pintar, clave, alto = 116, margen = 5 }) {
    this.#contenedor = contenedor;
    this.#ventanilla = ventanilla;
    this.#pintar = pintar;
    this.#clave = clave;
    this.#alto = alto;
    this.#margen = margen;

    this.#arriba = crearElemento('li', { clases: 'espaciador', 'aria-hidden': 'true' });
    this.#abajo  = crearElemento('li', { clases: 'espaciador', 'aria-hidden': 'true' });

    // Trabajo visual → un recálculo por fotograma, nunca más (apartado 13)
    this.#alDesplazar = porFotograma(() => this.#sincronizar());

    this.#ventanilla.addEventListener('scroll', this.#alDesplazar, {
      passive: true, signal: this.#controlador.signal
    });
    window.addEventListener('resize', this.#alDesplazar, {
      passive: true, signal: this.#controlador.signal
    });
  }

  /** Cambia los datos de la lista y repinta la ventana. */
  establecer(datos) {
    this.#datos = datos;
    this.#rango = { desde: -1, hasta: -1 };      // fuerza el repintado completo
    this.#sincronizar();
  }

  #sincronizar() {
    // ── FASE 1 · LEER. Dos lecturas, ninguna escritura entre medias ──
    const desplazamiento = this.#ventanilla.scrollTop;
    const altoVisible = this.#ventanilla.clientHeight;

    // ── Aritmética pura, sin tocar el DOM ──
    const total = this.#datos.length;
    const primeraVisible = Math.floor(desplazamiento / this.#alto);
    const cuantasCaben = Math.ceil(altoVisible / this.#alto);

    const desde = Math.max(0, primeraVisible - this.#margen);
    const hasta = Math.min(total, primeraVisible + cuantasCaben + this.#margen);

    if (desde === this.#rango.desde && hasta === this.#rango.hasta) return;  // nada que hacer
    this.#rango = { desde, hasta };

    // ── FASE 2 · ESCRIBIR ──
    this.#arriba.style.height = `${desde * this.#alto}px`;
    this.#abajo.style.height  = `${Math.max(0, total - hasta) * this.#alto}px`;

    const visibles = this.#datos.slice(desde, hasta);
    const existentes = new Map(
      [...this.#contenedor.children]
        .filter((n) => !n.classList.contains('espaciador'))
        .map((n) => [n.dataset.id, n])
    );

    const fragmento = document.createDocumentFragment();
    for (const dato of visibles) {
      const k = String(this.#clave(dato));
      fragmento.append(this.#pintar(dato, existentes.get(k) ?? null));
      existentes.delete(k);
    }

    // Una sola operación sobre el árbol vivo: espaciador + ventana + espaciador
    this.#contenedor.replaceChildren(this.#arriba, fragmento, this.#abajo);

    // Lo que quedaba en `existentes` ya no está en la ventana: se ha ido con el
    // replaceChildren, y al no guardarlo en ningún sitio, es basura recolectable (09-03)
  }

  /** Nodos realmente presentes, sin contar los dos espaciadores. */
  get pintados() { return this.#contenedor.children.length - 2; }

  destruir() {
    this.#controlador.abort();
    this.#alDesplazar.cancelar();
    this.#datos = [];
    this.#contenedor.replaceChildren();
  }
}

Y el CSS que hace posible la aritmética:

/* css/estilos.css */
.columna {
  height: 640px;
  overflow-y: auto;
  contain: layout paint;          /* la columna es una isla (apartado 12) */
}

.lista-tareas { margin: 0; padding: 0; list-style: none; }

.tarea {
  box-sizing: border-box;
  height: 108px;                  /* ALTURA FIJA: es el contrato de la virtualización */
  margin-bottom: 8px;             /* 108 + 8 = 116 px, el `alto` de ListaVirtual */
  overflow: hidden;
  contain: layout paint;
}

.espaciador { padding: 0; margin: 0; }

Y el cableado en la vista, sustituyendo la llamada a reconciliar:

// js/vista/tablero-vista.js
import { ListaVirtual } from './lista-virtual.js';
import { pintarTarjeta } from './tarjeta.js';

#listas = new Map();          // estado → ListaVirtual

#prepararColumnas() {
  this.#contenedor.replaceChildren(...COLUMNAS.map(({ estado, titulo }) => {
    const columna = $('#plantilla-columna').content.firstElementChild.cloneNode(true);
    // …igual que en 06-06: dataset, título, aria-labelledby, aria-live…

    this.#listas.set(estado, new ListaVirtual({
      contenedor: $('.lista-tareas', columna),
      ventanilla: columna,
      clave: (tarea) => tarea.id,
      pintar: (tarea, nodo) => pintarTarjeta(tarea, nodo, this.#estado.hoy),
      alto: 116,
      margen: 5
    }));

    return columna;
  }));
}

render() {
  const visibles = this.#visibles();
  const porEstado = Object.groupBy(visibles, (t) => t.estado);

  for (const { estado } of COLUMNAS) {
    const columna = $(`.columna[data-estado="${estado}"]`, this.#contenedor);
    const delEstado = porEstado[estado] ?? [];
    const horas = delEstado.reduce((suma, t) => suma + t.horasEstimadas, 0);

    ponerTexto($('.columna__contador', columna),
      `${delEstado.length} tarea${delEstado.length === 1 ? '' : 's'} · ${horas} h`);

    this.#listas.get(estado).establecer(delEstado);    // ← ya no hay reconciliar aquí
    $('.columna__vacia', columna).hidden = delEstado.length > 0;
  }

  // …resumen (con caché de 09-02) y emisión del evento, igual que en 06-06…
}

destruir() {
  for (const lista of this.#listas.values()) lista.destruir();
  this.#listas.clear();
  this.#controlador.abort();
  // …resto de la limpieza de 09-03…
}

La medición, y aquí es donde se cierra la lección:

Medida Sin virtualizar Con ListaVirtual
render() completo, 600 tareas 142 ms (78 con content-visibility) 31 ms
Tarjetas en el DOM 600 48 (16 × 3 columnas)
Nodos del documento 7.812 1.194
Memoria del montículo 12,8 MB 6,1 MB
Recolocación al desplazar 1,7 ms por fotograma
Fotogramas perdidos en 5 s de scroll 24 2

Los 1.194 nodos salen de una cuenta que conviene entender: 612 nodos de la carcasa de la página (cabecera, filtros, formulario, resumen, plantillas, las tres columnas con sus títulos y contadores) + 48 tarjetas × 12 nodos cada una = 576 + 6 espaciadores. Total, 1.194. La fila 9 pedía ≤ 1.500.

Y una propiedad que es la más valiosa de todas: ese número ya no depende del tamaño del tablero. Con 6.000 tareas serían los mismos 1.194 nodos y los mismos 31 ms. La virtualización no hace la aplicación un 20 % más rápida: cambia su complejidad de O(n) a O(1) en el número de elementos pintados, que es justo la clase de cambio que 09-02 identificó como la única que importa de verdad.

  1. Las contrapartidas de virtualizar

Sería deshonesto terminar el apartado anterior sin la letra pequeña. Virtualizar rompe cosas, y hay que saber cuáles y cómo se mitigan.

Lo que se rompe Por qué Mitigación
Buscar con Ctrl+F El navegador solo encuentra texto que existe en el DOM El buscador de la aplicación debe ser bueno; y content-visibility es buscable con hidden-matchable
Lectores de pantalla Anuncian «lista de 16 elementos» en lugar de 600 role="list" + aria-setsize="600" y aria-posinset en cada fila
Tabulación con teclado No se puede tabular hasta una fila que no existe Navegación con flechas gestionada por código, y desplazar al enfocar
Imprimir Solo se imprime la ventana Un modo «imprimir todo» que desactive la virtualización
Enlaces a una tarea concreta La tarea puede no estar pintada El enrutador (07-06) debe calcular el scrollTop y desplazar antes de buscar el nodo
Alturas variables La aritmética supone filas iguales Medir y cachear alturas, o forzar altura fija con overflow: hidden
Complejidad del código 90 líneas más y un modo de fallo nuevo Pruebas dedicadas y un destruir() correcto

Las dos primeras merecen desarrollo, porque son las que más se subestiman.

Accesibilidad. Una lista virtualizada mal hecha es una regresión de accesibilidad seria. El mínimo imprescindible:

// En ListaVirtual, al pintar cada fila
li.setAttribute('aria-setsize', String(this.#datos.length));   // «de 600»
li.setAttribute('aria-posinset', String(desde + i + 1));       // «el 137»
<ul class="lista-tareas" role="list" aria-live="polite"></ul>

Con eso, un lector de pantalla anuncia «tarea 137 de 600» aunque en el DOM solo haya dieciséis. Y los espaciadores llevan aria-hidden="true" para que no se anuncien como elementos vacíos de la lista.

La alternativa más barata. Antes de virtualizar, pregúntate si content-visibility: auto te basta. La comparación completa:

content-visibility: auto Virtualización
Esfuerzo 2 líneas de CSS ~90 líneas de JS y pruebas
render() 78 ms 31 ms
Nodos 7.812 1.194
Memoria 12,8 MB 6,1 MB
Ctrl+F Funciona Roto
Lectores de pantalla Sin cambios Requiere aria-setsize
Alturas variables Sin problema Requiere trabajo extra
Escala a 60.000 filas No (el árbol pesa)

Regla de decisión: usa content-visibility: auto por defecto; virtualiza solo cuando el número de nodos sea el problema, no el tiempo de pintado. En Nómada Tareas virtualizamos porque la fila 9 de la línea base lo exige explícitamente y porque el tablero seguirá creciendo. Si la línea base solo hubiera pedido bajar de 310 a 50 ms, content-visibility más los apartados 7 y 10 casi habrían bastado, con una décima parte del código.

Y ya que estamos siendo honestos: la implementación de ListaVirtual de arriba es deliberadamente sencilla. No soporta alturas variables, no maneja el desplazamiento suave hacia un elemento concreto, no repone el foco al salir y volver a entrar en la ventana. Cada una de esas cosas es un buen rato de trabajo. Es exactamente el tipo de problema que en el Módulo 10 verás resuelto de fábrica.

  1. Delegación de eventos, ahora con números

En 06-04 aprendiste delegación y se justificó por elegancia: un manejador en el contenedor que averigua el origen con closest('[data-accion]'). En 09-03 se justificó por memoria: un closure en lugar de 600. Falta la justificación de tiempo, y con la virtualización del apartado anterior se vuelve estructural.

Medición del pintado inicial de 600 tarjetas con tres acciones cada una:

Un manejador por botón Delegación en .tablero
Llamadas a addEventListener 1.800 1
Coste solo de registrar 38 ms 0,004 ms
Memoria de los oyentes 1,2 MB ~0 kB
Al crear una tarjeta nueva Hay que registrar sus 3 Funciona sola
Al eliminar una tarjeta Hay que retirarlos Nada que retirar
Coste por clic 0,002 ms 0,014 ms (el closest)

La penúltima fila es la razón profunda por la que la delegación no es opcional cuando hay virtualización: las tarjetas entran y salen del DOM constantemente al desplazarse. Con manejadores individuales habría que registrar y retirar oyentes en cada fotograma de scroll, lo que sería tan absurdo como suena.

Y sobre la última fila: sí, cada clic delegado cuesta siete veces más que uno directo. Son doce microsegundos, ocurren una vez cada varios segundos, y a cambio ahorras 38 ms en el arranque y una familia entera de fugas. Es exactamente el tipo de comparación que 09-02 enseñó a hacer: comparar magnitudes absolutas, no factores.

Un último apunte técnico. Con la lista virtualizada, el manejador delegado sigue funcionando sin cambios porque closest sube por el árbol y el contenedor .tablero nunca se destruye:

// js/vista/controlador.js — sin un solo cambio respecto a 06-04
$('.tablero').addEventListener('click', (evento) => {
  const boton = evento.target.closest('button[data-accion]');
  if (boton === null) return;

  const id = Number(boton.closest('[data-id]').dataset.id);
  // …resto igual…
}, { signal: controlador.signal });

Que una técnica elegida por claridad siga siendo correcta tras tres lecciones de optimización no es casualidad: es la misma observación de 09-02 sobre class Tarea. Las decisiones de diseño buenas suelen resultar también las rápidas.

  1. Las filas 5 y 9, cerradas

Recapitulemos el camino completo, porque la suma es lo que da la medida real de la lección.

Paso Qué se hizo render() Nodos
Línea base (09-01) 310 ms 7.812
1 Read-then-write en #ajustarAlturas (apartado 7) 179 ms 7.812
2 Huella y escritura condicional en pintarTarjeta (apartado 10) 142 ms 7.812
3 contain: layout paint en .tarea y .columna (apartado 12) 138 ms 7.812
4 content-visibility: auto (apartado 12) 78 ms 7.812
5 ListaVirtual (apartado 15) 31 ms 1.194

Y las filas de la línea base que quedan cerradas:

# Medida Línea base Objetivo Después Estado
5 render() completo, 600 tareas 310 ms ≤ 50 ms 31 ms
9 Nodos DOM del documento 7.812 ≤ 1.500 1.194
2 INP al escribir en el buscador 480 ms → 96 ms (09-02) ≤ 200 ms 42 ms

La fila 2 mejora de propina: el debounce de 09-02 había dejado el INP en 96 ms, de los cuales 78 eran el render. Con el render en 31 ms, la interacción completa —tecla, filtrado, render, pintado— baja a 42 ms, muy por debajo del umbral de los 100 ms de respuesta instantánea de 09-01.

Y dos cifras de contexto que no están en la tabla pero que importan:

  • La memoria del montículo baja de 12,8 MB a 6,1 MB, porque 6.618 nodos que ya no existen no ocupan nada. Otra vez el efecto de 09-03: arreglar una cosa mejora otra.
  • Todo esto es independiente del tamaño del tablero. Con 6.000 tareas, render() sigue costando 31 ms y el documento sigue teniendo 1.194 nodos. Lo único que crece es el filtrado y el ordenado, que son O(n) y O(n log n) sobre datos en memoria: unos 8 ms con 6.000 tareas.

  1. Síntoma → causa probable → solución

Esta tabla es el resumen operativo de la lección. Cuando algo vaya mal en el DOM, empieza por aquí.

Síntoma observado Qué ves en DevTools Causa probable Solución
Un render corto se vuelve larguísimo con volumen Muchos bloques morados intercalados en el amarillo, con triángulo de aviso Layout thrashing Read-then-write (7)
«Forced reflow is a likely performance bottleneck» El aviso literal Lectura de la lista negra dentro de un bucle Read-then-write (7)
El render tarda lo mismo aunque no cambie nada pintarTarjeta con mucho self time Se escribe sin comprobar Huella y escritura condicional (10)
Una animación va a tirones Carril Frames en rojo; morado por fotograma Se anima top, width o height transform y opacity, o FLIP (11)
Una animación se congela al hacer otra cosa Bloque amarillo largo durante la animación La animación depende del hilo principal CSS o Web Animations en lugar de rAF (11)
El desplazamiento va a saltos Manejador de scroll con mucho self time scroll sin estrangular o con throttle fijo porFotograma con rAF (13)
Todo se ralentiza al crecer la lista Layout y Paint enormes en el pintado inicial Se pintan elementos que nadie ve content-visibility (12) o virtualización (15)
El contador de nodos sube y no baja Curva Nodes creciente en Performance Nodos que se acumulan (o una fuga) Virtualización (15) o revisar 09-03
Cambiar una tarjeta recoloca la página entera Layout con muchos nodos afectados Sin aislamiento contain: layout paint (12)
Insertar elementos va lento Muchos Layout en el bucle de inserción Lectura de geometría intercalada Construir fuera del árbol (8)
El primer pintado tarda mucho pero luego va bien Un solo bloque enorme al arrancar Se pinta todo de golpe Lista progresiva (14) o virtualización (15)
Al desplazar, la barra de scroll da saltos content-visibility sin tamaño intrínseco contain-intrinsic-size: auto Xpx (12)
La aplicación consume mucha memoria de vídeo Muchas capas en el panel Layers will-change aplicado a demasiados elementos Aplicarlo y retirarlo (12)

Errores Comunes y Consejos

  • Creer que «el DOM es lento». El DOM no es lento: recalcular estilo y diseño lo es, y solo cuando lo fuerzas fuera de tiempo. Cruzar la frontera JS↔DOM cuesta nanosegundos.
  • Leer geometría dentro de un bucle que escribe. Es el error de la lección. Cuarenta lecturas de offsetHeight cuestan 137 ms; las mismas cuarenta, agrupadas, cuestan 4,3 ms.
  • No reconocer getComputedStyle como lectura cara. Es la más traicionera de la lista negra, porque no parece una medida.
  • Usar innerText por costumbre. Fuerza recálculo; textContent no. Salvo que necesites específicamente el texto visible, usa textContent.
  • Llamar a focus() en mitad de un bucle de escrituras. Es una lectura disfrazada: fuerza el diseño para saber dónde está el elemento.
  • Creer que DocumentFragment es la gran optimización. Aporta un 9 %. Lo que aporta un 1.600 % es no leer geometría en el bucle.
  • Escribir siempre, sin comprobar. Asignar el mismo textContent que ya había invalida igual. Una huella barata evita 9.600 escrituras por render.
  • Animar top, left, width o height. Cada fotograma pasa por layout. Usa transform y opacity, y FLIP cuando el cambio sea de posición real.
  • Poner will-change en todo por si acaso. Cada elemento promovido consume memoria de GPU. Actívalo justo antes y retíralo al acabar.
  • Usar contain: size sin dar dimensiones. El elemento se trata como de tamaño cero y desaparece.
  • Usar content-visibility: auto sin contain-intrinsic-size. La barra de desplazamiento se vuelve loca. Con auto Xpx, el navegador recuerda el tamaño real.
  • Usar setTimeout para trabajo visual. No se sincroniza con la pantalla, sigue corriendo en pestañas ocultas y produce fotogramas dobles. requestAnimationFrame.
  • Usar throttle(fn, 100) en scroll. Mejor que nada, peor que requestAnimationFrame: ejecuta menos veces y pierde más fotogramas.
  • Olvidar { passive: true } en scroll, wheel y touchstart. El navegador tiene que esperar a ver si llamas a preventDefault.
  • Virtualizar sin arreglar la accesibilidad. Sin aria-setsize y aria-posinset, una lista de 600 se anuncia como de 16. Es una regresión seria.
  • Virtualizar cuando bastaba content-visibility. Dos líneas de CSS frente a noventa de JavaScript con un modo de fallo nuevo. Mide antes de decidir.
  • Virtualizar con alturas variables sin medirlas. La aritmética supone filas iguales; si no lo son, la barra de desplazamiento miente.
  • Consejo: mide el número de nodos, no solo el tiempo. document.getElementsByTagName('*').length en la consola, o la curva Nodes del panel Performance. Es un indicador temprano que el tiempo no da.
  • Consejo: busca el triángulo de aviso en el diagrama de llamas. DevTools te dice literalmente dónde está el recálculo forzado, con la pila de llamadas que lo provocó.
  • Consejo: usa el panel Rendering de DevTools. Paint flashing colorea en verde lo que se repinta —si parpadea toda la pantalla al cambiar una tarjeta, tienes un problema de aislamiento—, y Layout Shift Regions señala los saltos de contenido, que serán protagonistas en 09-05.
  • Consejo: escribe una prueba que cuente escrituras. En Jest con jsdom no hay layout, pero sí puedes espiar Element.prototype.setAttribute y afirmar que un render sin cambios no escribe nada. Es barata y protege el apartado 10.
  • Consejo: los espaciadores de la lista virtual deben llevar aria-hidden="true". Si no, se anuncian como elementos vacíos de la lista.

Ejercicios

Ejercicio 1 — Cazar el thrashing. Este código coloca una barra de progreso en cada columna del tablero, proporcional a las horas abiertas. Con las tres columnas y 600 tareas tarda 96 ms, y DevTools marca el aviso de recálculo forzado.

function pintarBarras(columnas) {
  for (const columna of columnas) {
    const barra = columna.querySelector('.columna__barra');
    const ancho = columna.clientWidth;                          // (a)
    const alto = getComputedStyle(barra).height;                // (b)

    barra.style.width = `${ancho * 0.8}px`;                     // (c)
    barra.classList.toggle('columna__barra--alta', ancho > 320); // (d)

    const etiqueta = columna.querySelector('.columna__contador');
    etiqueta.style.top = `${barra.getBoundingClientRect().bottom + 4}px`;   // (e)
    etiqueta.textContent = `${Math.round(parseFloat(alto))} px de barra`;   // (f)
  }
}

Responde: (1) qué líneas son lecturas de la lista negra y cuáles escrituras; (2) cuántos recálculos forzados provoca con tres columnas y por qué; (3) reescríbelo con read-then-write; (4) estima la mejora sabiendo que un recálculo forzado sobre este árbol cuesta unos 11 ms; y (5) señala una segunda mejora, independiente del orden, que reduzca todavía más el coste.

Ejercicio 2 — ¿content-visibility o virtualización? Para cada una de estas cuatro pantallas de Nómada Tareas, decide entre «no hacer nada», «content-visibility: auto» y «virtualizar», justificando con las contrapartidas del apartado 16.

  1. El tablero canónico de Taller Nómada: 6 tareas en tres columnas.
  2. El historial de cambios: 4.200 líneas de texto de una sola altura, que Marta consulta con Ctrl+F constantemente.
  3. La vista de impresión del informe trimestral: 600 filas que hay que enviar a la impresora de una vez.
  4. El selector de etiquetas: 6 elementos con altura variable según la longitud del nombre.

Ejercicio 3 — Animar el cambio de columna. Cuando Iván pulsa «Empezar», la tarjeta salta de la columna «Pendientes» a «En curso» sin transición. Escribe la animación correcta: (a) explica por qué no se puede resolver con una animación CSS de top y left; (b) implementa el movimiento con la función flip del apartado 11, integrada en el flujo estado → render → evento de 06-06; (c) indica cómo evitar el layout thrashing si cambian varias tarjetas a la vez; y (d) añade el respeto a prefers-reduced-motion y explica por qué la animación debe seguir funcionando aunque el hilo principal esté ocupado.

Soluciones

Solución 1

(1) Clasificación de las líneas:

Línea Tipo Motivo
(a) columna.clientWidth Lectura Geometría del cliente
(b) getComputedStyle(barra).height Lectura Estilo resuelto, y depende del diseño
(c) barra.style.width = … Escritura Invalida estilo y diseño
(d) classList.toggle(...) Escritura Invalida estilo (y diseño, porque la clase cambia el tamaño)
(e) barra.getBoundingClientRect() Lectura dentro de una escritura Rectángulo
(f) etiqueta.textContent = … Escritura Invalida estilo, diseño y pintado

(2) Recálculos forzados. Por cada vuelta hay dos bloques lectura→escritura→lectura: (a)(b) leen, (c)(d) invalidan, (e) vuelve a leer —forzado—, (f) invalida otra vez. En la siguiente vuelta, (a) lee con invalidaciones pendientes —forzado— y (b) puede aprovechar el recálculo recién hecho. Con tres columnas: la primera vuelta hace 1 recálculo natural + 1 forzado en (e); las vueltas 2 y 3 hacen 2 forzados cada una. Total: 5 recálculos forzados y unos 55 ms solo en eso. La estructura es la del apartado 6: el bucle alterna.

(3) Reescritura:

function pintarBarras(columnas) {
  // ── FASE 1 · LEER TODO. Un solo recálculo forzado, al principio ──
  const medidas = columnas.map((columna) => {
    const barra = columna.querySelector('.columna__barra');
    return {
      columna,
      barra,
      etiqueta: columna.querySelector('.columna__contador'),
      ancho: columna.clientWidth,
      altoBarra: parseFloat(getComputedStyle(barra).height)
    };
  });

  // ── FASE 2 · ESCRIBIR TODO. Ninguna lectura aquí dentro ──
  for (const { barra, etiqueta, ancho, altoBarra } of medidas) {
    barra.style.width = `${ancho * 0.8}px`;
    barra.classList.toggle('columna__barra--alta', ancho > 320);

    // La posición de la etiqueta se CALCULA, no se vuelve a leer del DOM
    etiqueta.style.top = `${altoBarra + 4}px`;
    etiqueta.textContent = `${Math.round(altoBarra)} px de barra`;
  }
}

El cambio clave, más allá de separar fases, es que la línea (e) desaparece: la posición inferior de la barra se deduce de la altura ya medida en lugar de volver a preguntarla al DOM. La mejor lectura de geometría es la que no se hace.

(4) Mejora estimada. De 5 recálculos forzados a 1: se ahorran 4 × 11 ms = 44 ms, y el tiempo total pasa de 96 ms a unos 52 ms. Medido en el portátil de referencia con CPU 4×, el resultado real fue 96 → 49 ms, algo mejor de lo estimado porque el recálculo restante trabaja sobre un árbol menos invalidado.

(5) Segunda mejora, independiente del orden. Sustituir el estilo en línea por variables CSS y dejar que el CSS haga el cálculo. Así se escribe una sola propiedad personalizada por columna y el navegador resuelve anchos y posiciones sin que el JavaScript mida nada:

columna.style.setProperty('--proporcion', String(abiertas / total));
.columna__barra { width: calc(var(--proporcion, 0) * 80%); }
.columna__contador { top: calc(var(--alto-barra, 24px) + 4px); }

Con esto no queda ninguna lectura de geometría, y el coste cae a 1,2 ms. Es la lección de fondo de 06-02 llevada al extremo: deja que el CSS haga la geometría.

Solución 2

# Pantalla Decisión Justificación
1 Tablero canónico, 6 tareas No hacer nada 78 nodos y un render de 1,4 ms. Cualquier técnica de esta lección añadiría complejidad sin beneficio medible. Es la aplicación literal de la regla de 09-01: si no lo has medido como problema, no lo optimices
2 Historial de 4.200 líneas con Ctrl+F content-visibility: auto Virtualizar rompería Ctrl+F, que es el caso de uso principal de esa pantalla. Como las líneas tienen altura uniforme, contain-intrinsic-size: auto 28px deja el desplazamiento perfecto, y el ahorro de layout y pintado es prácticamente el mismo. Los 4.200 nodos son aceptables si no crecen sin límite
3 Vista de impresión, 600 filas No hacer nada (y desactivar cualquier optimización) La impresión necesita todo el contenido en el DOM. Si el tablero está virtualizado, hay que desvirtualizar antes de imprimir, escuchando window.matchMedia('print') o el evento beforeprint. Es la contrapartida de la tabla del apartado 16
4 Selector de 6 etiquetas, altura variable No hacer nada Seis elementos. Y aunque fueran seiscientos, la altura variable rompe la aritmética de ListaVirtual, que exige filas iguales; habría que medir y cachear alturas, con lo que content-visibility sería la opción sensata

La regla que atraviesa las cuatro respuestas: la técnica correcta depende de cómo se usa la pantalla, no de cuántos elementos tiene. Las 4.200 líneas del historial y las 600 filas del informe tienen problemas de tamaño parecidos y soluciones opuestas, porque en una manda Ctrl+F y en la otra manda la impresora.

Solución 3

(a) Por qué no vale animar top y left. Dos motivos, y el segundo es el que impide la solución:

  1. Ambas están en la fila roja del apartado 11: cada fotograma pasa por estilo, layout, pintado y composición. Con 24 tarjetas moviéndose se pierden 38 de 58 fotogramas.
  2. Y sobre todo: la tarjeta no se mueve dentro de un contenedor, cambia de contenedor. Pasa de <ul> de «Pendientes» al <ul> de «En curso». Una animación CSS no puede interpolar entre dos posiciones en árboles distintos, porque el cambio de padre es instantáneo y el estado inicial deja de existir. Por eso hace falta FLIP: medir antes, aplicar el cambio real, medir después, y animar la diferencia con transform.

(b) Implementación integrada en el flujo de 06-06:

// js/vista/controlador.js
import { flip } from './animar.js';
import { $ } from './dom.js';

$('.tablero').addEventListener('click', (evento) => {
  const boton = evento.target.closest('button[data-accion]');
  if (boton === null) return;

  const li = boton.closest('[data-id]');
  const id = Number(li.dataset.id);
  const tarea = tablero.buscarPorId(id);
  const destino = siguienteEstado(tarea, boton.dataset.accion);
  if (destino === null) return;

  // FLIP envuelve el ciclo completo: el cambio de estado Y el render
  li.style.willChange = 'transform';
  const animacion = flip(li, () => {
    tablero.cambiarEstado(id, destino);   // 1 · cambia el ESTADO
    vista.render();                        // 2 · redibuja: el <li> cambia de columna
  });

  animacion?.finished.finally(() => { li.style.willChange = 'auto'; });
}, { signal: controlador.signal });

Esto funciona porque la reconciliación por data-id de 06-06 reutiliza el mismo nodo: insertBefore lo mueve al nuevo <ul> en lugar de destruirlo y crear otro (06-05). Si el render recreara la tarjeta, el nodo medido en la fase «First» ya no existiría y FLIP no tendría nada que animar. Es un caso precioso en el que una decisión de corrección tomada tres módulos antes hace posible una optimización visual.

(c) Varias tarjetas a la vez. El error sería llamar a flip en un bucle: cada llamada hace dos getBoundingClientRect, así que veinticuatro tarjetas serían 48 lecturas alternadas con 24 cambios de layout. La versión correcta agrupa las tres fases:

export function flipVarias(nodos, cambiar) {
  // F · leer todas las posiciones iniciales: UN recálculo
  const primeras = new Map(nodos.map((n) => [n, n.getBoundingClientRect()]));

  cambiar();                                   // el cambio real, de golpe

  // L · leer todas las finales: UN recálculo
  const ultimas = new Map(nodos.map((n) => [n, n.getBoundingClientRect()]));

  // I + P · animar: solo escrituras, solo composición
  return nodos.map((n) => {
    const a = primeras.get(n);
    const b = ultimas.get(n);
    const dx = a.left - b.left;
    const dy = a.top - b.top;
    if (dx === 0 && dy === 0) return null;
    return n.animate(
      [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
      { duration: 220, easing: 'cubic-bezier(0.2, 0, 0.2, 1)' }
    );
  }).filter(Boolean);
}

Dos recálculos forzados en total, en lugar de 48. Es el patrón del apartado 7 aplicado a animación.

(d) prefers-reduced-motion y por qué la animación sobrevive al hilo ocupado:

const sinMovimiento = window.matchMedia('(prefers-reduced-motion: reduce)');

export function flip(nodo, cambiar) {
  if (sinMovimiento.matches) { cambiar(); return null; }   // ← accesibilidad primero
  // …resto igual…
}

Respetar prefers-reduced-motion (07-06) no es un detalle estético: para las personas con trastornos vestibulares, el movimiento en pantalla puede provocar mareo real. La animación debe desaparecer, no ralentizarse.

Y la razón por la que esta animación sobrevive a un hilo principal ocupado es la del apartado 11: element.animate() con transform y opacity se ejecuta en el hilo de composición, no en el principal. Una vez lanzada, si tu JavaScript se queda 300 ms haciendo algo, la animación sigue a 60 fps. Con requestAnimationFrame no: cada fotograma depende de que el hilo principal esté libre, así que la animación se congelaría. Es un argumento decisivo para preferir CSS o la Web Animations API siempre que el efecto se pueda expresar de forma declarativa.

Conclusión

Las dos filas que dominaban la tabla están cerradas. render() ha pasado de 310 ms a 31 ms y el documento de 7.812 nodos a 1.194, con el INP del buscador bajando de propina de 96 a 42 ms y el montículo de 12,8 a 6,1 MB. Y lo más valioso: esas cifras ya no dependen del tamaño del tablero.

Entiendes por qué el DOM es caro, que no es por lo que casi todo el mundo cree. Cruzar la frontera entre JavaScript y el motor de renderizado cuesta nanosegundos; lo que cuesta milisegundos es recalcular, y el navegador ya evita recalcular de más agrupando todas tus escrituras hasta justo antes del siguiente fotograma. Conoces el pipeline de renderizado —JavaScript, estilo, layout, pintado, composición— y sabes que se puede entrar por la mitad: cambiar width recorre las cinco fases, cambiar background-color se salta el layout, y cambiar transform u opacity se salta también el pintado y lo resuelve la GPU sola.

Sabes qué es un cálculo de estilo síncrono forzado y tienes la lista negra que lo provoca —offsetHeight y familia, clientWidth, scrollTop, getBoundingClientRect, getComputedStyle, innerText, focus(), scrollIntoView()—, con lo que queda cerrado el aviso que 06-02 dejó abierto. Y reconoces el layout thrashing a simple vista: una de esas lecturas dentro de un bucle que además escribe. En Nómada Tareas eran cuarenta lecturas de offsetHeight sobre las tarjetas vencidas —los 41 recálculos de estilo y 38 layout del Bottom-Up de 09-01— y costaban 137 ms de los 310. El patrón read-then-write los redujo a uno solo y el render a 179 ms, sin cambiar ni una operación: solo el orden.

Tienes las magnitudes reales de cada técnica, que es lo que evita perder el tiempo en la equivocada. DocumentFragment y replaceChildren aportan un 9–10 %, no el orden de magnitud que la leyenda les atribuye; lo que multiplica por dieciséis es intercalar una lectura en el bucle de inserción. reconciliar de 06-06 gana 38× en el caso frecuente —una tarjeta que cambia— y pierde frente a replaceChildren cuando se reordena la lista entera, límite que la propia lección anunció. Y la optimización más aburrida resultó ser de las mejores: no escribir lo que no ha cambiado, con una huella barata que ahorra 9.600 escrituras por render y baja pintarTarjeta de 149 a 101 ms.

Sabes animar barato: solo transform y opacity, porque son las únicas que se resuelven en composición; con FLIP para los movimientos que cambian la geometría de verdad, con will-change puesto y retirado en lugar de permanente, y con la observación decisiva de que una animación CSS o de la Web Animations API sobrevive a un hilo principal ocupado mientras que una de requestAnimationFrame no. Conoces las tres herramientas de aislamiento: contain para prometer que lo de dentro no afecta a lo de fuera, content-visibility: auto con su contain-intrinsic-size obligatorio —dos líneas de CSS que bajaron el render de 142 a 78 ms—, y will-change con su coste en memoria de GPU. Y sabes cuándo toca requestAnimationFrame en lugar de setTimeout: para todo trabajo visual, con el patrón porFotograma que ejecuta más veces que un throttle de 100 ms y aun así pierde doce veces menos fotogramas, porque lo que importa no es ejecutar poco sino ejecutar en el momento correcto.

Has implementado la virtualización dos veces: la lista progresiva con centinelas de IntersectionObserver, que hace aparecer la primera tarjeta en 11 ms pero acumula nodos al desplazarse, y la ventana calculada con altura fija y espaciadores, que mantiene 48 tarjetas en el DOM sea cual sea el tamaño del tablero. Y has visto su letra pequeña sin adornos: rompe Ctrl+F, exige aria-setsize y aria-posinset para no destrozar la experiencia con lector de pantalla, complica la impresión y los enlaces directos, obliga a filas de altura uniforme y añade noventa líneas con un modo de fallo nuevo. De ahí la regla de decisión: content-visibility por defecto, virtualización solo cuando el problema sean los nodos. Aquí lo era, porque la fila 9 lo exigía explícitamente. Por último, la delegación de eventos de 06-04 ha recibido su tercera justificación —38 ms de registro frente a 0,004 ms, y la única forma sensata de convivir con tarjetas que entran y salen del DOM en cada fotograma— confirmando otra vez que las decisiones de diseño buenas suelen resultar también las rápidas.

Queda un frente, y es el único que no puedes arreglar escribiendo mejor JavaScript, porque ocurre antes de que se ejecute la primera línea de tu código. Las filas 1, 3 y 10 siguen intactas: 4,1 s de LCP, 0,21 de CLS y 214 kB en 28 peticiones antes de que aparezca la primera tarjeta. Todo lo que has optimizado en tres lecciones —el índice Map, la caché, el worker, la limpieza de fugas, la ventana virtual— no sirve de nada durante los cuatro segundos en los que Lucía mira una pantalla en blanco desde el móvil. Ahí manda otra cosa: qué descarga el navegador, en qué orden y cuánto de eso se usa realmente. Es la deuda que 01-03 y 06-01 dejaron apuntada al hablar de defer y type="module", y la que 05-04 aplazó explícitamente al mencionar el tree shaking y los empaquetadores. Le toca ahora, y cierra el módulo: Carga Perezosa y División de Código.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados