wandres.dev
VITE 8 Y ROLLDOWN · el motor por debajo

El grafo de módulos y el bundling

Cómo el bundler convierte tu proyecto en un artefacto de producción: el grafo de módulos como materia prima, el tree shaking que poda las exportaciones que nadie usa, el code splitting que reparte el código en chunks cacheables, el tratamiento de los assets con hash de contenido, y cómo leer la carpeta de salida del build de Astro 7 para entender qué se generó y por qué.

⏱ 17 min

Un build no es una traducción lineal de ficheros de entrada a ficheros de salida. Es una operación sobre un grafo: el bundler parte de tus páginas, sigue cada import hasta el último rincón de node_modules, y con ese mapa completo decide qué código sobrevive, cómo se agrupa y con qué nombre se emite. Leer la carpeta de salida sin entender ese grafo es leer el resultado de una demostración sin ver los pasos. Esta lección te enseña los pasos —tree shaking, code splitting, assets— para que el contenido de la carpeta de build deje de ser un misterio con nombres raros.

🎯 Al terminar esta lección sabrás
  • Ver el grafo de módulos como la materia prima que el bundler recorre desde cada página.
  • Entender el tree shaking: qué poda, por qué solo funciona con ESM y qué lo derrota.
  • Razonar el code splitting y por qué los chunks compartidos con hash mejoran la caché.
  • Leer la carpeta de salida del build de Astro y reconocer cada tipo de fichero emitido.

El grafo de módulos: la materia prima

Cada página de tu sitio es un punto de entrada del grafo. El bundler empieza ahí y sigue las aristas —cada import— hasta construir el conjunto completo de módulos alcanzables. Un módulo al que ninguna página llega, directa o indirectamente, sencillamente no existe para el build: no se emite. Ese grafo, con sus nodos y sus dependencias, es la estructura sobre la que operan todas las optimizaciones posteriores; no hay tree shaking ni splitting sin él.

flowchart TD
E1[pagina index] --> SHARED[modulo compartido]
E2[pagina blog] --> SHARED
E1 --> A[modulo solo de index]
E2 --> B[modulo solo de blog]
SHARED --> CHUNK[chunk comun cacheable]
A --> C1[chunk de index]
B --> C2[chunk de blog]
style SHARED fill:#f9e2af,color:#11111b
style CHUNK fill:#a6e3a1,color:#11111b

En Astro este grafo tiene una particularidad que conviene fijar: la mayoría de tu marcado se resuelve en el servidor y no viaja al cliente. El grafo del navegador solo se puebla con las islas —los componentes con directiva client:— y su código. Por eso un sitio Astro típico emite muchísimo menos JavaScript de cliente que una aplicación equivalente que hidrate la página entera: buena parte de los nodos del grafo nunca cruzan al bundle del navegador.

Conviene distinguir, de hecho, dos grafos que el build maneja en paralelo:

  • El grafo de servidor abarca todo lo necesario para producir el HTML: páginas, layouts, componentes .astro y los datos que consultan en su frontmatter.
  • El grafo de cliente es mucho más pequeño y arranca solo en las islas: contiene el código de framework que se hidrata y lo que ese código importa.

Un mismo componente puede vivir en ambos —renderizado en el servidor, hidratado en el cliente—, pero solo su mitad interactiva engorda el bundle que descarga el visitante. Esta dualidad es la razón de fondo de que Astro pueda apoyarse en dependencias pesadas para generar HTML sin penalizar por ello al navegador.

Tree shaking: podar lo que nadie usa

El tree shaking es la eliminación de código muerto a escala de exportación. Si un módulo exporta diez funciones y tus páginas solo usan dos, el bundler descarta las otras ocho. La magia no es adivinación: se apoya en una propiedad estructural de los módulos ESM. Como import y export son estáticos —se resuelven leyendo el código, no ejecutándolo— el bundler puede saber con certeza qué exportaciones se consumen antes de correr una sola línea.

Para que la poda funcione tienen que cumplirse tres condiciones, y cada una explica un fallo típico cuando no se da:

  • Los módulos deben ser ESM, con imports y exports estáticos y analizables sin ejecutar el código.
  • Las importaciones deben ser específicas: traer solo lo que se usa, no el espacio de nombres entero por comodidad.
  • El paquete debe estar libre de efectos secundarios o declararlo, para que el bundler se atreva a borrar lo que nadie usa.
ℹ️
Por qué CommonJS derrota el tree shaking

Un módulo CommonJS expone su interfaz a través de asignaciones dinámicas a module.exports que solo se conocen al ejecutar el código. El bundler no puede saber estáticamente qué se usa y qué no, así que, por seguridad, conserva el módulo entero. Ese es el motivo técnico de fondo de por qué las dependencias en CJS engordan tu bundle y las publicadas en ESM se podan finas: no es una cuestión de calidad, sino de si la forma del módulo es analizable sin ejecutarlo.

Hay un matiz que separa a quien entiende el build de quien solo lo sufre: los efectos secundarios. Si un módulo, con solo importarse, hace algo observable —registrar un elemento global, mutar un objeto compartido— el bundler no puede eliminarlo aunque no uses ninguna de sus exportaciones, porque borrarlo cambiaría el comportamiento. Los paquetes bien empaquetados declaran su ausencia de efectos con el campo sideEffects para autorizar una poda agresiva.

// package.json de una libreria que promete no tener efectos secundarios
{
  "name": "mi-libreria",
  "sideEffects": false
}

Cuando una librería enorme se niega a adelgazar por más tree shaking que esperes, sospecha de efectos secundarios sin declarar: sin esa promesa explícita, el bundler juega a lo seguro y conserva el módulo entero por si acaso.

Code splitting y chunks

Si el bundler metiera todo el grafo en un único fichero, cada página cargaría el código de todas las demás y cualquier cambio invalidaría la caché del sitio entero. El code splitting evita ambas cosas repartiendo el grafo en varios chunks: unidades de salida que el navegador descarga por separado y cachea de forma independiente.

🧩

Chunk por entrada

Cada página produce su propio chunk con el código que solo ella usa, para no arrastrar el de rutas que el visitante quizá no visite.

🔗

Chunk compartido

El código que varias páginas importan se extrae a un chunk común, que el navegador descarga una vez y reutiliza en toda la navegación.

✂️

Punto de división dinámico

Un import() dinámico marca una frontera: su objetivo se separa en un chunk aparte que solo se descarga cuando de verdad se necesita.

#️⃣

Hash de contenido

Cada chunk se nombra con un hash de su contenido, de modo que solo cambia el nombre de lo que cambió y el resto sigue cacheado.

La cuarta tarjeta es la más importante para el rendimiento real. Al nombrar cada chunk con un hash derivado de su contenido, editar una sola página cambia el nombre de su chunk y del común si lo tocó, pero deja intactos los nombres de todos los demás. El navegador de un visitante recurrente solo redescarga lo que cambió y sirve el resto desde su caché. Esa es la razón por la que verás nombres como Contador.a1b2c3d4.js en la salida: el hash no es ruido, es la clave de una caché que dura.

En Astro esta lógica se aplica isla por isla. Cada componente hidratado es un punto de entrada del grafo de cliente, y el bundler lo emite como su propio chunk con las dependencias que solo él usa. Un import() dinámico refuerza el efecto: marca a mano una frontera de división para diferir código hasta que de verdad se necesite.

// un import dinamico crea un punto de division explicito
async function abrirEditor() {
  const { montarEditor } = await import('./editor-pesado.js');
  montarEditor();
}

Aquí el módulo editor-pesado.js no entra en el chunk inicial: el bundler lo aísla en uno aparte que solo se descarga cuando alguien llama a abrirEditor, y no un instante antes.

Leer el output del build

Cuando ejecutas astro build, la carpeta de salida cuenta una historia legible si sabes su alfabeto. En un sitio estático encuentras el HTML de cada ruta ya renderizado y una carpeta de assets con hash donde viven los chunks de JavaScript, los estilos y las imágenes procesadas.

astro build
# dist/
#   index.html                    ruta renderizada en el build
#   blog/primero/index.html       otra ruta prerenderizada
#   _astro/
#     Contador.a1b2c3d4.js        chunk de una isla hidratada
#     client.e5f6a7b8.js          runtime compartido de cliente
#     index.9c0d1e2f.css          estilos con hash de contenido
#     portada.7a8b9c0d.webp       imagen optimizada y con hash

Los assets —imágenes, CSS, fuentes— siguen la misma lógica de hash que los chunks. Astro extrae los estilos con scope de cada componente, los optimiza y los emite con un nombre versionado; las imágenes que pasan por su pipeline salen convertidas y con hash. Si construyes para SSR, la salida se parte en dos carpetas: una con lo que sirve el cliente y otra con el código de servidor que ejecuta el adapter en cada petición. La terminal, además, imprime un resumen con las rutas generadas y sus tiempos: leerlo te dice de un vistazo qué páginas se prerenderizaron y cuáles quedaron para render bajo demanda.

Al leer una carpeta de build conviene fijarse en tres señales concretas:

  • El número y el tamaño de los chunks de cliente: si son muchos y grandes en un sitio de contenido, algo se está hidratando de más.
  • La presencia de un chunk compartido dominante: revela qué código arrastran en común todas las páginas y merece una auditoría.
  • La división en cliente y servidor cuando hay SSR: confirma qué se resolvió en el build y qué se dejó para la petición.
💡
El resumen de la terminal es la primera radiografía

Antes de abrir la carpeta de salida, lee el resumen que Astro imprime al terminar el build. Enumera cada ruta con su tiempo y marca cuáles se prerenderizaron: es la radiografía más rápida del proyecto. Una ruta que esperabas estática y aparece como bajo demanda, o un tiempo desproporcionado en una página concreta, te ahorran media hora de inspección apuntando directos al problema.

⚠️
Un chunk compartido enorme suele delatar un import mal colocado

Si al inspeccionar la salida ves un chunk común desproporcionado, la causa habitual es una dependencia pesada importada en un punto que fuerza a incluirla en el código compartido de muchas páginas. Antes de aceptar el peso, rastrea quién la importa: a menudo basta con mover ese import a la única ruta que lo necesita, o diferirlo con un import() dinámico, para que el bundler lo aísle en su propio chunk y deje de lastrar al resto del sitio.

El build no traduce ficheros: razona sobre un grafo

Hay un salto conceptual que cambia para siempre cómo lees una carpeta de salida, y es dejar de pensar en el build como una función que toma ficheros de entrada y devuelve ficheros de salida, uno a uno, como un traductor página por página. El build no opera sobre ficheros: opera sobre un grafo de relaciones, y cada fichero que emite es el resultado de una decisión global tomada mirando el mapa completo, no la traducción local de una entrada concreta. Un chunk no corresponde a un fichero tuyo; corresponde a un subconjunto de módulos que el bundler decidió agrupar porque comparten un patrón de uso a lo largo de todo el grafo. Una exportación desaparece no porque tú la borraras, sino porque el análisis del grafo demostró que ningún camino desde ninguna página la alcanza. Un asset lleva un hash no por capricho, sino porque su identidad en la caché del navegador se define por su contenido y no por su nombre original. Entender esto reordena tu relación con la herramienta en un sentido muy práctico: cuando algo en la salida te sorprende —un bundle demasiado grande, un módulo que esperabas podado y sigue ahí, un chunk que se descarga cuando no debería— la pregunta correcta nunca es qué le pasa a este fichero, sino qué forma tiene el grafo que produjo esta decisión. La respuesta está siempre en las aristas: quién importa a quién, si esos imports son estáticos o dinámicos, si la forma del módulo es analizable o se resiste al análisis, si hay efectos secundarios que atan un nodo que creías suelto. El bundler es, en el fondo, un razonador sobre ese grafo, y las optimizaciones que aplica —tree shaking, splitting, hashing— son teoremas que solo puede probar si la estructura se lo permite. Por eso escribir código empaquetable no es un truco de configuración, sino una forma de pensar: importaciones estáticas y explícitas, módulos ESM sin efectos secundarios ocultos, fronteras dinámicas puestas a conciencia donde quieres que el bundler corte. Cuando escribes pensando en el grafo, el build te premia con una salida limpia; cuando escribes ignorándolo, la salida te devuelve, fielmente, el desorden de tus dependencias. Aprender a leer la carpeta de build es, en realidad, aprender a leer la sombra que tu grafo proyecta.

⚔️ Descifra tu carpeta de salida
  1. Ejecuta astro build y abre la carpeta de salida; identifica el HTML de cada ruta, la carpeta de assets con hash y distingue los chunks de JavaScript de los estilos.
  2. Añade una isla con client:load en una sola página, reconstruye y localiza el chunk nuevo que corresponde a esa isla; confirma que las demás páginas no lo cargan.
  3. Cambia una línea de una única página y reconstruye: compara los nombres con hash antes y después para ver que solo cambió el de lo que tocaste.
  4. Convierte un import estático pesado en un import() dinámico y observa en la salida cómo el bundler lo aísla en su propio chunk diferido.