wandres.dev
POR QUÉ EXISTE EL BUILD · módulos y navegadores

Qué hace una build tool por dentro

Las cinco tareas que compone toda herramienta de build: transpilar cada archivo, resolver el grafo de módulos, empaquetarlo, optimizarlo y servirlo en desarrollo. Cinco piezas que explican cualquier bundler.

⏱ 13 min

Bajo la palabra “build” se esconde siempre la misma tubería, sin importar la herramienta. Una build tool no hace magia: compone cinco tareas bien delimitadas —transpilar, resolver, empaquetar, optimizar y servir— sobre un grafo de módulos. Cuando conoces esas cinco piezas dejas de ver una caja negra y empiezas a leer cualquier bundler, y cualquier error, como lo que es: un paso concreto de un proceso ordenado.

🎯 Al terminar esta lección sabrás
  • Nombrar las cinco tareas que compone toda herramienta de build.
  • Entender la resolución de módulos y por qué es más profunda de lo que parece.
  • Distinguir empaquetar de optimizar, y tree shaking de code splitting.
  • Ver por qué el dev server es una tarea distinta del build de producción.

Antes de entrar en detalle, este es el mapa de las cinco tareas. Todo lo que hace una build tool cae en una de ellas.

🔤

1. Transpilar

Convertir cada archivo en JavaScript que el motor entienda: borrar tipos, traducir JSX, rebajar sintaxis.

🧭

2. Resolver

Seguir cada import desde los puntos de entrada hasta dibujar el grafo completo de dependencias.

📦

3. Empaquetar

Fundir los cientos de módulos del grafo en unos pocos chunks que el navegador descarga de un tirón.

✂️

4. Optimizar

Podar código muerto, dividir en trozos bajo demanda, minificar y poner hash para cachear.

Transpilar y resolver

La primera tarea es transpilar: transformar cada archivo, uno a uno, en JavaScript que el motor entienda. Aquí se borra TypeScript, se convierte JSX en llamadas a funciones, se rebaja la sintaxis al target elegido y se inyectan los ayudantes que haga falta.

Es una operación local: no necesita saber nada del resto del proyecto, solo del archivo que tiene delante. Los motores que la ejecutan hoy —esbuild, SWC, Oxc— son justamente los que ganaron la carrera de la velocidad.

La segunda tarea es resolver el grafo. Partiendo de uno o varios puntos de entrada, la herramienta lee cada import, encuentra el archivo al que apunta y repite el proceso hasta dibujar el grafo completo de dependencias. Suena trivial y no lo es: la resolución de módulos es uno de los rincones más profundos del ecosistema.

🧭

Rutas y extensiones

Un ./util puede ser util.ts, util.tsx, util.js o util/index.js. La herramienta prueba un orden de extensiones para dar con el archivo real.

📦

Bare specifiers

Un import "lodash-es" obliga a recorrer los node_modules hacia arriba hasta encontrar el paquete, algo que el navegador no sabe hacer por su cuenta.

🚪

El campo exports

El package.json moderno declara con exports qué archivo sirve cada import, y con conditions varía la respuesta según el entorno.

🔗

Alias y symlinks

Alias de proyecto como @/componentes y los enlaces simbólicos de pnpm añaden capas que la resolución debe atravesar sin perderse.

Ese campo exports es hoy el corazón de la resolución moderna. Un mismo paquete puede entregar un archivo distinto según quién lo importe —ESM o CommonJS, navegador o servidor— gracias a las conditions:

{
  "name": "mi-libreria",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "browser": "./dist/index.browser.mjs"
    }
  }
}
📝
La resolución es donde más errores nacen

Un porcentaje sorprendente de los fallos de build no son de código, sino de resolución: una extensión que falta, un exports mal declarado que oculta un archivo, un dual package hazard en el que la versión ESM y la CommonJS del mismo paquete coexisten y se pisan. Cuando un import falla, la pregunta correcta casi nunca es “¿está mal mi código?”, sino “¿cómo está resolviendo la herramienta esta ruta?”.

Empaquetar y optimizar

Con el grafo en la mano, empaquetar consiste en fundir esos cientos de módulos en unos pocos archivos, o chunks. Históricamente el motivo era la red: cada archivo era una petición, y muchas peticiones pequeñas penalizaban la carga.

El bundler concatena, reescribe las referencias entre módulos y produce artefactos que el navegador descarga de un tirón. Pero empaquetar sin más produciría archivos enormes. Por eso la cuarta tarea, optimizar, es donde se gana el rendimiento real, y conviene no confundir sus técnicas:

  • Tree shaking: análisis estático del grafo ESM para eliminar los export que nadie usa. Es eliminación de código muerto, y solo funciona bien porque ESM es estático y analizable.
  • Code splitting: dividir la salida en trozos que se cargan bajo demanda. Un import() dinámico o una ruta perezosa se convierten en un chunk aparte que el navegador solo pide cuando hace falta.
  • Minificación: renombrar variables, borrar espacios y comentarios y colapsar código para reducir bytes sin cambiar el comportamiento.
  • Hashing de contenido: incrustar un hash en el nombre del archivo —app.9f3a1c.js— para que el navegador cachee de forma agresiva e invalide solo cuando el contenido cambia.

El code splitting nace de una sola pista que tú das en el código: el import() dinámico. Donde lo escribes, el bundler parte el grafo.

// Esta linea le dice al bundler donde partir el grafo en un chunk aparte
const editor = await import("./editor-pesado.js");
// El codigo del editor no viaja en el bundle inicial: se pide al usarse

Tree shaking y code splitting tiran en la misma dirección —enviar lo mínimo— pero por caminos opuestos: uno quita lo que nunca se usa, el otro aplaza lo que no se usa todavía. Juntos convierten un grafo monolítico en una entrega quirúrgica.

A esto se suma el manejo de assets: imágenes, CSS y fuentes también entran al grafo, se les pone hash, y los más pequeños se incrustan en línea para ahorrar peticiones. El resultado es un puñado de archivos con nombres versionados, listos para un CDN.

Servir en desarrollo

La quinta tarea vive en otro tiempo y con otras prioridades. En producción quieres el artefacto más pequeño y rápido, y no te importa esperar unos segundos a que se genere. En desarrollo quieres exactamente lo contrario: arranque instantáneo y ver el cambio en cuanto guardas, aunque el resultado no esté optimizado.

Por eso el dev server moderno no empaqueta: sirve ESM nativo y transforma cada módulo solo cuando el navegador lo pide. La misma tubería, pero recorrida bajo demanda en lugar de entera y por adelantado.

flowchart LR
A[Puntos de entrada] --> B[Resolver grafo]
B --> C[Transpilar cada modulo]
C --> D{Modo}
D -->|produccion| E[Empaquetar]
E --> F[Optimizar]
F --> G[Artefactos con hash]
D -->|desarrollo| H[Servir ESM bajo demanda]
H --> I[HMR y source maps]
style A fill:#89b4fa,color:#11111b
style G fill:#a6e3a1,color:#11111b
style I fill:#f9e2af,color:#11111b

Ese modo servido se apoya en dos piezas más. El HMR (Hot Module Replacement) sustituye un módulo en caliente sin recargar la página, preservando el estado de la interfaz; el módulo declara cómo aceptar la actualización.

// El modulo se auto-parchea sin recargar la pagina entera
if (import.meta.hot) {
  import.meta.hot.accept((nuevo) => aplicar(nuevo));
}

Y los source maps mantienen un mapa desde el código transformado y minificado de vuelta a tu fuente original, para que cuando depures veas tu TypeScript y no un amasijo de una sola línea. Sin source maps, todo lo que gana la optimización lo perderías en cuanto tuvieras que buscar un bug.

Una sola tubería sobre un grafo

La palabra “build” intimida porque suena a caja negra, pero no hay ninguna caja: hay un grafo y cinco pasos que operan sobre él. Se parte de unos puntos de entrada, se resuelve el grafo siguiendo cada import, se transpila cada nodo a JavaScript ejecutable, se funden los nodos en chunks y se optimizan esos chunks; en desarrollo, ese mismo grafo se sirve pieza a pieza en lugar de fundirse. Interiorizar esta anatomía tiene un efecto liberador y muy práctico: cada error que verás en tu carrera vive en uno de estos pasos, y saber en cuál es media solución. Un “cannot resolve module” es la fase de resolución que no encontró un archivo. Un “unexpected token” es la transpilación que no supo leer una sintaxis. Un bundle sospechosamente enorme es tree shaking que no pudo actuar, casi siempre porque algo rompió el análisis estático. Una página que se recarga entera en vez de parchearse es HMR que no pudo aislar el cambio. Cuando dejas de ver “el build falló” y empiezas a ver “el paso de resolución falló”, cualquier herramienta —webpack, Vite, Rolldown o la que venga— se vuelve legible, porque todas componen estas mismas cinco piezas en distinto orden y con distinta velocidad.

⚔️ Desmonta la tubería
  1. Crea un proyecto mínimo con Vite y ejecuta primero vite y luego vite build: observa cuál sirve sin empaquetar y cuál genera artefactos.
  2. Añade un import() dinámico a un módulo pesado y comprueba en la salida del build que aparece un chunk separado.
  3. Deja un export sin usar y verifica, buscándolo en el bundle final, que el tree shaking lo eliminó.
  4. Abre las DevTools sobre el build de producción y confirma que los source maps te devuelven a tu código fuente original al poner un breakpoint.