wandres.dev
RENDERIZADO II · Layout thrashing y el DOM

Leer todo y luego escribir todo: el patrón de dos fases

Cómo separar medición de mutación en una aplicación entera, con un planificador de dos colas que funciona, y dónde encaja cada fase en el ciclo del fotograma.

⏱ 19 min

La cura del layout thrashing cabe en una frase: agrupa todas las lecturas y después todas las escrituras. Lo difícil no es entenderla, es imponerla cuando las lecturas y las escrituras están repartidas entre ocho componentes que no se conocen entre sí. Esta lección va del patrón mínimo al planificador que puedes meter en una aplicación real, pasando por el detalle que casi nadie tiene claro: en qué momento exacto del fotograma se ejecuta cada cosa.

🎯 Al terminar esta lección sabrás
  • Aplicar la separación en dos fases a un bloque de código concreto.
  • Implementar un planificador de dos colas que agrupe lecturas y escrituras de toda la aplicación.
  • Situar requestAnimationFrame, ResizeObserver e IntersectionObserver en el ciclo del fotograma y saber en cuál las lecturas son gratis.
  • Reconocer los casos donde dos fases no bastan y qué hacer entonces.

El patrón en su forma mínima

El caso de libro es un bucle que mide y escribe sobre el mismo conjunto de elementos. Separarlo es mecánico: extraer las medidas a un array, y después consumirlo.

// Antes: N layouts.
for (const caja of cajas) {
  caja.style.width = caja.parentElement.offsetWidth / 2 + 'px';
}

// Despues: 1 layout.
const anchos = cajas.map((c) => c.parentElement.offsetWidth);
cajas.forEach((c, i) => (c.style.width = anchos[i] / 2 + 'px'));

Lo importante no es el map. Es que entre la última lectura y la primera escritura hay una frontera, y esa frontera es una invariante: en la fase de lectura nadie escribe, en la fase de escritura nadie lee. En cuanto una sola función viola la invariante, la fase de escritura vuelve a ensuciar el árbol y la siguiente lectura vuelve a forzar el layout.

Hay una segunda forma del patrón que aparece menos en los tutoriales y resuelve más casos: medir una vez y guardar el resultado. Si el valor no depende del elemento concreto —el ancho del contenedor, el alto de una fila uniforme, el ancho de la barra de scroll— léelo una vez fuera del bucle.

// El ancho del contenedor es el mismo para las N cajas: se lee una vez.
const anchoContenedor = contenedor.clientWidth;
for (const caja of cajas) {
  caja.style.width = anchoContenedor / 2 + 'px';
}

Este caso es el más frecuente en código real y también el que más se pasa por alto, porque el offsetWidth está escondido dentro de una función auxiliar y no se ve que se llama mil veces.

Una cola de dos fases para toda la aplicación

En cuanto la aplicación tiene más de un componente, la invariante deja de poder mantenerse localmente. El componente A mide en su render, el componente B escribe en el suyo, y el orden lo decide el framework. La solución estructural es un planificador: en vez de tocar el DOM directamente, todo el mundo encola su trabajo en una de dos colas, y el planificador las vacía en orden una vez por fotograma.

// planificador.js
const lecturas = [];
const escrituras = [];
let programado = false;

function ejecutar(cola) {
  for (let i = 0; i < cola.length; i++) {
    try {
      cola[i]();
    } catch (err) {
      // Un fallo en una tarea no puede tumbar el resto de la cola,
      // pero tampoco debe tragarse: se relanza fuera del bucle.
      setTimeout(() => {
        throw err;
      });
    }
  }
}

function vaciar() {
  programado = false;
  // Vaciamos las colas ANTES de ejecutar. Si un callback encola mas
  // trabajo, va al fotograma siguiente en vez de crecer el array
  // que estamos recorriendo.
  const l = lecturas.splice(0, lecturas.length);
  const e = escrituras.splice(0, escrituras.length);
  ejecutar(l);
  ejecutar(e);
}

function programar() {
  if (programado) return;
  programado = true;
  requestAnimationFrame(vaciar);
}

export function medir(fn) {
  lecturas.push(fn);
  programar();
}

export function mutar(fn) {
  escrituras.push(fn);
  programar();
}

Un uso típico: colocar un tooltip debajo de un botón sin provocar un layout por cada tooltip de la página.

import { medir, mutar } from './planificador.js';

function colocarTooltip(boton, tooltip) {
  medir(() => {
    const r = boton.getBoundingClientRect();
    const alto = tooltip.offsetHeight;
    mutar(() => {
      tooltip.style.transform =
        'translate(' + r.left + 'px,' + (r.bottom + 8) + 'px)';
      tooltip.style.visibility = 'visible';
      // El alto medido decide si cabe debajo o hay que voltearlo arriba.
      if (r.bottom + 8 + alto > window.innerHeight) {
        tooltip.style.transform =
          'translate(' + r.left + 'px,' + (r.top - alto - 8) + 'px)';
      }
    });
  });
}

Aunque veinte componentes llamen a colocarTooltip en la misma tarea, el resultado es un solo layout forzado: el que provoca la primera lectura de la fase de medición. Las diecinueve siguientes encuentran el árbol limpio y son gratis. Este es exactamente el diseño de la librería FastDOM, que lleva más de una década resolviendo el problema; escribirlo tú mismo son cuarenta líneas y te ahorra una dependencia.

Nótese el detalle del window.innerHeight dentro de la fase de medición y no de la de escritura. Es una lectura, y meterla en la fase equivocada rompe la invariante entera aunque el valor no cambie casi nunca.

Dónde encaja cada cosa en el ciclo del fotograma

Aquí está el matiz que separa a quien aplica el patrón de memoria de quien lo entiende. El ciclo de un fotograma en el navegador es, simplificando, este orden:

  1. Se ejecutan las tareas pendientes: eventos de entrada, temporizadores, promesas resueltas.
  2. Se ejecutan los callbacks de requestAnimationFrame.
  3. Se ejecutan los callbacks de ResizeObserver y se calcula IntersectionObserver.
  4. Se recalcula el estilo y se ejecuta el layout.
  5. Se pinta y se compone.

De ahí salen dos conclusiones que contradicen consejos muy repetidos.

Leer dentro de requestAnimationFrame no es gratis. El callback de rAF se ejecuta en el punto 2, antes del layout del punto 4. Si en el paso 1 alguien escribió estilos, tu lectura en rAF fuerza el layout igual que en cualquier otro sitio. Lo que consigue rAF no es evitar el layout forzado, es agruparlo: si todas las lecturas del fotograma pasan por la misma cola de rAF, hay uno en vez de veinte. Es exactamente el valor del planificador de arriba, y merece la pena decirlo así de claro porque la creencia de que “en rAF leer es gratis” está muy extendida y es falsa.

Leer dentro de un ResizeObserver sí es gratis. Los callbacks de observador se ejecutan en el punto 3, después de que el motor haya hecho el trabajo pesado y justo antes de pintar. El contentRect que te llega ya está calculado; leerlo no fuerza nada. Y si lo que querías era precisamente el tamaño de un elemento, esto es estrictamente mejor que medirlo tú.

// Reaccionar a un cambio de tamano sin forzar layout nunca.
const ro = new ResizeObserver((entradas) => {
  for (const e of entradas) {
    // e.contentRect ya viene calculado: leerlo es un acceso a un campo.
    const columnas = Math.max(1, Math.floor(e.contentRect.width / 240));
    e.target.style.setProperty('--columnas', String(columnas));
  }
});
ro.observe(document.querySelector('.rejilla'));

Escribir dentro del callback sí tiene un riesgo: si la escritura cambia el tamaño del propio elemento observado, provocas otra notificación y el navegador la difiere al fotograma siguiente emitiendo el error ResizeObserver loop completed with undelivered notifications. No es un fallo fatal —el navegador corta el bucle—, pero indica que estás realimentando el observador. La escritura de arriba solo cambia una variable CSS que altera el layout interno, no el tamaño del contenedor, así que es segura.

IntersectionObserver da la misma ventaja: sus entradas traen boundingClientRect, intersectionRect y rootBounds ya calculados. Un manejador de scroll que llama a getBoundingClientRect() en cada evento hace ochenta layouts forzados por segundo; el mismo efecto con IntersectionObserver hace cero.

El patrón de dos fases es un parche excelente para un problema que casi siempre se puede no tener

Separar lecturas de escrituras es la respuesta correcta y hay que saber aplicarla, pero conviene ver qué está admitiendo: que tu layout se calcula dos veces, una en el motor y otra en tu JavaScript. Cada vez que mides el DOM para decidir un estilo estás reimplementando, en un lenguaje interpretado y con un modelo mental peor, una parte del trabajo que el motor de layout hace en C++ con veinte años de optimizaciones. El patrón de dos fases hace que ese trabajo duplicado cueste un layout en vez de quinientos, lo cual está muy bien, pero sigue costando uno de más. La pregunta que hay que hacerse antes es otra: ¿por qué necesito el número? Un porcentaje enorme de las mediciones que ves en código real existen para resolver cosas que el propio CSS ya sabe resolver desde hace años. Medir el contenedor para decidir cuántas columnas caben es una container query. Medir la imagen para reservar hueco es aspect-ratio. Medir la posición del header para pegarlo arriba es position: sticky. Medir el ancho del texto para truncarlo es text-overflow. Medir el viewport para elegir un tamaño de fuente es clamp(). Medir la posición de un elemento para colocar un popover encima es anchor positioning. Cada una de esas sustituciones no reduce el coste de la medición: lo elimina, y de paso elimina la clase entera de bugs de desincronización que aparecen cuando el DOM cambia entre tu medida y tu escritura. Así que el orden correcto de las preguntas es: primero, ¿puedo no medir?; segundo, ¿puedo medir con un observador que se ejecuta después del layout?; y solo tercero, ¿cómo agrupo las mediciones que me quedan? La mayoría de la gente empieza por la tercera y se queda ahí, optimizando muy bien un trabajo que no hacía falta hacer.

Cuando dos fases no bastan

Hay tres situaciones donde la separación limpia no es posible y conviene reconocerlas antes de pelearse con ellas.

La medición depende de la escritura anterior. Quieres saber si un texto desborda, y para saberlo tienes que aplicarle antes la fuente y el ancho definitivos. Aquí hay una dependencia real y el layout intermedio es inevitable. Lo que sí puedes es hacerlo una vez para todos los elementos: escribe los estilos de los N elementos, deja que ocurra un layout, y después mide los N. Sigues teniendo dos fases, solo que tres en vez de dos: escribir, medir, escribir.

El framework controla el orden. En React el sitio correcto para medir es useLayoutEffect, que se ejecuta después de que React haya aplicado las mutaciones al DOM y antes de que el navegador pinte. Es el equivalente de la fase de medición del planificador. Meter la medición en useEffect la mueve después del pintado y produce un parpadeo visible; meterla en el cuerpo del componente la ejecuta contra un DOM que aún no refleja el render. La versión de esto en frameworks con reactividad de grano fino cambia de nombre, pero la posición en el ciclo es la misma.

La medición ocurre durante una animación. Si en cada fotograma mides y escribes, el patrón de dos fases dentro del fotograma es correcto pero insuficiente: pagas un layout por fotograma, sesenta por segundo. La salida no es agrupar mejor, es sacar la propiedad animada del layout: animar transform en vez de left, opacity en vez de visibility, y dejar que el compositor haga el trabajo sin volver a tocar la geometría.

Ninguno de los tres casos invalida el patrón. Lo que hacen es marcar su frontera: dos fases resuelve el thrashing, no resuelve el hecho de que estés en el camino crítico del layout.

⚔️ Convierte un componente real
  1. Coge un manejador de scroll de tu código que llame a getBoundingClientRect(). Sustitúyelo por IntersectionObserver y verifica en el perfil que desaparecen los layouts forzados.
  2. Implementa el planificador de dos colas y añade, solo en desarrollo, una comprobación que detecte lecturas hechas durante la fase de escritura y avise por consola.
  3. Mide el mismo trabajo de tres formas: intercalado, con dos fases síncronas y con el planificador. Explica por qué las dos últimas dan lo mismo cuando hay un solo componente y difieren cuando hay veinte.
  4. Busca en tu CSS una medición de JavaScript que puedas sustituir por una container query o por aspect-ratio. Bórrala.
  5. Provoca a propósito el error de bucle de ResizeObserver escribiendo el ancho del elemento observado dentro de su propio callback, y observa cómo el navegador corta el ciclo.