El perfil del layout thrashing: franjas moradas dentro del script
Reconocer la disposición sincrónica forzada en el perfil, entender por qué el navegador no puede evitarla, la lista de propiedades que la disparan y el patrón de lectura y escritura por lotes.
Hay un patrón de código que convierte una operación lineal en una cuadrática sin que nadie escriba un bucle anidado, que no aparece en ninguna revisión de código porque cada línea implicada es perfectamente razonable, y que produce en el perfil una silueta tan característica que se identifica desde el otro lado de la sala. Se llama disposición sincrónica forzada, y su versión repetida en bucle es lo que la gente llama layout thrashing.
- Reconocer la silueta de franjas alternas y localizar la línea responsable.
- Explicar por qué el navegador se ve obligado a recalcular la disposición sincrónicamente.
- Enumerar las propiedades que fuerzan el cálculo al leerlas.
- Aplicar el patrón de separar lecturas de escrituras y medir la mejora.
La silueta
En la pista del hilo principal, dentro de un único bloque de ejecución de JavaScript, aparecen decenas o cientos de franjas estrechas de color morado alternando con el amarillo del script. Al ampliar, cada franja es un bloque de recálculo de estilo seguido de uno de disposición. El panel marca esos bloques con un aviso y ofrece un enlace directo a la línea que los provocó.
La silueta es inconfundible porque contradice el orden normal del pipeline. En un fotograma sano, la disposición ocurre una vez, al final, después de que todo el script haya terminado de modificar el DOM. Ver disposición dentro del script significa que algo obligó al navegador a adelantarla.
El coste no está en que se recalcule la disposición: eso hay que hacerlo de todas formas. Está en que se recalcule muchas veces en lugar de una. El navegador agrupa por defecto todas las modificaciones y resuelve una sola vez antes de pintar; ese agrupamiento es una optimización que tu código puede desactivar sin querer, y desactivarla convierte cien modificaciones baratas en cien recálculos completos.
Por qué el navegador no puede evitarlo
El mecanismo es una consecuencia inevitable de una garantía del lenguaje, y entenderlo evita buscar una opción de configuración que no existe.
Cuando modificas el DOM, el navegador no recalcula nada: marca el árbol como sucio y sigue ejecutando tu código, con la intención de resolverlo todo junto antes del siguiente fotograma. Es una optimización enorme y funciona sola.
Pero cuando lees una propiedad cuyo valor depende de la geometría, el navegador tiene que devolverte un número correcto ahora mismo, porque tu código lo va a usar en la línea siguiente. No puede devolverte el valor viejo, porque estaría mintiendo. No puede devolverte una promesa, porque la API es síncrona desde hace treinta años. Así que hace lo único que puede: procesa todas las modificaciones pendientes, recalcula estilos, recalcula la disposición, y entonces te da el valor.
Alternar escritura y lectura en un bucle fuerza ese ciclo completo en cada vuelta.
// El patron patologico: escribe, lee, escribe, lee...
function ajustarAlturas(elementos) {
for (const el of elementos) {
el.style.height = 'auto'; // escritura: ensucia el arbol
el.style.height = el.scrollHeight + 'px'; // lectura: fuerza el recalculo
}
}
Con doscientos elementos, eso son doscientos recálculos de disposición del documento entero. Y como cada recálculo cuesta proporcionalmente al tamaño del documento, el coste total crece con el producto de los dos: el bucle es lineal, el trabajo es cuadrático.
Las propiedades que lo disparan
La lista no es intuitiva y merece tenerla presente. Fuerzan el cálculo, al leerlas:
Todo lo relacionado con desplazamiento y posición: offsetTop, offsetLeft, offsetWidth, offsetHeight, offsetParent. Todo lo relacionado con el área de cliente: clientTop, clientLeft, clientWidth, clientHeight. Todo lo relacionado con el desplazamiento del contenido: scrollTop, scrollLeft, scrollWidth, scrollHeight. Los métodos de medición: getBoundingClientRect(), getClientRects(). La consulta de estilos resueltos: getComputedStyle() y la lectura de cualquier propiedad del objeto que devuelve. Y algunas propiedades globales: window.innerWidth e innerHeight cuando hay cambios pendientes, scrollX, scrollY, y los métodos de desplazamiento programático.
Hay además dos casos que sorprenden. focus() puede forzar disposición porque el navegador necesita saber si el elemento está visible. Y la lectura del ancho de un canvas o la medición de texto pueden hacerlo indirectamente.
El patrón que lo resuelve
La solución no es leer menos: es agrupar todas las lecturas antes de todas las escrituras. Un solo recálculo forzado al principio, y todas las modificaciones después.
// Version corregida: primero se lee todo, luego se escribe todo
function ajustarAlturasPorLotes(elementos) {
// Fase de lectura: fuerza como mucho un recalculo
const alturas = elementos.map(el => {
el.style.height = 'auto';
return el.scrollHeight;
});
// Ojo: el map de arriba sigue alternando. Hay que separarlo de verdad:
return alturas;
}
// La separacion correcta, en dos pasadas completas
function ajustarBien(elementos) {
for (const el of elementos) el.style.height = 'auto'; // escrituras
const alturas = elementos.map(el => el.scrollHeight); // lecturas: un recalculo
elementos.forEach((el, i) => { el.style.height = alturas[i] + 'px'; }); // escrituras
}
La versión intermedia está ahí a propósito: es el error que se comete al intentar arreglar el problema, porque el map parece una fase de lectura y en realidad escribe y lee en cada iteración. La separación tiene que ser en pasadas completas del bucle, no dentro de la misma iteración.
Comprobar la diferencia con números es rápido y convence a cualquiera:
// Comparativa medible entre el patron malo y el bueno
(() => {
const host = document.createElement('div');
host.style.cssText = 'position:absolute;left:-9999px;top:0;width:400px';
document.body.append(host);
const nodos = [];
for (let i = 0; i < 400; i++) {
const d = document.createElement('div');
d.textContent = 'Contenido de prueba numero ' + i + ' con texto suficiente para ocupar linea.';
host.append(d);
nodos.push(d);
}
const malo = () => {
for (const el of nodos) { el.style.height = 'auto'; el.style.height = el.scrollHeight + 'px'; }
};
const bueno = () => {
for (const el of nodos) el.style.height = 'auto';
const hs = nodos.map(el => el.scrollHeight);
nodos.forEach((el, i) => { el.style.height = hs[i] + 'px'; });
};
const medir = (nombre, fn) => {
nodos.forEach(el => el.style.height = '');
void document.body.offsetHeight; // vaciar trabajo pendiente
const t = performance.now();
fn();
void document.body.offsetHeight; // forzar el ultimo recalculo
console.log(nombre, Math.round(performance.now() - t), 'ms');
};
medir('alternando lectura y escritura:', malo);
medir('por lotes separados: ', bueno);
host.remove();
})();
La diferencia típica está entre cinco y cincuenta veces, y crece con el tamaño del documento. En un árbol pequeño la mejora es modesta; en una aplicación real con miles de nodos, es la diferencia entre una congelación y una operación imperceptible.
Cuándo aparece sin que lo hayas escrito
El patrón rara vez se escribe de forma tan evidente. Aparece de estas cuatro maneras, y las cuatro son difíciles de ver leyendo código.
Repartido entre funciones. Una función mide y otra escribe, y quien llama las alterna en un bucle. Cada función por separado es correcta.
Dentro de una librería. Un componente de terceros que mide su propio tamaño al montarse; montar cien de ellos produce cien recálculos. El gráfico de llamas lo atribuye a la librería y la solución está en montarlos de otra forma.
En un manejador de desplazamiento. Leer la posición y ajustar algo en función de ella, en cada evento de desplazamiento. Es la forma más dañina porque ocurre continuamente y durante una interacción.
Por un observador mal usado. Un observador de redimensionamiento cuyo callback modifica el tamaño de algo que a su vez dispara el observador. Además de forzar disposición, el navegador detecta el bucle y lo corta, lo que produce avisos y comportamiento inconsistente.
Para los dos primeros casos, el observador de fotogramas de animación largos da la respuesta directamente: incluye por script el tiempo dedicado a estilo y disposición forzados, así que la atribución no hay que reconstruirla a mano.
Después de arreglar unos cuantos casos aparece una conclusión que va más allá de la técnica de separar fases: el patrón vuelve siempre, porque la separación de lecturas y escrituras es una disciplina que hay que mantener en cada cambio, y basta con que alguien añada una medición en el sitio equivocado seis meses después para reintroducirlo entero. Es un arreglo local a un problema estructural. El arreglo estructural es más ambicioso y consiste en aceptar una idea incómoda: medir el DOM para tomar decisiones de disposición es, casi siempre, la señal de que el diseño está intentando hacer en JavaScript algo que el motor de disposición ya sabe hacer. Igualar alturas de tarjetas, colocar un elemento junto a otro, decidir si el texto cabe, reaccionar al ancho del contenedor: los cuatro casos que más veces producen este patrón tienen hoy soluciones declarativas que el navegador resuelve en su propia fase de disposición, sin viajes de ida y vuelta a JavaScript y sin forzar nada. Una rejilla iguala alturas por definición. El posicionamiento por anclaje coloca un elemento respecto a otro sin medirlos. Una consulta de contenedor reacciona al ancho disponible sin escuchar el redimensionamiento. Y cuando la medición es inevitable de verdad, hay dos herramientas que la hacen barata: los observadores de intersección y de redimensionamiento entregan las medidas ya calculadas en su devolución de llamada, sin forzar nada, porque el navegador las obtuvo durante su propio ciclo de disposición y solo te las está pasando. Sustituir un manejador de desplazamiento que lee posiciones por un observador de intersección no es una micro-optimización: elimina la categoría entera de problema. La regla que resume el nivel: cada lectura de geometría desde JavaScript es una petición al navegador de que interrumpa lo que estaba optimizando; si puedes conseguir el mismo efecto declarativamente o con un observador, esa lectura no debería existir.