La etapa de estilo: qué se recalcula y por qué
Cómo el motor decide qué elementos necesitan recalcular estilo, qué invalida un subárbol entero, y por qué el coste no es proporcional al número de reglas.
La etapa de estilo tiene fama de barata y en la mayoría de las páginas lo es, hasta que deja de serlo de golpe. Su coste no depende de cuántas reglas tengas, sino de cuántos elementos han quedado invalidados y cuántas reglas hay que reconsiderar para cada uno. Una animación que toca una custom property heredada en la raíz puede invalidar el documento entero cada fotograma sin que ninguna herramienta grite, porque técnicamente todo funciona: solo es lento.
- Describir qué produce exactamente la etapa de estilo y qué no.
- Explicar el mecanismo de invalidación y qué lo dispara.
- Identificar los tres patrones que invalidan más elementos de los necesarios.
- Medir el coste de recálculo de estilo y atribuirlo a su causa.
Qué produce esta etapa
La etapa de estilo toma el árbol del DOM y el conjunto de reglas, y produce para cada elemento un objeto con el valor computado de todas las propiedades. Ese trabajo se descompone en tres subtareas con costes muy distintos.
Emparejar selectores. Para cada elemento, determinar qué reglas le aplican. El motor no recorre las reglas en orden: tiene índices por etiqueta, clase, identificador y atributo, y para cada elemento solo considera las reglas cuyo selector clave —el componente más a la derecha— podría coincidir. Después evalúa esos candidatos de derecha a izquierda, porque descartar antes es más barato.
Aplicar la cascada. Ordenar las declaraciones que compiten por origen, capa, especificidad y orden de aparición, y quedarse con una.
Computar los valores. Convertir el valor especificado en el valor computado: resolver em a píxeles, heredar lo que corresponda, evaluar calc(), resolver var(). Esta subtarea es la que sorprende, porque escala con el número de propiedades personalizadas y con la profundidad del árbol, no con el número de reglas.
Lo que no produce esta etapa es geometría. width: 50% computa a 50%, no a píxeles; convertirlo requiere saber el ancho del contenedor, y eso es layout. Esa separación explica por qué cambiar una propiedad puede costar solo estilo o costar estilo más layout: depende de si el valor computado se puede resolver sin conocer el entorno.
La invalidación: quién se recalcula y quién no
El motor no recalcula todo el documento cuando cambia algo. Mantiene información de invalidación que le permite marcar un conjunto mínimo de elementos sucios. El mecanismo tiene tres granularidades y conviene distinguirlas porque el salto de coste entre ellas es enorme.
Invalidación local. Cambias un estilo en línea de un elemento. Solo ese elemento se marca. Es el caso barato.
Invalidación por descendencia. Cambias algo que puede afectar a los descendientes: una clase que aparece en un selector de descendencia, o una propiedad heredada. El motor marca el elemento y su subárbol. El coste es proporcional al tamaño del subárbol.
Invalidación global. Cambias algo en el elemento raíz que se hereda, o modificas la hoja de estilos, o insertas una regla. El coste es proporcional al documento entero.
El salto de la primera a la tercera es el que produce animaciones lentas sin causa aparente. Y hay un caso concreto donde ocurre casi sin querer.
/* Cada cambio de --acento en :root invalida el estilo de
todo el documento, porque la propiedad se hereda a todos. */
:root { --acento: oklch(70% 0.15 250); }
.boton { background: var(--acento); }
// Animar esto con rAF invalida el documento entero, 60 veces por segundo.
function pulso(t) {
const h = 250 + Math.sin(t / 500) * 40;
document.documentElement.style.setProperty('--acento', `oklch(70% 0.15 ${h})`);
requestAnimationFrame(pulso);
}
La corrección es no tocar la raíz: declara la variable en el elemento más cercano que la necesite y anímala ahí. El subárbol invalidado pasa de miles de elementos a unos pocos, sin cambiar nada del resultado visual.
Los tres patrones que invalidan de más
Custom properties heredadas en la raíz. El caso anterior. Es el más frecuente y el más caro, porque las custom properties se heredan por defecto y :root es el ancestro de todo. La regla operativa es: una custom property que se anima no vive en :root. Vive en el contenedor más pequeño que la use.
Hay además un matiz que agrava el problema: una custom property sin registrar es una cadena de texto sin tipo, así que el motor no puede saber que solo afecta a background y tiene que dar por sucias todas las propiedades que la usen, en todo el subárbol. Registrarla con @property no elimina la invalidación por herencia, pero da al motor información de tipo que permite interpolarla de verdad en lugar de saltar, y en algunos motores permite optimizaciones adicionales.
@property --acento {
syntax: '<color>';
inherits: false; /* clave: corta la herencia y la invalidacion */
initial-value: oklch(70% 0.15 250);
}
inherits: false es la parte importante para el coste. Una custom property registrada como no heredada invalida solo el elemento donde se declara.
Selectores con el estado en un ancestro lejano. Un selector como body.modo-oscuro .tarjeta .titulo obliga a que cualquier cambio de clase en body invalide el subárbol completo. Es correcto y a veces inevitable, pero si ese cambio de clase ocurre durante una animación, estás pagando una invalidación global por fotograma. Cuando el estado tiene que vivir arriba, lo barato es que lo consuma una custom property no heredada, no un selector de descendencia.
Modificar hojas de estilo en ejecución. Insertar o borrar reglas con CSSOM invalida el emparejamiento de selectores para todo el documento, porque los índices dejan de ser válidos. Hacerlo dentro de una animación es raro pero ocurre, típicamente en código que genera keyframes dinámicos. La alternativa es generar los keyframes una vez y parametrizarlos con variables.
El modelo mental habitual es que estilo se recalcula cuando cambias algo, y eso te lleva a buscar la causa entre tus escrituras. Falta la mitad: estilo también se recalcula cuando lees algo que depende de él y hay invalidaciones pendientes. getComputedStyle(el).color no consulta una tabla: obliga al motor a ejecutar el recálculo de estilo pendiente en ese instante, de forma síncrona, dentro de tu tarea de JavaScript. Y como el motor no sabe qué parte del árbol te interesa, hace todo lo que tenga sucio. El caso que arruina animaciones es el de una función de utilidad que lee estilo computado dentro de un bucle: si entre lecturas alguien escribe cualquier cosa, cada lectura vuelve a disparar el recálculo completo, y tienes un coste cuadrático perfectamente invisible en el código. Lo mismo vale para las lecturas de geometría, que además fuerzan layout. La disciplina que lo evita se enuncia igual que la de layout —lee todo, luego escribe todo— pero hay un matiz que la gente pasa por alto: si en tu fase de lectura llamas a código de terceros, no controlas si escribe. Un componente que en su lector llama a un hook de una librería que pone una clase por si acaso ha convertido tu fase de lectura limpia en un intercalado. Por eso, cuando midas y veas muchos recálculos de estilo pequeños en un mismo fotograma en lugar de uno grande, no busques tu bucle: busca quién lee entre tus escrituras.
Medirlo y atribuirlo
En el panel de rendimiento, el recálculo de estilo aparece como una entrada propia, y lo importante no es su duración sino su recuento de elementos afectados, que las herramientas de Chromium muestran al seleccionarla. Ese número es el que te dice si tienes un problema de invalidación o simplemente un documento grande.
Una medida rápida desde código, para comparar dos implementaciones sin abrir herramientas:
// Coste de un recalculo de estilo forzado, aislado de layout.
function costeEstilo(accion, repeticiones = 200) {
accion();
getComputedStyle(document.body).color; // vacia la cola pendiente
const t0 = performance.now();
for (let i = 0; i < repeticiones; i++) {
accion();
getComputedStyle(document.body).color; // fuerza estilo, no layout
}
return (performance.now() - t0) / repeticiones;
}
const enRaiz = costeEstilo(() => {
document.documentElement.style.setProperty('--x', String(Math.random()));
});
const enHoja = costeEstilo(() => {
document.querySelector('.tarjeta').style.setProperty('--x', String(Math.random()));
});
console.log({ enRaiz, enHoja });
La diferencia entre esos dos números en un documento real es de uno o dos órdenes de magnitud, y el resultado visual de las dos versiones puede ser idéntico. Es la mejor demostración de que el coste de estilo no está en lo que animas, sino en a cuántos elementos les pides que se lo replanteen.
- Anima una custom property declarada en
:rootdurante tres segundos y registra el panel de rendimiento. Anota el número de elementos afectados por recálculo. - Mueve la declaración al contenedor más pequeño que la use y repite. Compara los dos números.
- Registra la propiedad con
@propertyeinherits: falsey mide otra vez. - Con la función de medición de esta lección, cuantifica las tres versiones y ordena las mejoras por efecto.