CSS en el build: code splitting, extracción y orden
En desarrollo Vite inyecta el CSS por JavaScript para un HMR instantáneo; en el build lo extrae a archivos css reales, lo parte por chunk asíncrono y lo enlaza. Pero partir el CSS reabre una herida antigua: la cascada depende del orden, y el orden entre archivos partidos no siempre es el que supones. Las capas y los módulos son la respuesta de 2026.
En desarrollo, Vite inyecta el CSS a través de JavaScript para lograr un HMR instantáneo. En el build eso provocaría un destello de contenido sin estilo y un render bloqueado a la espera del JS, así que Vite extrae el CSS a archivos .css reales, los parte por chunk asíncrono y los enlaza con etiquetas <link>. Pero partir el CSS reabre una herida antigua del lenguaje: la cascada depende del orden de inserción, y el orden entre archivos partidos no siempre es el que darías por supuesto. Esta lección va de esa tensión y de cómo desactivarla.
- Entender por qué en dev el CSS viaja por JS y en el build se extrae a archivos.
- Ver qué hace
cssCodeSplit: un CSS por chunk asíncrono, cargado bajo demanda. - Manejar CSS Modules y saber qué los distingue del CSS global.
- Razonar sobre el orden de carga y por qué el split puede alterar la cascada.
De la inyección por JS a la extracción a archivos
En desarrollo, un import "./estilos.css" hace que Vite inyecte una etiqueta <style> mediante JavaScript. Es ideal para el HMR —puede intercambiar los estilos sin recargar la página— pero sería malo en producción: el render dependería de que el JS se ejecute, con el consiguiente destello sin estilo, ese FOUC que delata una cascada que llega tarde. Por eso el build hace lo contrario: extrae todo el CSS a archivos .css y emite etiquetas <link rel="stylesheet">, de modo que el navegador pueda pintar con estilo antes de ejecutar una sola línea de JavaScript.
Esta asimetría entre dev y build es deliberada, no un descuido: cada mitad elige el compromiso opuesto. En desarrollo manda la velocidad de iteración, y el CSS por JS permite reemplazarlo en caliente sin recargar. En producción manda la experiencia de carga, y solo un archivo enlazado deja pintar sin esperar al script. El mismo import de CSS produce dos comportamientos distintos según la mitad, y entender esa dualidad evita sorpresas al pasar de una a otra.
| Desarrollo | Build | |
|---|---|---|
| Cómo llega el CSS | inyectado por JS | archivo .css enlazado |
| Prioridad | HMR instantáneo | pintar sin esperar al JS |
| Riesgo que evita | recargar al editar | el destello sin estilo |
flowchart TB subgraph Dev d1[import de css] --> d2[Vite inyecta un style por JS] d2 --> d3[HMR intercambia estilos sin recargar] end subgraph Build b1[import de css] --> b2[extrae a un archivo css con hash] b2 --> b3[emite un link por chunk] b3 --> b4[render con estilo antes de ejecutar JS] end style d2 fill:#89b4fa,color:#11111b style b2 fill:#a6e3a1,color:#11111b
cssCodeSplit: un CSS por chunk
La opción build.cssCodeSplit está activa por defecto. Con ella, cada chunk asíncrono que importa CSS obtiene su propio archivo .css extraído, que se carga solo cuando ese chunk se carga. Una ruta que el usuario nunca visita jamás descarga su CSS: el estilo se parte y viaja con el código al que pertenece.
import { defineConfig } from "vite";
export default defineConfig({
build: {
cssCodeSplit: true, // por defecto: un .css por chunk asincrono
},
});
Ponerlo en false concatena todo el CSS en un único archivo que se carga por adelantado. El orden se vuelve más simple y determinista, pero pierdes la carga bajo demanda; a veces es lo que quieres al empaquetar una librería de componentes, donde consumidores distintos cargan piezas distintas y prefieres una hoja única con precedencia estable a un puñado de fragmentos cuyo orden no controlas.
Qué chunk se lleva qué CSS
El reparto sigue el grafo. El CSS que importa el entry va a la hoja crítica que se carga de inmediato; el CSS que importa un chunk asíncrono —una ruta perezosa, un componente diferido— viaja en la hoja de ese chunk y no se descarga hasta que el chunk se pide.
dist/assets/
├─ index.4f2a9c.css # css critico de la ruta inicial
├─ perfil.a7b3e2.css # solo se descarga al entrar en /perfil
└─ ajustes.c19d4a.css # solo al entrar en /ajustes
- CSS del entry va a la hoja crítica, con
<link>en elhead, siempre. - CSS de un chunk asíncrono viaja en su hoja propia, cargada con el chunk.
- CSS compartido por varios chunks lo coloca Vite para no duplicarlo.
CSS Modules: alcance local
Un archivo .module.css recibe un trato especial: sus nombres de clase se transforman en identificadores locales únicos con hash, y en tu JavaScript importas un mapa de esos nombres. Eso lo distingue del CSS global, que vierte sus reglas sobre todo el documento sin barrera alguna.
/* card.module.css */
.card { padding: 1rem; }
.card:hover { transform: translateY(-2px); }
import styles from "./card.module.css";
// styles.card === "card__card___a1b2c" en el build
element.className = styles.card;
El patrón del nombre generado se gobierna con css.modules.generateScopedName, que ya viste en el nivel de configuración: legible en desarrollo, compacto en producción. La clave es que dos componentes pueden declarar .card sin pisarse, porque físicamente no comparten nombre. Los módulos no cambian el modelo de la cascada —siguen siendo reglas globales—; lo que hacen es garantizar que dos autores no colisionen por accidente, resolviendo el problema del nombrado aunque no el del orden.
Los módulos añaden además composes, que permite que una clase local herede de otra sin duplicar reglas ni depender de la cascada:
/* boton.module.css */
.base { padding: 0.5rem 1rem; border-radius: 6px; }
.primario { composes: base; background: royalblue; }
El orden de carga y la cascada
La cascada de CSS es sensible al orden: entre dos reglas de igual especificidad que apuntan al mismo elemento, gana la última insertada. Con una sola hoja concatenada, “la última insertada” es simplemente el orden del código fuente, estable y legible. En cuanto partes el CSS por chunk y cargas los chunks bajo demanda, “la última insertada” pasa a depender de la navegación y de los tiempos de red: visitar la ruta A y luego la B puede dar un orden, y entrar directo a la B, otro. Es el análogo en CSS de una condición de carrera: las mismas reglas, distintas intercalaciones, distintos píxeles.
Conviene recordar qué decide, en CSS, qué regla gana cuando varias apuntan al mismo elemento, de mayor a menor peso:
- La importancia: una declaración
!importantvence a las normales. - Las capas:
@layerfija una precedencia explícita entre grupos de reglas. - La especificidad: un selector más específico gana a uno más general.
- El orden de inserción: a igualdad de todo lo anterior, gana la última.
El code splitting solo puede tocar el último eslabón, el orden. Por eso apoyarse en cualquiera de los tres superiores —y sobre todo en las capas— vuelve el resultado inmune al azar de la carga.
Vite se esfuerza por preservar un orden determinista basado en el grafo de módulos, pero las fronteras asíncronas y los tiempos de import dinámico aún pueden sorprenderte. La mitigación correcta no es pelear por fijar el orden, sino eliminar la dependencia del orden. La herramienta de 2026 para ello son las capas de cascada, @layer: declaras la precedencia de forma explícita y por nombre, de modo que qué regla gana lo decide su capa, no el azar de qué <link> analizó antes el navegador.
@layer base, components, utilities;
@layer components {
.btn { background: royalblue; }
}
El orden de la primera línea fija la precedencia para siempre: utilities gana a components, y este a base, sin importar en qué archivo ni en qué momento se inserte cada regla. Has movido la decisión de precedencia del cuándo se cargó al en qué capa está declarado, que es exactamente el eje que el code splitting no puede desordenar.
cssCodeSplit true
Un .css por chunk, cargado bajo demanda. Menos CSS inicial, pero orden dependiente de la carga.
cssCodeSplit false
Una sola hoja por adelantado. Más CSS inicial, pero orden estable y determinista.
Los CSS Modules hacen imposible la colisión de nombres —al hashear las clases, dos componentes no pueden chocar en el espacio global—. Las capas de cascada hacen explícita la precedencia —una regla en utilities gana a una de components sin importar el orden de inserción—. Entre ambas conviertes el CSS de un global dependiente del orden en algo que, por fin, se comporta como si tuviera estructura. En un proyecto con code splitting de CSS, apoyar la precedencia en capas y no en el orden de carga es lo que vuelve el resultado reproducible.
La razón por la que partir CSS es sutilmente más difícil que partir JS es que los módulos de JavaScript son un espacio de nombres de verdad —un import enlaza una exportación concreta, y el orden de carga no cambia qué función recibiste—, mientras que el CSS no tiene espacio de nombres alguno: cada regla aterriza en una única cascada global, y cuando dos reglas de igual especificidad apuntan al mismo elemento, gana la última insertada. En una hoja concatenada, “la última insertada” es solo el orden del código fuente: estable y legible. En el instante en que partes el CSS por chunk asíncrono y cargas los chunks bajo demanda, “la última insertada” se vuelve una función de la navegación y de la latencia de red —visita la ruta A y luego la B y obtienes un orden; entra directo a la B y puedes obtener otro—. Es el análogo en CSS de una condición de carrera: las mismas entradas, distintas intercalaciones, distintos píxeles en pantalla. La reacción equivocada es pelear con el bundler por imponer un orden determinista; la correcta es eliminar por completo la dependencia del orden, y 2026 por fin nos da la herramienta para hacerlo con limpieza: las capas de cascada. Las capas permiten declarar la precedencia de forma explícita y por nombre —base por debajo de componentes por debajo de utilidades— de manera que quién gana lo decide la capa de la regla, no el accidente de cuál <link> de qué chunk analizó el navegador en último lugar. Los CSS Modules atacan la misma dolencia por el otro extremo, hasheando los nombres de clase para que dos componentes no puedan colisionar físicamente en el espacio global. Entre el scoping —hacer imposibles las colisiones— y las capas —hacer explícita la precedencia—, conviertes el CSS en algo con estructura de verdad. Y la lección generaliza mucho más allá del CSS: siempre que el code splitting expone una suposición de orden que no sabías que tenías —el orden de inserción del CSS, el orden de los efectos de lado, la secuencia de los polyfills—, el arreglo duradero nunca es fijar el orden, sino volver el resultado independiente de él. Depender del orden es depender de un detalle que el bundler tiene todo el derecho a cambiar; construir sobre precedencia explícita es construir sobre algo que ninguna reordenación puede romper.
- Monta una app de dos rutas con CSS por ruta y confirma que cada una emite su propio
.css, cargado solo al visitarla. - Pon
cssCodeSplitenfalsey observa cómo todo el CSS colapsa en un archivo cargado por adelantado; compara el peso inicial. - Convierte una hoja global en un
.module.css, importa el mapa y confirma que los nombres de clase salen con hash. - Crea dos reglas de igual especificidad en dos chunks y cárgalos en distinto orden para reproducir un vuelco de la cascada.
- Envuelve tus estilos en
@layer base, components, utilitiesy confirma que la precedencia deja de depender del orden de carga.