Anatomía de un bundle
Abre el archivo que sale del build y mira dentro. Un bundle no es código concatenado sin más: lleva un runtime diminuto que el bundler inyecta, un registro de módulos que mapea cada id a una función fábrica, y un require interno que las ejecuta bajo demanda y cachea el resultado. Cómo un navegador, que solo sabe correr un script, acaba ejecutando un grafo entero; por qué el caché hace singleton a cada módulo y permite los ciclos; y cómo el scope hoisting aspira a borrar ese runtime en el caso ideal.
Has visto el grafo construirse, transformarse, trocearse y emitirse. Ahora abre el archivo resultante y mira por dentro. Un bundle no es código pegado sin más: lleva pasajero un runtime diminuto —unas pocas líneas que el bundler inyecta—, un registro de módulos que asocia cada id a una función fábrica, y un require interno que ejecuta esas fábricas bajo demanda y cachea lo que devuelven. Entender esta anatomía desmitifica el output: ves con exactitud cómo un navegador, que solo sabe ejecutar un script de arriba abajo, termina recorriendo y ejecutando el grafo entero que construiste en tiempo de compilación. El bundle es, literalmente, ese grafo puesto a andar.
- Reconocer el runtime que el bundler inyecta en el output y por qué existe.
- Entender el registro de módulos como un mapa de id a función fábrica.
- Ver cómo un require interno ejecuta y cachea cada módulo bajo demanda.
- Seguir la ejecución de un bundle desde el entry hasta su primer efecto.
El envoltorio: un runtime diminuto
Cuando varios módulos no pueden fundirse en un solo ámbito, el bundler los envuelve y añade un pequeño runtime: el andamiaje que hace que un puñado de funciones aisladas se comporten como un grafo de módulos con imports y exports. En su forma clásica, el bundle es una función que se ejecuta sola y que contiene dos piezas: un objeto con todos los módulos y una función que sabe ejecutarlos en el orden correcto. Ese runtime no lo escribiste tú; lo inyecta el bundler, y es idéntico para todos tus módulos.
Conviene decir de entrada que este runtime es el caso no ideal. La meta del bundler moderno es el scope hoisting: fundir los módulos que puede en un único ámbito, renombrando sus variables para que no colisionen, de modo que no haga falta registro ni require para la parte enlazada estáticamente. En el mejor caso, el runtime casi desaparece y queda código lineal. Reaparece, inevitable, cuando hay interoperabilidad con CommonJS, imports dinámicos que cargan chunks aparte, o ciclos que no se dejan aplanar. Entender el runtime clásico es entender tanto el bundle real de esos casos como aquello que el hoisting intenta borrar.
En la práctica, el runtime reaparece siempre que el enlace estático no basta por sí solo:
- Imports dinámicos, que cargan un chunk aparte en tiempo de ejecución y hay que coser al grafo ya en marcha.
- Interoperabilidad con CommonJS, que exige emular
module.exportsyrequirepara módulos que no son ESM. - Ciclos que no se pueden aplanar sin romper el orden de evaluación entre los módulos implicados.
- Chunks compartidos, que necesitan un mecanismo para que un archivo enlace con los módulos que viven en otro.
El registro de módulos
El corazón del runtime es el registro: un objeto cuyas claves son los ids que asignó la fase de resolución y cuyos valores son funciones fábrica. Cada uno de tus módulos se convierte en una de esas funciones. La clave está en que registrar un módulo no es ejecutarlo: las fábricas se definen todas, pero ninguna corre hasta que alguien la invoca. El registro es un catálogo de código latente, indexado por id, listo para ejecutarse cuando se le pida.
// El registro: cada id apunta a una funcion fabrica, aun sin ejecutar
const modules = {
"util.js": function (module, exports, require) {
exports.saludo = "Hola";
},
"app.js": function (module, exports, require) {
const util = require("util.js"); // pide otro modulo por su id
console.log(util.saludo);
},
};
Fíjate en la firma de cada fábrica: recibe module, exports y require. Esos tres parámetros son la traducción del sistema de módulos a simples argumentos de función. Lo que un módulo “exporta” es lo que escribe en exports; lo que “importa” lo obtiene llamando a require con un id. El bundler ha reescrito tus import y export —declaraciones estáticas— en manipulaciones de estos objetos, convirtiendo la semántica de módulos en pura mecánica de funciones.
Esta reescritura es la razón última por la que la fase de transformación debía producir imports analizables. El bundler necesita saber, en compilación, exactamente qué importa cada módulo para poder traducir cada import en la llamada a require correcta y cada export en una escritura sobre exports. Un import que solo se conociera en ejecución no cabría en esta mecánica estática: por eso ESM, con sus declaraciones fijas en el nivel superior, es lo que hace posible todo el modelo, y por eso el import() dinámico necesita un tratamiento aparte con carga de chunks.
El require interno y el caché
Falta la pieza que da vida al registro: la función require interna. Su lógica es de una economía elegante. Recibe un id; si ese módulo ya se ejecutó, devuelve sus exports cacheados sin volver a correrlo; si no, crea un objeto de módulo, lo registra en el caché antes de ejecutar la fábrica, invoca la fábrica para que rellene sus exports, y los devuelve.
const cache = {};
function require(id) {
if (cache[id]) return cache[id].exports; // ya ejecutado: devuelve lo cacheado
const module = { exports: {} };
cache[id] = module; // cachea ANTES de ejecutar: clave para ciclos
modules[id](module, module.exports, require); // ejecuta la fabrica del modulo
return module.exports;
}
require("app.js"); // una sola llamada al entry dispara toda la ejecucion
La lógica del require cabe en cuatro pasos, y cada uno tiene su razón de ser:
- Consulta el caché por el id; si el módulo ya corrió, devuelve sus exports y termina sin reejecutar nada.
- Crea el objeto de módulo con un
exportsvacío que la fábrica irá rellenando. - Lo registra en el caché antes de ejecutar, para que un import circular encuentre la entrada a medio construir.
- Ejecuta la fábrica pasándole
module,exportsy el propiorequire, y devuelve lo que quedó enexports.
Dos detalles cargan con todo el peso. El caché garantiza que cada módulo se ejecute una sola vez por muchas veces que lo importen: es lo que hace a un módulo un singleton, exactamente como manda ESM, y es la razón de que un módulo con estado compartido lo comparta de verdad. Y cachear antes de ejecutar la fábrica es lo que permite los ciclos: si app requiere util y util, a mitad de ejecución, requiere app, la segunda llamada encuentra a app ya en el caché —aunque incompleto— y devuelve sus exports parciales en vez de caer en una recursión infinita. Ese orden aparentemente arbitrario es la línea que hace terminar a los imports circulares.
Que cada módulo se ejecute una sola vez tiene una cara luminosa —el estado compartido se comparte de verdad— y una cara traicionera. Si un módulo ejecuta efectos secundarios al cargarse —abrir una conexión, registrar un manejador global, leer una variable de entorno—, esos efectos ocurren exactamente una vez, cuando el primer require lo despierta, y en el orden que dicta el grafo. El bug clásico nace cuando dos módulos que se importan en círculo dependen del estado del otro durante su ejecución inicial: uno recibe los exports del otro a medio construir. Entender el caché previo a la ejecución es entender por qué eso pasa sin colgarse y cómo evitarlo.
Cómo se ejecuta en el navegador
Ahora junta las piezas y mira el bundle correr. El navegador carga el chunk de entrada como un único script y lo ejecuta de arriba abajo. Ese script hace tres cosas: define el registro de módulos, define la función require, y llama a require con el id del entry. Esa única llamada es la chispa. La fábrica del entry se ejecuta, llama a require por cada uno de sus imports, cada uno ejecuta su fábrica y cachea sus exports, y así recursivamente hasta que todo el grafo alcanzable se ha ejecutado, cada módulo una vez, en orden de dependencia.
flowchart TD load[Navegador carga el chunk de entrada] --> def[Define el registro y el require] def --> call[Llama al require del entry] call --> fac[Ejecuta la fabrica del entry] fac --> imp[Pide sus imports con require] imp -->|no cacheado| run[Ejecuta la fabrica y la cachea] imp -->|ya cacheado| ret[Devuelve los exports cacheados] run --> imp style call fill:#a6e3a1,color:#11111b style run fill:#89b4fa,color:#11111b
El orden en que corren las fábricas no es arbitrario: es un recorrido en profundidad del grafo desde el entry, en el que un módulo termina de ejecutarse solo después de aquellos de los que depende. Ese es exactamente el orden topológico que la generación grabó al linealizar cada chunk. El bundle no solo lleva el grafo como datos: lleva impreso el orden correcto de despertarlo, de modo que ninguna fábrica use algo que todavía no existe.
El registro
Un mapa de id a función fábrica. El grafo convertido en catálogo de código latente, donde definir no es todavía ejecutar.
El require
El motor que ejecuta fábricas bajo demanda y cachea sus exports. Da identidad única a cada módulo y hace terminar los ciclos.
El arranque
Una sola llamada al entry que, como una ficha de dominó, desencadena el recorrido de todo el grafo alcanzable.
Repara en la simetría perfecta con los primeros niveles. En tiempo de compilación, el bundler construyó el grafo recorriéndolo desde el entry: resolver, cargar, transformar, descubrir imports, recurrir. En tiempo de ejecución, el require recorre ese mismo grafo desde el entry: ejecutar la fábrica, pedir imports, recurrir. Son dos paseos por la misma estructura, uno para tejerla y otro para revivirla. El registro es el grafo cristalizado; el require es el crawl que lo despierta. Y un import() dinámico no rompe el modelo: se compila en un runtime que primero descarga el chunk asíncrono —otro archivo con su propio trozo de registro— y luego llama a require sobre el módulo que buscaba, extendiendo el paseo a una pieza que no estaba cargada al inicio.
Nada sustituye a abrir un bundle sin minificar y encontrar las piezas con tus propios ojos. Construye un proyecto minúsculo con dos o tres módulos, desactiva la minificación, y abre el archivo de salida. Busca el objeto que mapea ids a funciones —el registro—, la función que comprueba un caché y ejecuta fábricas —el require—, y la llamada final que arranca por el entry. Cuando reconozcas esas tres piezas en un bundle real, el output deja de ser una pared de código ilegible y se convierte en una estructura que sabes leer. Con scope hoisting activo verás menos runtime y más código lineal fundido; sin él, verás el patrón clásico en toda su claridad.
Si abres un bundle de producción de Rolldown o esbuild en 2026, puede que no reconozcas el registro ni el require de inmediato: el scope hoisting habrá fundido la mayoría de tus módulos en un solo ámbito, con las variables renombradas para no colisionar, y el resultado se lee como un único archivo lineal sin envoltorios. Eso no contradice el modelo de este nivel, lo cumple en su forma óptima: el runtime solo sobrevive donde el enlace estático no llega —imports dinámicos, interoperabilidad con CommonJS, ciclos—. Aprende primero el patrón clásico con registro y require; luego reconocerás el moderno como ese mismo patrón después de aspirarle todo el andamiaje posible.
Llegas al final del nivel con la imagen completa, y es más bonita de lo que parecía. Un bundle no es un archivo de código: es un grafo de módulos representado como datos ejecutables, más el mínimo runtime necesario para recorrerlo. Toda la anatomía se reduce a tres piezas que ahora sabes nombrar y reconocer. El registro, un mapa de id a función fábrica, es el grafo convertido en un catálogo de código latente donde definir no es ejecutar. El require interno es el motor que ejecuta esas fábricas bajo demanda y cachea sus exports, dando a cada módulo la identidad única que ESM promete y permitiendo, por el orden en que cachea, que los ciclos terminen en vez de colgarse. Y la ejecución arranca con una sola llamada al entry que, como una ficha de dominó, desencadena el recorrido de todo el grafo alcanzable. La revelación profunda es la simetría: el bundler construye el grafo recorriéndolo en compilación y el navegador lo revive recorriéndolo en ejecución, dos paseos por la misma estructura, uno que la teje y otro que la despierta. Interiorizar esto cierra el círculo de todo el nivel. Los entry points eran las raíces del paseo. El grafo era la estructura. Las fases lo construían nodo a nodo. El troceado lo repartía en archivos. El empaquetado justificaba por qué convertirlo en esto en lugar de servir los módulos crudos. Y aquí, en el bundle, ves el destino de todo ese trabajo: un artefacto autónomo que lleva consigo el grafo y las instrucciones para reconstruirlo en el navegador. Cuando el scope hoisting logra fundir los módulos y borrar el runtime, no niega este modelo: lo optimiza, colapsando muchos paseos en uno solo y lineal. Quien ha visto el runtime entiende tanto el bundle clásico como la ambición del moderno. Ya no lees un dist como una caja negra; lees un grafo que sabe cómo revivirse.
- Crea un proyecto con tres módulos donde el entry importe a los otros dos, construye sin minificar y abre el archivo de salida.
- Localiza el registro de módulos: el objeto cuyas claves son ids y cuyos valores son funciones fábrica con
module,exportsyrequire. - Encuentra la función
requireinterna y confirma que comprueba un caché antes de ejecutar y que registra el módulo antes de invocar su fábrica. - Sigue la llamada que arranca por el entry y traza en papel el orden en que se ejecutan las fábricas hasta el primer efecto visible.
- Introduce un import circular entre dos módulos y razona, leyendo el
require, por qué el caché previo a la ejecución evita la recursión infinita.