Layout: por qué es la etapa cara
Qué calcula exactamente el layout, por qué el coste no es local, qué es un layout síncrono forzado y cómo se acota el alcance con contención.
Layout es la etapa que convierte valores computados en geometría, y es cara por una razón estructural que no se puede optimizar: el tamaño de una caja puede depender de sus hijos, de sus hermanos y de su contenedor a la vez. Esa circularidad obliga al motor a resolver un sistema, no a rellenar una tabla, y explica por qué una propiedad aparentemente inocente puede disparar trabajo en la mitad del documento. Esta lección explica el mecanismo, no el eslogan.
- Explicar por qué el coste de layout no se puede acotar mirando solo el elemento que cambia.
- Reconocer un layout síncrono forzado en una traza y en el código que lo causa.
- Acotar el alcance del layout con
containy concontent-visibility. - Estimar el coste relativo de animar una propiedad de layout frente a una de composición.
Por qué el coste no es local
Layout resuelve un sistema de dependencias en dos direcciones, y las dos existen a la vez en cualquier página real.
Hacia abajo. El ancho de un bloque en flujo normal lo impone su contenedor. Ese ancho determina dónde se parten las líneas de texto, que determina la altura, que a su vez afecta a la posición de todo lo que viene después.
Hacia arriba. El tamaño intrínseco de un contenedor lo imponen sus hijos. Un fit-content, un elemento de rejilla con auto, un contenedor flex sin altura fija: en todos ellos el motor tiene que medir a los hijos antes de poder colocar al padre.
Cuando las dos direcciones se cruzan, aparecen las medidas múltiples. Un contenedor flex con align-items: stretch y elementos cuya altura depende del ancho puede exigir dos o tres pasadas de medición sobre el mismo subárbol. Los motores modernos cachean resultados intermedios de forma agresiva —el motor de layout de Blink, LayoutNG, se diseñó explícitamente alrededor de esa idea—, pero la caché solo ayuda si la entrada no cambia, y en una animación cambia sesenta veces por segundo.
De ahí sale la primera regla, y es más fuerte que “no animes width”: anima propiedades cuyo valor no participe en el cálculo de nadie más. width participa; transform no, porque se aplica después de que la geometría esté resuelta y no afecta al espacio que la caja ocupa en el flujo.
La segunda regla es que la posición del elemento en el árbol importa tanto como la propiedad. Animar width en el último hijo de un contenedor con overflow: hidden y tamaño fijo invalida poco. Animar la misma width en un elemento cuya altura afecta al alto del documento invalida hasta la raíz, y con ella todo lo que venga después en el flujo.
El layout síncrono forzado
El motor agrupa las invalidaciones y hace un único layout por fotograma. Ese agrupamiento se rompe en cuanto tu código pide un valor que solo se puede contestar con geometría actualizada: entonces el motor tiene que ejecutar estilo y layout en ese instante, dentro de tu tarea, antes de devolverte el número.
La lista de propiedades y métodos que fuerzan layout es larga, pero se puede recordar por su naturaleza: cualquier cosa que devuelva una medida en píxeles del estado actual.
| Categoría | Ejemplos |
|---|---|
| Geometría del elemento | offsetTop, offsetHeight, clientWidth, getBoundingClientRect() |
| Desplazamiento | scrollTop, scrollHeight, scrollIntoView() |
| Estilo resuelto | getComputedStyle(el).height y cualquier propiedad de longitud |
| Documento | window.innerWidth en algunos casos, getClientRects() |
| Rangos y texto | Range.getBoundingClientRect(), Element.checkVisibility() |
El patrón destructivo es alternar lectura y escritura sobre elementos distintos, porque cada escritura invalida y cada lectura fuerza:
// Patologico: N layouts sincronos forzados en un solo fotograma.
for (const fila of filas) {
fila.style.height = `${fila.previousElementSibling.offsetHeight}px`;
}
La corrección no es cachear el valor de un elemento, es separar las fases:
// Una sola invalidacion y un solo layout, al final del fotograma.
const alturas = filas.map(f => f.previousElementSibling.offsetHeight); // leer
for (let i = 0; i < filas.length; i++) {
filas[i].style.height = `${alturas[i]}px`; // escribir
}
En una traza de rendimiento, un layout síncrono forzado aparece como un bloque de layout dentro de una tarea de JavaScript, en lugar de después. Las herramientas de Chromium lo marcan con un aviso y te dan la línea de código que lo provocó, tanto la que lo forzó como la escritura anterior que dejó el árbol sucio. Esa doble atribución es la que hace que valga la pena mirar la traza en vez de razonar sobre el código.
Cuando se compara animar width con animar transform se suele decir que la primera dispara layout y ahí se acaba la explicación. Falta la parte que de verdad explica el factor de cien. Un layout de un subárbol pequeño puede costar 0.2 milisegundos, que cabría de sobra en el presupuesto. El problema es lo que arrastra detrás: si la geometría cambió, la lista de dibujo de esa zona ya no vale, así que hay que repintar, y si hay que repintar hay que rasterizar. Y la rasterización tiene un coste proporcional al área en píxeles de dispositivo, no al número de elementos. Un contenedor de 900 por 600 píxeles CSS en una pantalla con factor de escala 2 son 900 por 600 por 4, es decir 2.16 millones de píxeles que hay que volver a producir. Sesenta veces por segundo son 129 millones de píxeles por segundo generados y subidos a la GPU, para un cambio que visualmente es un rectángulo que se ensancha. Animar transform sobre ese mismo contenedor genera exactamente cero píxeles nuevos: la textura ya está en memoria de la GPU y lo que cambia es una matriz de 16 números que se aplica al dibujar. Esa es la diferencia real, y por eso la comparación correcta no es “una etapa más” sino “millones de píxeles frente a dieciséis números”. También explica dos cosas que de otro modo parecen contradictorias: por qué animar width en un elemento diminuto casi no se nota —el área es pequeña—, y por qué animar box-shadow, que no toca layout en absoluto, puede ser igual de caro que width —el área afectada por una sombra difusa es enorme y hay que recalcular un desenfoque sobre ella—.
Acotar el alcance con contención
Cuando de verdad necesitas animar una propiedad de layout, la herramienta que reduce el coste no es una curva más corta: es declarar al motor que las consecuencias no salen de una caja.
contain: layout es una promesa: nada de lo que ocurra dentro de este elemento afecta al layout de fuera, y nada de fuera afecta al layout de dentro más allá del tamaño que le impongas. Con esa garantía, el motor puede recalcular el subárbol sin volver a subir hasta la raíz.
.panel-lista {
contain: layout paint; /* el layout y el pintado no salen de aqui */
}
contain: paint añade que nada se dibuja fuera de los límites del elemento, lo que además recorta el área de repintado. Las dos juntas son la combinación habitual para listas y paneles.
El valor content es un atajo para layout paint style, y strict añade size, que es una promesa mucho más fuerte: el tamaño del elemento no depende de su contenido. size es la que más ahorra y la que más fácil rompe el diseño, porque obliga a que el elemento tenga dimensiones propias.
content-visibility: auto va un paso más allá: además de contener, permite al motor saltarse por completo el layout y el pintado del contenido cuando está fuera de la pantalla. Para listas largas es la mejora más grande disponible sin virtualizar en JavaScript. Su contrapartida hay que conocerla: el contenido saltado no participa en la búsqueda del navegador ni en el cálculo de la altura, así que necesita contain-intrinsic-size para no destrozar la barra de desplazamiento.
.item-largo {
content-visibility: auto;
contain-intrinsic-size: auto 180px; /* altura estimada mientras esta oculto */
}
El auto delante de la longitud es importante: hace que el motor recuerde el tamaño real una vez que lo ha medido, en lugar de usar siempre la estimación.
El orden de coste, con números
Para tener una intuición calibrada, conviene fijar el orden de magnitud relativo entre las tres familias de cambio sobre un contenedor de tamaño medio en una página real. Los valores absolutos dependen del dispositivo, pero las proporciones se sostienen:
| Qué animas | Etapas que dispara | Coste relativo aproximado |
|---|---|---|
transform, opacity en capa propia |
Draw | 1 |
background-color, box-shadow |
Paint, Raster, Draw | 20 a 100 |
width, height, top, margin |
Layout, Paint, Raster, Draw | 50 a 300 |
| Cambio de contenido que altera el alto del documento | Todo, desde la raíz | 200 a más de 1000 |
Lo que hay que retener no es la tabla, es la forma: el salto grande está entre composición y pintado, no entre pintado y layout. Muchísima gente evita width con cuidado religioso y anima box-shadow sin pensarlo, y en un elemento grande la segunda es peor.
- Anima
widthde un contenedor de 800 por 500 píxeles durante tres segundos y registra el panel de rendimiento. Anota el tiempo total de layout y el de rasterización por separado. - Cambia la animación a
scalesobre el mismo elemento con el mismo resultado visual aproximado y compara los dos totales. - Vuelve a la versión con
width, añadecontain: layout painty mide otra vez. Anota cuál de los dos totales bajó y cuál no. - Explica en una frase por qué la contención no eliminó el coste de rasterización.