wandres.dev
QUÉ HACE UN BUNDLER · el grafo de módulos

Los entry points y el grafo de módulos

Un bundler no recibe una carpeta de archivos: recibe una raíz. Cómo parte de uno o varios entry points, sigue cada import como un hilo hasta descubrir todo el código alcanzable, y teje con ello un grafo dirigido de módulos. Por qué ese grafo, y no tu carpeta src, es la unidad de verdad del empaquetado, cómo se deduplican las dependencias compartidas, y qué diferencia a un import estático de uno dinámico en la forma del grafo.

⏱ 15 min

Contra toda intuición, un bundler no toma una carpeta de archivos y los pega. Toma un único punto —el entry point— y desde ahí deduce todo lo demás. Siguiendo cada import como quien tira de un hilo, descubre el universo completo de código alcanzable y lo teje en un grafo dirigido de módulos. Ese grafo, no tu carpeta src, es la verdadera unidad de trabajo: todo lo que el bundler hará después —resolver, transformar, optimizar, trocear, emitir— son operaciones sobre él. Entender cómo nace ese grafo a partir de una raíz es entender el primer acto de cualquier empaquetado.

🎯 Al terminar esta lección sabrás
  • Entender qué es un entry point y por qué un bundler necesita una raíz, no una lista de archivos.
  • Ver el grafo de módulos como un grafo dirigido cuyas aristas son los imports.
  • Comprender el descubrimiento recursivo que teje el grafo a partir de la raíz.
  • Distinguir imports estáticos de dinámicos y su efecto sobre la forma del grafo.

El entry point: una raíz, no una carpeta

La primera sorpresa al mirar un bundler por dentro es que no le pasas archivos: le pasas una raíz. En Rollup esa raíz se llama input; en Vite, para una app, es directamente el index.html. A partir de ese punto, y solo de ese punto, el bundler averigua qué código importa tu programa. La consecuencia es tajante: todo lo alcanzable desde el entry acaba en la salida, y todo lo inalcanzable es, por definición, código muerto que jamás se emite. Un archivo perfecto en tu disco que nadie importa no existe para el bundler.

// rollup.config.ts — un solo entry
export default {
  input: "src/main.ts",
  output: { dir: "dist", format: "es" },
};

// varios entry points: una app multipágina o una librería con varias puertas
export default {
  input: {
    home: "src/home.ts",
    admin: "src/admin.ts",
  },
  output: { dir: "dist", format: "es" },
};

Esto invierte la intuición ingenua de que el proyecto es la carpeta. La unidad real del build no es el sistema de archivos, sino la clausura transitiva de los imports desde la raíz: el conjunto de todo lo que se alcanza siguiendo aristas. Por eso puedes tener cientos de archivos en src y que el bundle contenga solo una fracción, y por eso borrar un import puede desalojar del bundle un subárbol entero de código que de golpe se vuelve inalcanzable.

Los entry points múltiples no son un capricho. Una app multipágina declara un entry por página HTML; una librería declara un entry por cada puerta pública que ofrece a sus consumidores. En Vite, el matiz es elegante: el entry no es un .js, es el .html. Vite lo parsea, encuentra las etiquetas script y las toma como raíces, de modo que el HTML actúa de manifiesto de qué se ejecuta.

Los entry points, según el proyecto, adoptan formas distintas, pero todos cumplen el mismo papel de raíz:

  • Una SPA: un único entry, la raíz de toda la aplicación.
  • Una app multipágina: un entry por página, y a menudo un .html por cada una en Vite.
  • Una librería: un entry por cada puerta pública que ofreces a tus consumidores.
  • Un worker o un script aparte: su propio entry, con su propio grafo independiente.

El grafo de módulos

Cada raíz es el origen de un grafo dirigido. Los nodos son módulos: unidades ya resueltas, cada una con su id canónico, su código fuente, sus exports y su lista de imports. Las aristas son los imports: si app.ts importa de formato.ts, existe una arista dirigida de app a formato. Un módulo no es un archivo cualquiera del disco, sino un archivo que alguien alcanzó importándolo.

La propiedad que hace útil al grafo es la deduplicación. Si veinte módulos importan la misma utilidad, esa utilidad es un solo nodo con veinte aristas entrantes, no veinte copias. El bundler la memoiza por su id, de modo que las dependencias en diamante —dos ramas que convergen en un mismo módulo— colapsan en un único nodo compartido. Sin esta regla, empaquetar duplicaría código sin freno.

flowchart TD
entry[main.js ENTRY] --> app[app.js]
entry --> theme[theme.css]
app --> button[Button.jsx]
app --> format[format.js]
button --> format
app -.->|import dinamico| chart[Chart.jsx]
style entry fill:#a6e3a1,color:#11111b
style format fill:#89b4fa,color:#11111b
style chart fill:#f9e2af,color:#11111b

Fíjate en format.js: lo importan app y button, pero es un solo nodo. Y fíjate en que el grafo puede tener ciclos: ESM admite imports circulares mediante enlaces vivos, así que el grafo de módulos es un grafo dirigido que puede contener ciclos, a diferencia del grafo acíclico de tareas de un monorepo. El bundler debe tolerarlos sin colgarse, cerrando la recursión cuando reencuentra un nodo ya en curso.

ℹ️
Módulo no es lo mismo que archivo

Un mismo archivo del disco puede dar lugar a más de un módulo si se resuelve con condiciones distintas, y un módulo puede existir sin ningún archivo detrás —los módulos virtuales que generan los plugins. La identidad de un nodo no es su ruta, sino el id que le asigna la fase de resolución. Pensar en archivos te confunde en cuanto aparecen los casos interesantes; pensar en nodos con un id te mantiene en el terreno correcto.

Como estructura de datos, el grafo de módulos tiene cuatro rasgos que conviene fijar desde ya:

  • Es dirigido: las aristas tienen sentido, de quien importa hacia lo importado.
  • Puede tener ciclos: ESM los admite con enlaces vivos, así que no es un DAG.
  • Está deduplicado: cada módulo es un solo nodo, por muchas aristas que le entren.
  • Es la clausura transitiva de la raíz: contiene todo lo alcanzable y nada más.

El descubrimiento recursivo

El grafo no se declara en ningún sitio: emerge. El bundler arranca en cada entry y repite un ciclo. Resuelve la raíz a un id, carga su fuente, la transforma, la parsea a un AST, lee sus declaraciones import y export, resuelve cada dependencia descubierta y recurre sobre ella. Memoiza por id, de modo que un módulo ya visitado no se vuelve a recorrer, solo se añade una arista. Cuando no quedan imports sin visitar, el grafo está completo.

// El crawl, en pseudocódigo: la esencia de descubrir el grafo
function crawl(id, grafo) {
  if (grafo.has(id)) return;          // memoizacion: cada nodo una vez
  const fuente = cargar(id);
  const codigo = transformar(fuente);
  const ast = parsear(codigo);
  const modulo = { id, imports: [] };
  grafo.set(id, modulo);
  for (const spec of importsDe(ast)) {  // analisis estatico del AST
    const idDep = resolver(spec, id);
    modulo.imports.push(idDep);
    crawl(idDep, grafo);               // recursion sobre cada dependencia
  }
}

La clave silenciosa está en importsDe(ast): funciona porque las declaraciones ESM son estáticas. Un import vive en el nivel superior del módulo, no puede esconderse dentro de un if, y su especificador es una cadena literal. Eso permite al bundler leer el grafo entero sin ejecutar una sola línea de tu código, solo analizando la sintaxis. Es la propiedad de la que cuelga todo lo demás: no puedes trocear ni podar lo que no puedes ver de forma estática, y ESM te deja verlo.

📝
Por qué CommonJS complica el grafo

El análisis estático funciona porque un import de ESM es una declaración fija: su especificador es una cadena literal en el nivel superior del módulo. CommonJS no da esa garantía, porque un require(variable) puede resolverse a cualquier cosa en ejecución y puede esconderse dentro de un if o de una función. Por eso los bundlers hacen esfuerzos considerables para analizar CommonJS, y por eso el mundo se movió a ESM: un grafo que se puede leer sin ejecutar es un grafo sobre el que se puede optimizar. La estática no es un capricho de diseño, es la condición del análisis.

Imports estáticos frente a dinámicos

No todas las aristas pesan igual. Un import estático es una arista ansiosa: su destino pertenece al mismo conjunto alcanzable y se incorpora al grafo de inmediato, normalmente para acabar en el mismo chunk que quien lo importa. Un import() dinámico es una arista perezosa: devuelve una promesa y marca un punto de división. El bundler igualmente descubre el destino —sigue siendo alcanzable— pero lo trata como la raíz de un chunk aparte que se cargará bajo demanda.

🔗

Import estático

import x from './a'. Arista ansiosa, analizable en sintaxis, hoisted al nivel superior. Ata el destino al conjunto alcanzable de forma inmediata. Es lo que habilita el tree shaking y la construcción temprana del grafo.

🧵

Import dinámico

import('./a'). Arista perezosa que devuelve una promesa. Marca un límite de chunk: el destino se descubre pero se emite por separado y se carga cuando el código lo pide. Es la semilla del code splitting.

⚠️
Un import sin binding también es una arista

No hace falta usar lo que importas para crear una arista. Un import "./analytics" sin nombre —un import solo por sus efectos secundarios— añade igualmente un nodo al grafo y lo mantiene alcanzable, porque el bundler no puede saber si ejecutarlo importa. Por eso un módulo que solo registra un manejador global o parchea un prototipo no se elimina aunque no exportes nada de él: su arista existe, luego vive. Distinguir un import por su valor de un import por su efecto es clave para entender, en el nivel 21, por qué el tree shaking conserva ciertas cosas que parecían muertas.

Esta distinción, que ahora parece un detalle, gobierna el nivel 22 entero. Los puntos de import() dinámico son las tijeras con las que el bundler recorta el grafo en piezas cargables por separado, y colocarlos bien es una de las decisiones de rendimiento más rentables que tomarás. Por ahora basta con quedarte con la forma: un grafo con la mayoría de aristas ansiosas y unas pocas aristas perezosas que anuncian dónde el bundle podrá partirse.

El grafo es el bundler; todo lo demás son operaciones sobre él

Si te llevas una sola idea de este nivel, que sea esta: un bundler no es un programa que junta archivos, es un programa que construye un grafo y luego lo transforma. Cada capacidad que asociarás a los bundlers en los próximos niveles es, mirada de cerca, una operación sobre este grafo dirigido de módulos. El tree shaking es marcar los nodos y exports vivos y descartar los muertos. El code splitting es particionar el grafo en subconjuntos que se cargan por separado. El HMR es propagar una invalidación a lo largo de las aristas hasta un límite que sabe reaccionar. La resolución decide qué id tiene cada nodo; la transformación reescribe su contenido; la generación del output recorre el grafo y lo linealiza en chunks. Interiorizar esto cambia cómo lees los errores y las configuraciones: dejas de ver una caja negra que escupe un dist y empiezas a ver una estructura de datos que puedes razonar. Cuando un módulo aparece inesperadamente en tu bundle, preguntas quién lo alcanza —qué cadena de aristas lo hace vivo— en vez de encogerte de hombros. Cuando quieres partir el bundle, buscas dónde insertar una arista perezosa. Cuando algo no se elimina pese a no usarse, sospechas de una arista que lo mantiene alcanzable. El grafo es el objeto; la raíz es su origen; los imports son sus aristas. Quien piensa en grafos entiende bundlers. Quien piensa en archivos, no.

⚔️ Reconstruye un grafo a mano
  1. Toma un proyecto pequeño y, partiendo de su entry, dibuja en papel el grafo de módulos siguiendo cada import. Marca las hojas (sin imports) y localiza cualquier módulo compartido por dos ramas.
  2. Añade un archivo nuevo a src que nadie importe y confirma con el build que no aparece en el dist: es inalcanzable, luego inexistente para el bundler.
  3. Convierte un import estático en un import() dinámico y observa cómo la salida gana un chunk separado para ese subárbol.
  4. Introduce a propósito un import circular entre dos módulos y comprueba que el bundler lo tolera en vez de colgarse; razona por qué la memoización por id lo permite.
  5. Declara dos entry points en la config y verifica que el módulo que ambos comparten no se duplica, sino que se emite una vez.