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
- Por qué el DOM es caro
- El pipeline de renderizado, fase a fase
- Qué operación despierta qué fase
- Reflow, repaint y el cálculo de estilo síncrono forzado
- La lista negra: qué lecturas fuerzan un reflow
- Layout thrashing: el bucle que alterna lectura y escritura
- El patrón read-then-write, medido sobre las 600 tarjetas
- Agrupar inserciones:
DocumentFragmentyreplaceChildren - Actualizar en vez de redibujar: qué aporta de verdad
reconciliar - Escribir solo lo que cambia
- Animar barato:
transformyopacity - Aislar el trabajo:
will-change,containycontent-visibility - Sincronizar con
requestAnimationFrame(y no consetTimeout) - Virtualización I: centinelas con
IntersectionObserver - Virtualización II: la ventana calculada con altura fija
- Las contrapartidas de virtualizar
- Delegación de eventos, ahora con números
- Las filas 5 y 9, cerradas
- Síntoma → causa probable → solución
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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.
- 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.
- 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__metaen 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 deDocumentFragment(06-05). display: noneyvisibility: hiddenno 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.
- 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íncronoDevTools 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.
- 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 sí |
| 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.
- 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.
- 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.
- Agrupar inserciones:
DocumentFragment y replaceChildren
DocumentFragment y replaceChildrenEn 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 | 1× |
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.
replaceChildrenno.
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 |
- Actualizar en vez de redibujar: qué aporta de verdad
reconciliar
reconciliarEn 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:
reconciliargana 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.
- 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 3classList.add/toggle - 3 asignaciones de
textContent - 1
replaceChildrende la lista de etiquetas, que destruye y recrea los<li>de etiqueta - 2 escrituras de
disabledy 2setAttribute('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.
disabledse 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,datasetcon selectores CSS asociados—, no todo por sistema.classList.addde una clase ya presente tampoco invalida.classListes unDOMTokenListy comprueba la pertenencia antes de modificar. Esa es la razón de que en 06-02 se insistiera en usarclassListen lugar de machacarclassName: 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.
- Animar barato:
transform y opacity
transform y opacityCambiemos 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.
- Aislar el trabajo:
will-change, contain y content-visibility
will-change, contain y content-visibilityEstas 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.
Y ahora las tres reglas que hacen que will-change ayude en lugar de perjudicar:
- Úsalo con moderación extrema. Cada capa consume memoria de GPU. Poner
will-change: transformen 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. - Ponlo y quítalo. Lo correcto es añadirlo justo antes (con una clase, o al hacer
:hoversobre el ancestro) y retirarlo al acabar. Dejarlo permanentemente en el CSS es exactamente lo que no hay que hacer. - No lo uses para «arreglar» animaciones lentas. Si tu animación toca
height,will-change: heightno 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 |
- Sincronizar con
requestAnimationFrame (y no con setTimeout)
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:
requestAnimationFramepara trabajo visual,setTimeout/debouncepara 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.
- Virtualización I: centinelas con
IntersectionObserver
IntersectionObserverLlegamos 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+removeal agotar los datos evita que el observador siga vivo sin motivo, y evita el nodo desprendido de 09-03.destruir()condisconnect()es obligatorio: unIntersectionObserverobservando 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.
- 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.
- 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 sí 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»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) | Sí |
Regla de decisión: usa
content-visibility: autopor 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-visibilitymá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.
- 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.
- 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.
- 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
offsetHeightcuestan 137 ms; las mismas cuarenta, agrupadas, cuestan 4,3 ms. - No reconocer
getComputedStylecomo lectura cara. Es la más traicionera de la lista negra, porque no parece una medida. - Usar
innerTextpor costumbre. Fuerza recálculo;textContentno. Salvo que necesites específicamente el texto visible, usatextContent. - 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
DocumentFragmentes 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
textContentque ya había invalida igual. Una huella barata evita 9.600 escrituras por render. - Animar
top,left,widthoheight. Cada fotograma pasa por layout. Usatransformyopacity, y FLIP cuando el cambio sea de posición real. - Poner
will-changeen todo por si acaso. Cada elemento promovido consume memoria de GPU. Actívalo justo antes y retíralo al acabar. - Usar
contain: sizesin dar dimensiones. El elemento se trata como de tamaño cero y desaparece. - Usar
content-visibility: autosincontain-intrinsic-size. La barra de desplazamiento se vuelve loca. Conauto Xpx, el navegador recuerda el tamaño real. - Usar
setTimeoutpara trabajo visual. No se sincroniza con la pantalla, sigue corriendo en pestañas ocultas y produce fotogramas dobles.requestAnimationFrame. - Usar
throttle(fn, 100)enscroll. Mejor que nada, peor querequestAnimationFrame: ejecuta menos veces y pierde más fotogramas. - Olvidar
{ passive: true }enscroll,wheelytouchstart. El navegador tiene que esperar a ver si llamas apreventDefault. - Virtualizar sin arreglar la accesibilidad. Sin
aria-setsizeyaria-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('*').lengthen 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.setAttributey 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.
- El tablero canónico de Taller Nómada: 6 tareas en tres columnas.
- El historial de cambios: 4.200 líneas de texto de una sola altura, que Marta consulta con Ctrl+F constantemente.
- La vista de impresión del informe trimestral: 600 filas que hay que enviar a la impresora de una vez.
- 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__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:
- 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.
- 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 contransform.
(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
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
