Las fases: resolución, carga y transformación
Antes de ser un nodo del grafo, cada módulo recorre la misma cadena de montaje interna: adquiere una identidad con la resolución, un cuerpo con la carga, y una forma final con la transformación, y solo entonces se parsea para que sus propios imports realimenten el descubrimiento. El pipeline por módulo que late en el corazón de cualquier bundler, y por qué son las mismas tres fases en las que se enganchan los plugins que ya conociste.
El grafo del nivel anterior no aparece por arte de magia: cada nodo se fabrica pasando por una cadena de montaje idéntica. Antes de que un módulo pueda ser un nodo necesita tres cosas en orden: una identidad —¿quién eres?—, un cuerpo —¿qué contienes?— y una forma final —¿en qué te conviertes?—. Esas son las fases de resolución, carga y transformación. Solo después se parsea el resultado, se leen sus imports y el bucle vuelve a empezar por cada uno. Este pipeline por módulo es el latido interno del bundler, y son exactamente las mismas fases en las que se enganchan los hooks de plugin que viste en el nivel 14, ahora contadas desde el lado del motor.
- Entender la resolución como el paso que traduce un especificador a un id canónico.
- Entender la carga como el paso que obtiene el código fuente de un id, venga o no del disco.
- Entender la transformación como la reescritura del módulo antes de parsearlo.
- Ver las tres fases como un bucle por módulo que alimenta el descubrimiento del grafo.
Resolución: del especificador al id
Cuando el bundler encuentra import './boton', esa cadena './boton' es un especificador, no un id. Le falta la extensión, no es una ruta absoluta y ni siquiera tiene por qué corresponder a un archivo en el disco. La fase de resolución es quien convierte ese especificador en el id canónico del módulo: la etiqueta única e inequívoca que identificará a ese nodo en el grafo. Dos especificadores que resuelven al mismo id son el mismo nodo; ese es el mecanismo exacto de la deduplicación.
La resolución no es trivial. Debe distinguir un especificador relativo (./boton, que se ancla en quien importa) de uno bare (react, que se busca en node_modules), aplicar el algoritmo de Node, honrar el campo exports del paquete y sus conditions, expandir los alias de tu config y decidir si un módulo es externo —esto es, si queda fuera del bundle porque el entorno ya lo proveerá—. Todo eso lo estudiaste en el nivel 5; aquí lo que importa es su papel en el pipeline: la resolución es la fase que asigna identidad, y sin identidad no hay nodo.
// La resolucion decide la identidad de un nodo
resolver("./boton", "/src/app.ts") // -> "/src/boton.tsx" (relativo)
resolver("react", "/src/app.ts") // -> "/node_modules/react/index.js" (bare)
resolver("virtual:datos", importer) // -> "\0virtual:datos" (sin archivo)
La resolución tiene que arbitrar varios mundos a la vez, y de ahí su fama de fase más enrevesada del pipeline:
- Especificadores relativos como
./boton, anclados en la ruta de quien importa. - Especificadores bare como
react, buscados ennode_modulescon el algoritmo de Node. - El campo
exportsdel paquete y sus conditions, que mandan un mismo nombre a archivos distintos según el entorno. - Los alias de tu config, que reescriben un prefijo a una ruta concreta.
- Los módulos externos, que reciben un id pero quedan fuera del bundle porque los proveerá el entorno.
Por convención, cuando la resolución devuelve un id que no corresponde a ningún archivo del disco —un módulo virtual generado al vuelo— lo prefija con un byte nulo. Ese marcador le dice al resto del pipeline que no intente leer un archivo inexistente y que trate el id como opaco. Es un detalle pequeño con una función grande: separa limpiamente el mundo de los archivos reales del mundo de los módulos inventados, que conviven en el mismo grafo sin pisarse.
Carga: del id al código fuente
Con un id ya resuelto, el bundler necesita su contenido. La fase de carga recibe el id y devuelve el código fuente, como una cadena o como un objeto { code, map } que incluye un sourcemap. El comportamiento por defecto es el obvio: leer el archivo del disco por su ruta. Pero es interceptable, y ahí está la riqueza.
Si algo intercepta la carga y devuelve el código antes de tocar el disco, ese id nunca necesita existir como archivo. Ese es el mecanismo de los módulos virtuales: contenido generado en memoria —una tabla de rutas, un JSON de configuración, un shim— que entra en el grafo como cualquier otro nodo. La carga es también la puerta por la que entran los recursos que no son JavaScript: un .css o un .json se cargan aquí como texto, para que la siguiente fase los convierta en algo que el grafo entienda.
// La carga entrega un cuerpo; no siempre desde el disco
cargar("/src/boton.tsx") // lee el archivo real
cargar("\0virtual:datos") // devuelve codigo generado en memoria
cargar("/src/logo.svg") // entrega el SVG como texto, listo para transformar
Un mismo grafo, por tanto, mezcla nodos de procedencias muy distintas sin que el resto del pipeline note la diferencia:
- Del disco: el caso por defecto, leer el archivo por su ruta resuelta.
- Generado en memoria: un módulo virtual cuyo contenido produce un plugin al vuelo, sin archivo detrás.
- Ni siquiera JavaScript: un
.css, un.jsono un.svgentregados como texto para que la fase siguiente los convierta.
Transformación: reescribir antes de parsear
El código recién cargado casi nunca está listo para el grafo. La fase de transformación lo reescribe: baja TypeScript a JavaScript, convierte JSX en llamadas de función, inyecta variables de entorno, envuelve un .css en un módulo que lo registra, convierte un SVG en un export. Es donde ocurre la compilación a nivel de lenguaje —el down-leveling que adapta sintaxis moderna al target—, y es una cadena: varias transformaciones pueden aplicarse en secuencia, y la salida de una es la entrada de la siguiente.
Dos exigencias definen esta fase. La primera es que su salida debe ser JavaScript válido con imports y exports analizables, porque el paso inmediato es parsearla; un módulo que tras transformarse no se puede parsear rompe el pipeline. La segunda son los sourcemaps: si una transformación mueve posiciones y no propaga su mapa, el navegador te mostrará código que no escribiste al depurar. Encadenar transformaciones obliga a componer sourcemaps con cuidado, un tema que ya viste con detalle desde el lado del plugin.
En una app real, la transformación no para de trabajar aunque tú no lo pidas de forma explícita:
- Baja TypeScript a JavaScript borrando las anotaciones de tipo.
- Convierte JSX en llamadas de función que el navegador sí entiende.
- Sustituye
import.meta.envy las claves dedefinepor valores literales. - Rebaja construcciones de sintaxis moderna al target de navegadores que soportas.
- Envuelve assets como CSS o SVG en un módulo con un export utilizable.
Es tentador creer que un archivo .js moderno pasa la transformación intacto, pero rara vez es así. Aunque no haya TypeScript ni JSX, el bundler puede tener que rebajar sintaxis reciente al target configurado, reemplazar import.meta.env por valores literales, o reescribir imports de dependencias pre-empaquetadas. La fase de transformación siempre corre; lo que varía es cuánto trabajo hace. Asumir que tu código llega al grafo tal cual lo escribiste es la fuente de más de una sorpresa al depurar el output.
Si el nombre de las tres fases te suena, es porque las viste desde el otro lado en el nivel 14. Los hooks resolveId, load y transform de Rollup no son más que los puntos donde un plugin se engancha a estas mismas fases del motor: resolveId interviene en la resolución, load en la carga, transform en la transformación. Lo que aquí describimos como la anatomía interna del bundler es, visto por fuera, la superficie de extensión que usan los plugins. Motor y ecosistema son la misma máquina contada en dos direcciones.
El pipeline como bucle
Ata las tres fases y reaparece el crawl del nivel anterior, ahora con sus engranajes a la vista. El bundler arranca en un entry, lo resuelve a un id, carga su fuente, la transforma, la parsea a un AST, descubre sus imports y, por cada uno, vuelve a empezar: resolver, cargar, transformar, parsear, recurrir. El grafo no se declara; es el residuo de repetir este bucle hasta que no quedan imports sin visitar. Cada id se procesa una sola vez, y los módulos independientes se procesan en paralelo para exprimir los núcleos disponibles.
flowchart LR spec[especificador] --> res[Resolucion da el id canonico] res --> load[Carga da el codigo fuente] load --> trans[Transformacion reescribe] trans --> parse[Parseo produce el AST] parse --> disc[Descubre los imports] disc -->|por cada import| spec parse --> node[Nodo del grafo] style res fill:#a6e3a1,color:#11111b style load fill:#89b4fa,color:#11111b style trans fill:#cba6f7,color:#11111b
Resolución
De especificador a id canónico. Asigna identidad: dos especificadores con el mismo id son el mismo nodo, y de ahí nace la deduplicación.
Carga
De id a código fuente. Del disco por defecto, o generado en memoria para los módulos virtuales que no tienen archivo detrás.
Transformación
Reescribe el fuente en cadena: TypeScript, JSX, sintaxis y assets, arrastrando el sourcemap de etapa en etapa.
Dos propiedades operativas del bucle explican buena parte del rendimiento de un build. La primera es la memoización por id: cada módulo pasa por sus fases una sola vez, por muchas aristas que apunten a él, así que una utilidad compartida por cincuenta módulos se resuelve, carga y transforma una vez. La segunda es el paralelismo: los módulos que no dependen entre sí recorren el pipeline a la vez, repartidos entre los núcleos disponibles, porque nada obliga a procesar en serie ramas independientes del grafo.
Un motor real no ejecuta resolver, cargar y transformar en pasos estrictamente separados para todo el grafo: intercala, cachea resultados parciales y paraleliza con agresividad. Pero el orden lógico por módulo es inviolable, y por pura dependencia de datos: no puedes cargar lo que no has identificado, ni transformar lo que no has cargado, ni parsear lo que no has transformado. La libertad del scheduler para reordenar el trabajo termina donde empieza esta cadena causal.
En 2026 este bucle tiene una propiedad que antes no existía. Con Rolldown y Oxc, la resolución, la transformación, el parseo y el bundling comparten un único AST en Rust: el módulo se parsea una vez y esa representación se reutiliza en cada fase, en lugar de reparsearse con reglas ligeramente distintas en cada herramienta. De ahí sale a la vez velocidad —parsear una vez es más barato que parsear cuatro— y coherencia. Y como el núcleo es nativo, cada hook de plugin en JavaScript cruza la frontera entre Rust y JavaScript; por eso los filtros declarativos de hook que viste en el nivel 14 importan tanto: evitan ese cruce para la inmensa mayoría de módulos que no te incumben.
Detrás de la aparente complejidad de un build moderno hay una estructura de una simplicidad casi provocadora: por cada módulo, el bundler hace tres preguntas y un análisis. Quién eres —la resolución, que te da una identidad. Qué contienes —la carga, que te da un cuerpo. En qué te conviertes —la transformación, que te da tu forma final. Y luego te parsea para descubrir a quién importas, y repite. Eso es todo. El grafo entero, con sus miles de nodos, no es más que el eco de este bucle aplicado una y otra vez sobre cada import que aparece. Interiorizar esto tiene un efecto liberador sobre cómo lees el ecosistema. El plugin que carga imágenes intercepta la fase de carga. El que compila un lenguaje de plantillas intercepta la de transformación. El que soporta un protocolo de import inventado ayer intercepta la de resolución. No hay magia distinta en cada uno: son variaciones del mismo patrón mínimo, enganchándose a una de las tres fases del mismo bucle. Y como Rollup, Rolldown, esbuild, Vite y sus primos comparten esta anatomía, entenderla una vez te sirve para todos. Cuando en 2026 el motor se reescribe en Rust y comparte un solo AST, no cambia el bucle: cambia lo rápido y lo coherente que corre. Aprendiste el bucle, no la herramienta; el andamio cambia, el cimiento permanece. Ese es el salto: dejar de configurar bundlers por superstición y empezar a razonar sobre lo que le pasa a cada módulo, fase a fase, hasta convertirse en un nodo.
- Toma un import bare como
reacty traza a mano su resolución: de especificador a id dentro denode_modules, honrando el campoexports. - Escribe un módulo virtual mínimo (un id con prefijo nulo cuya carga devuelva código generado) y confirma que entra en el grafo sin existir como archivo.
- Importa un
.svgo un.jsony razona en qué fase deja de ser un archivo de texto y se convierte en un módulo con un export utilizable. - Rompe a propósito un sourcemap en una transformación y compruébalo en el depurador del navegador: verás código que no escribiste.
- Explica en dos frases por qué compartir un solo AST vía Oxc hace el pipeline a la vez más rápido y más coherente que tener un parser distinto por fase.