Generar el output: chunks y formatos
Una vez construido y podado el grafo, el bundler debe convertirlo de vuelta en archivos que un navegador cargue. Cómo la fase de generación linealiza el grafo, agrupa los módulos en chunks según los entry points, los imports dinámicos y el código compartido, y emite cada chunk en un formato de módulo concreto —esm, iife y sus parientes. El compromiso entre pocas peticiones y buena granularidad de caché, y por qué el hashing del nombre de archivo es parte del diseño.
Construir el grafo es media historia; la otra media es deshacerlo en archivos. Una vez que el bundler tiene el grafo completo, transformado y podado de código muerto, debe recorrerlo y linealizarlo en algo que un navegador sepa cargar: uno o varios archivos de texto. Esta es la fase de generación, y encierra dos decisiones independientes. La primera es cómo agrupar los módulos en chunks —qué va con qué en cada archivo. La segunda es en qué formato emitir cada chunk —con qué sistema de módulos declara sus dependencias y expone sus exports. Agrupar y dar forma: eso es generar el output.
- Ver la fase de generación como la operación inversa a la construcción del grafo.
- Entender qué decide los límites de un chunk: entradas, imports dinámicos y código compartido.
- Conocer los formatos de salida (esm, iife y parientes) y cuándo sirve cada uno.
- Comprender el papel del hashing de nombres en la caché de larga duración.
De grafo a archivos
Los bundlers separan con nitidez dos etapas. Primero construyen el grafo —resolver, cargar, transformar, descubrir— y aplican los análisis que operan sobre él, como el tree shaking que marca qué exports viven. Después, en una fase distinta que Rollup llama generate, toman ese grafo ya decidido y lo serializan: eligen cómo repartir sus nodos entre archivos y escriben cada archivo. Esta división importa porque construir es análisis y generar es empaquetado; son problemas diferentes con reglas diferentes.
Serializar un grafo no es trivial porque un grafo no tiene un orden lineal y un archivo sí. Dentro de cada chunk, los módulos deben ordenarse de forma que un módulo aparezca después de aquello de lo que depende —un orden topológico local—, para que cuando el archivo se ejecute de arriba abajo, nada se use antes de estar definido. La generación es, en el fondo, el arte de aplanar una estructura de aristas en una secuencia de líneas sin romper ninguna dependencia.
Rollup expone esta separación en su propia API: rollup() construye el grafo y devuelve un objeto en memoria, y luego generate() o write() producen la salida a partir de él. La razón no es cosmética. Al separar el análisis del empaquetado, un mismo grafo construido una sola vez puede emitirse en varios formatos o con varias configuraciones de output sin rehacer el trabajo de resolver, cargar y transformar. Construir es caro; generar es barato; separarlos permite reutilizar lo caro.
Agrupar en chunks
Un chunk es un conjunto de módulos que se emiten juntos en un mismo archivo. La pregunta natural es por qué no meter siempre todo en un solo archivo gigante. La respuesta la dictan tres fuerzas que trazan los límites entre chunks:
Entry chunks
Cada entry point del nivel 17.1 produce su propio chunk de entrada: el archivo que el HTML carga primero. Varias entradas, varios chunks de entrada.
Async chunks
Cada import() dinámico corta el grafo: su subárbol se emite como un chunk aparte que se carga bajo demanda, no al inicio. Son las tijeras del code splitting.
Shared chunks
Un módulo alcanzado por dos chunks distintos puede extraerse a un chunk común para no duplicarse. Una librería usada por varias rutas vive una sola vez.
flowchart TD G[Grafo de modulos completo] --> P[Particion en chunks] P --> E[Chunk de entrada main] P --> S[Chunk compartido vendor] P --> A[Chunk asincrono chart] E --> F1[main.hash.js] S --> F2[vendor.hash.js] A --> F3[chart.hash.js] style E fill:#a6e3a1,color:#11111b style S fill:#89b4fa,color:#11111b style A fill:#f9e2af,color:#11111b
Aquí vive un compromiso que gobernará el nivel 22. Menos chunks significan menos peticiones de red, pero peor granularidad de caché: si todo está en un archivo, cambiar una línea invalida el archivo entero en el navegador de tus usuarios. Más chunks significan mejor caché y descargas en paralelo, pero un coste por petición y el riesgo de una cascada de invalidaciones. Además de los límites automáticos, casi todos los bundlers dejan definir chunks manuales para agrupar a mano —por ejemplo, aislar las dependencias estables en un vendor que rara vez cambia—.
Extraer el código compartido a su propio chunk parece siempre ganar: evitas enviar la misma librería dos veces. Pero cada extracción añade un archivo más y una arista de carga más entre chunks. Llevado al extremo, un exceso de chunks compartidos minúsculos produce una cascada de peticiones que anula el ahorro. El objetivo no es maximizar el reuso ni minimizar los archivos, sino encontrar el punto donde el código verdaderamente común se comparte sin pulverizar el output en decenas de fragmentos. Es un problema de equilibrio, no de máximos.
El equilibrio del troceado se juega en dos ejes que tiran en direcciones opuestas:
- Pocos chunks: menos peticiones y menos sobrecarga, pero peor caché —un cambio pequeño invalida un archivo grande— y menos paralelismo de descarga.
- Muchos chunks: mejor granularidad de caché y descargas en paralelo, pero coste por petición y riesgo de cascadas de invalidación entre chunks.
Cuando quieres tomar tú las riendas, casi todos los bundlers exponen una función de chunks manuales que decide, módulo a módulo, en qué chunk cae cada uno:
// Aislar las dependencias estables en un chunk vendor que rara vez cambia
export default {
output: {
manualChunks(id) {
if (id.includes("node_modules")) return "vendor";
},
},
};
Emitir en un formato
Un chunk es un conjunto de módulos, pero un archivo .js necesita hablar un dialecto concreto de módulos para declarar de qué otros chunks depende y qué expone. Ese dialecto es el formato de salida. El mismo grafo puede emitirse en varios, y la elección depende de dónde se ejecutará el resultado.
// main.[hash].js — formato esm
import { render } from "./vendor.[hash].js";
const titulo = "Hola";
render(titulo);
export { titulo };
// bundle.iife.js — formato iife: se envuelve y expone un global
var MiApp = (function () {
"use strict";
function render(t) { /* ... */ }
const titulo = "Hola";
render(titulo);
return { titulo };
})();
El esm usa import y export nativos: es el formato moderno, lo entienden los navegadores y Node sin ayuda, y conserva la estructura estática que permite seguir podando. Es el destino por defecto para apps y librerías modernas. El iife —expresión de función invocada al momento— se envuelve en una función que se ejecuta sola y expone su resultado en una variable global; sirve para un script clásico sin sistema de módulos, como un widget que se incrusta en cualquier página. Junto a ellos viven el cjs, para el require de Node, y el umd, que detecta el entorno y funciona como AMD, CommonJS o global a la vez, al precio de un envoltorio más aparatoso.
Resumido, cada formato responde a un destino distinto:
- esm: el moderno.
importyexportnativos, tree-shakeable, enlaza chunks entre sí. Navegadores y Node al día. - iife: un script suelto que se ejecuta solo y expone un global. Ideal para un widget incrustable; casa mal con el troceado.
- cjs: el
requirede Node. Para herramientas y entornos que aún viven en CommonJS. - umd: el todoterreno que detecta el entorno. Máxima compatibilidad, a cambio del envoltorio más aparatoso.
Cuando hay varios chunks, el formato no solo da forma a cada archivo por dentro: decide cómo un chunk carga a otro. En esm, un chunk de entrada importa a un chunk compartido con un import nativo que el navegador resuelve. En iife, no hay imports entre archivos: o todo va en uno, o el orden de las etiquetas script y las variables globales hacen de pegamento. Por eso el iife encaja mal con el code splitting y el esm lo abraza: la capacidad de un chunk de importar a otro es justo lo que el formato moderno regala y el clásico no tiene.
Nombres y hashing
El último detalle de la generación es cómo se llaman los archivos, y no es cosmético. Los bundlers incrustan un hash del contenido en el nombre: main.a1b2c3.js. Si el contenido no cambia, el hash no cambia, y el navegador reutiliza indefinidamente el archivo cacheado; si cambia una línea, cambia el hash, cambia la URL y el navegador descarga la versión nueva. Es el truco que permite servir los assets con caché eterna sin miedo a servir código rancio: el cache busting por nombre.
Los bundlers distinguen varios tipos de salida a la hora de nombrar, cada uno con su plantilla:
- Entry chunks: los archivos que carga el HTML; su nombre suele ser estable y legible.
- Chunks internos: los asíncronos y compartidos, nombrados con hash porque nadie los referencia a mano.
- Assets: CSS, imágenes y fuentes, también con hash para el cache busting.
Este esquema tiene un efecto de segundo orden que conecta con el troceado. Si el hash de un chunk depende de su contenido, y su contenido incluye los nombres de los chunks que importa, entonces cambiar un chunk compartido cambia su hash y propaga el cambio a todos los que lo importan, invalidando su caché en cascada. Diseñar bien los límites de los chunks es, en parte, contener esa propagación: aislar lo que cambia a menudo de lo que casi nunca cambia. Verás el pipeline de hashing y assets a fondo en el nivel 25.
La fase de generación es donde el bundler cierra el círculo: toma la estructura abstracta que construyó —un grafo dirigido de módulos, podado y transformado— y la devuelve al mundo concreto de los archivos que un navegador descarga y ejecuta. Y lo hace resolviendo dos problemas ortogonales que conviene no confundir jamás. El primero es de topología: cómo particionar el grafo en chunks, una decisión que equilibra el número de peticiones contra la granularidad de la caché, guiada por las entradas, los puntos de import dinámico y el código que dos ramas comparten. El segundo es de interfaz: en qué formato emitir cada chunk, lo que no es un detalle de estilo sino un contrato con quien lo va a ejecutar —esm para el navegador y Node modernos, con imports que enlazan chunks entre sí; iife para un script suelto que expone un global; cjs y umd para los mundos que aún viven en require. La lección profunda es que estas dos decisiones son independientes: puedes trocear igual y emitir en formatos distintos, o emitir el mismo formato con troceados opuestos. Quien las mantiene separadas en la cabeza lee cualquier configuración de output sin perderse, porque sabe distinguir una opción que reparte módulos entre archivos de una que decide el dialecto de esos archivos. Y quien entiende que el hashing del nombre no es decoración, sino el mecanismo que hace segura la caché eterna y a la vez la fuente de las invalidaciones en cascada, empieza a diseñar el troceado pensando en qué cambia junto y qué cambia por separado. Generar no es escribir archivos: es traducir un grafo a un paquete que otro sistema —el runtime del navegador— sabrá reconstruir.
- Construye un proyecto con un
import()dinámico y cuenta los chunks deldist: identifica cuál es el de entrada y cuál el asíncrono. - Añade una librería usada por dos rutas distintas y comprueba si el bundler la extrae a un chunk compartido en vez de duplicarla.
- Emite el mismo código en formato esm y en formato iife y compara los dos archivos: localiza el envoltorio de función y el global que aparecen solo en el iife.
- Cambia una línea de un módulo y observa cómo cambia el hash de su chunk en el nombre de archivo, mientras los chunks intactos conservan el suyo.
- Modifica un chunk compartido y verifica la propagación: su nuevo hash obliga a renombrar todos los chunks que lo importan.