learning-front

Nivel 9 · El ciclo completo: del commit al deploy

Bundling a fondo

Qué hace un bundler por dentro: el grafo de módulos, transpilación y minificación, tree-shaking, code-splitting en chunks y los source maps. Y el zoo de bundlers —Vite/Rolldown, webpack, esbuild, rspack, Turbopack— comparados: cuándo elegir cada uno.

Llevas usando Vite desde el Nivel 4. Escribes npm run dev y el servidor arranca en milisegundos. Escribes npm run build y aparece una carpeta dist/ con ficheros que tienen nombres raros como index-B3kXp2mN.js. Lo que nunca has visto es lo que ocurre entre el momento en que pulsas Enter y el momento en que esos ficheros aparecen.

Este capítulo abre la caja. No para que configures Vite desde cero (ya lo haces), sino para que entiendas qué decisiones toma el bundler por ti y cuándo necesitas cambiar alguna. Cuando sepas qué es un grafo de módulos, por qué tree-shaking elimina código que no usas, o cómo un import() dinámico parte el bundle en dos, podrás leer el output de npm run build con criterio y optimizarlo cuando la app crezca.

El problema que resuelven los bundlers es concreto. El navegador entiende HTML, CSS y JavaScript. Pero tú escribes TypeScript, JSX y docenas de ficheros que se importan entre sí con rutas relativas. El navegador no entiende TypeScript ni JSX. Y aunque entiende módulos ES (el import/export que llevas usando desde el Nivel 3), cargar cien ficheros separados en producción es lento: cada fichero es una petición de red, y las peticiones en paralelo tienen un límite. El bundler traduce, combina, elimina lo que sobra y empaqueta en el mínimo número de ficheros posible.

El grafo de módulos#

El bundler no lee tus ficheros en orden alfabético. Empieza por el punto de entrada (normalmente main.tsx o index.tsx) y sigue cada import que encuentra, construyendo un grafo donde cada módulo es un nodo y cada relación de importación es una arista.

texto
main.tsx
   └── App.tsx
         ├── HeroCard.tsx
         │     └── datos/heroes.ts
         └── HeroFilter.tsx
               └── datos/heroes.ts

En este grafo, datos/heroes.ts aparece dos veces como destino: tanto HeroCard.tsx como HeroFilter.tsx lo importan. El bundler lo detecta y lo incluye una sola vez en el bundle final, aunque dos módulos lo necesiten. Sin ese grafo, podría incluir el mismo fichero dos veces y enviar código duplicado al navegador.

El grafo también es la base del tree-shaking: si un módulo existe en el proyecto pero nadie lo importa, no forma parte del grafo y no entra en el bundle. Si un módulo tiene cinco exports pero solo se importa uno, el bundler solo incluye ese uno.

Transpilación: traducir lo que el navegador no entiende#

El navegador no entiende TypeScript ni JSX. Antes de empaquetar, el bundler tiene que transpilar: convertir el código que escribiste en JavaScript que el navegador ejecuta.

La transpilación de JSX es sencilla de ver. Este componente:

tsx
// el componente que escribe el desarrollador
function HeroCard({ nombre }: { nombre: string }) {
  // el JSX se parece a HTML pero es una sintaxis especial de JavaScript
  return <li className="hero-card">{nombre}</li>;
}

Se convierte en una llamada a función que el navegador entiende:

javascript
// el resultado tras transpilar: sin JSX, solo JavaScript
function HeroCard({ nombre }) {
  return React.createElement("li", { className: "hero-card" }, nombre);
}

Ese React.createElement es el modelo mental clásico. Con el runtime JSX automático —el que usan hoy Vite y @vitejs/plugin-react por defecto— el JSX se transpila a _jsx(...) de react/jsx-runtime y ya no hace falta importar React en cada fichero. El resultado es el mismo: una llamada a función que el navegador entiende.

La transpilación de TypeScript elimina los tipos. El navegador no puede ejecutar anotaciones de tipo porque no son parte de JavaScript: el transpilador las borra y deja solo el código de lógica. El comportamiento del programa no cambia; solo desaparece la capa de tipos que TypeScript usaba para comprobar en tiempo de build.

La transpilación es distinta de minificar. Transpilar cambia el lenguaje o la sintaxis. Minificar toma JavaScript válido y lo hace más pequeño sin cambiar lo que hace. Son dos pasos del build que ocurren uno después del otro.

Tree-shaking: tirar el código muerto#

Si tienes un módulo de utilidades con tres funciones pero solo usas una en tu aplicación, ¿deberían entrar las tres en el bundle? Sin tree-shaking, sí. Con tree-shaking, no.

typescript
// utils.ts — tres funciones exportadas
export function calcularRatio(victorias: number, partidas: number) {
  return victorias / partidas;
}
export function formatearRol(rol: string) {
  return rol.toUpperCase();
}
export function contarTanques(heroes: { rol: string }[]) {
  return heroes.filter((h) => h.rol === "tanque").length;
}
typescript
// App.tsx — solo importa una
import { calcularRatio } from "./utils";

El bundler analiza el grafo y ve que formatearRol y contarTanques no se importan en ningún fichero. Las marca como código muerto y no las incluye en el bundle. El usuario no descarga ni parsea esas funciones.

El tree-shaking funciona porque los módulos ES son estáticos: el bundler sabe en tiempo de build qué se exporta y qué se importa, sin ejecutar el código. Si usaras el formato CommonJS (require/module.exports), el bundler no podría hacer ese análisis porque los require pueden ser dinámicos (dentro de un if, con una variable como ruta) y el bundler no puede saber en build qué se va a importar en runtime.

El “¿y qué?” de no tener tree-shaking: código muerto que viaja por la red, ocupa espacio en caché y tarda en parsear. En una librería grande donde solo necesitas dos funciones, sin tree-shaking pagas el coste de todo el módulo.

Minificación: el mismo código, más pequeño#

Tras transpilar y eliminar código muerto, el bundler minifica: toma el JavaScript resultante y lo comprime al mínimo sin cambiar lo que hace.

El proceso elimina lo que el intérprete no necesita:

  • Espacios en blanco, saltos de línea y comentarios
  • Nombres de variables locales, que se acortan a letras de una sola letra
javascript
// antes de minificar — legible para el desarrollador
function calcularRatio(victorias, partidas) {
  // victorias dividido entre partidas, redondeado a dos decimales
  return Math.round((victorias / partidas) * 100) / 100;
}
javascript
// después de minificar — idéntico en comportamiento, más pequeño en bytes
function c(a,b){return Math.round(a/b*100)/100}

La función hace exactamente lo mismo. Los nombres a y b son válidos JavaScript. El comentario no existía para el intérprete. El resultado ocupa una fracción del espacio.

La minificación es distinta de transpilar. Minificar no cambia el lenguaje: el resultado sigue siendo JavaScript. Transpilar cambia la sintaxis o el lenguaje. En el pipeline de Vite 8, Rolldown transpila TypeScript y JSX a JavaScript y luego minifica el resultado en un solo paso unificado. Antes de Vite 8, esas dos tareas las hacían herramientas distintas (esbuild para la transpilación en desarrollo, Rollup para el bundle de producción), lo que explica por qué verás esa separación en tutoriales y configuraciones anteriores.

Code-splitting: partir el bundle en chunks#

Un bundle único que contiene toda la aplicación tiene un problema: el usuario que solo visita la página de inicio descarga el código de todas las demás páginas. Si el Team Builder tiene una vista de lista, una vista de detalle de héroe, y una vista de estadísticas, el usuario que solo ve la lista paga el coste de descargar las otras dos.

El code-splitting parte el bundle en chunks: ficheros JS independientes que el navegador descarga bajo demanda. Vite genera un chunk separado cada vez que encuentra un import() dinámico, que ya viste en el Nivel 3.

En React, el patrón habitual es React.lazy, que ya viste en el Nivel 8:

tsx
// en lugar de importar HeroDetail de forma estática al cargar la app,
// lo declaramos como componente perezoso
import { lazy, Suspense } from "react";

// React.lazy recibe una función que devuelve un import() dinámico.
// Vite detecta ese import() en build y genera un chunk separado para HeroDetail.
const HeroDetail = lazy(() => import("./HeroDetail"));

export default function App() {
  return (
    // Suspense muestra el fallback mientras el chunk viaja por la red.
    // Cuando llega, React sustituye el fallback por el componente real.
    <Suspense fallback={<p>Cargando detalle del héroe...</p>}>
      <HeroDetail />
    </Suspense>
  );
}

Tras ejecutar npm run build con esta configuración, dist/assets/ contiene al menos dos ficheros JS: el chunk inicial (lo que carga siempre) y el chunk de HeroDetail (lo que se descarga solo cuando el usuario llega a esa vista). El panel Network de DevTools muestra ese segundo chunk apareciendo como petición de red en el momento exacto en que el usuario lo necesita.

El “¿y qué?” de no usar code-splitting: la primera carga de la app es proporcional a todo el código que contiene, no a lo que el usuario va a ver. En una aplicación con muchas vistas, eso puede marcar la diferencia entre una experiencia que arranca rápido y una que hace esperar.

Source maps: depurar el código de producción#

El bundle minificado de producción tiene esta pinta:

texto
function c(a,b){return Math.round(a/b*100)/100}var d=["Reinhardt","Ana","Lucio"]

Si algo falla en producción y el stack trace dice index-B3kXp2mN.js:1:18432, no sabes qué fichero de tu código corresponde a esa posición. Los source maps resuelven ese problema.

Un source map es un fichero .map que el bundler genera junto al bundle minificado. Contiene una tabla que mapea cada posición del bundle de vuelta a la posición original en tu código fuente: index-B3kXp2mN.js:1:18432 corresponde a src/utils.ts:47:5.

Los DevTools del navegador descargan el .map automáticamente cuando están abiertos y el usuario activa la pestaña Sources. Con el source map cargado, el error aparece en tu TypeScript original con los nombres de variables y funciones que escribiste.

Lo que no pasa: el usuario que navega sin abrir DevTools no descarga el .map. El navegador solo lo pide cuando los DevTools están activos y necesita mostrarte el código fuente. Activar source maps en producción tiene un coste de almacenamiento (los .map son grandes) pero coste cero para el usuario final.

Para activarlos en Vite, una línea en vite.config.ts:

typescript
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  build: {
    // genera un fichero .map junto a cada chunk JS de producción
    sourcemap: true,
  },
});

El zoo de bundlers, y cuándo cada uno#

En 2026 hay más de un bundler en uso activo. No todos hacen lo mismo ni encajan en los mismos contextos.

BundlerMotorCuándo usarlo
Vite + RolldownRust (Rolldown), dev y prodProyecto nuevo. El estándar de facto en 2026
webpackJavaScriptProyecto heredado con configuración compleja o plugins sin alternativa en Vite
rspackRust, compatible webpackProyecto heredado grande que necesita acelerar su build sin reescribir la config
esbuildGoHerramienta de transformación rápida o bundler simple; ecosistema de plugins limitado
RollupJavaScriptLibrerías: genera ESM limpio con tree-shaking fino y formatos múltiples
TurbopackRust, integrado en Next.jsSi ya usas Next.js de Vercel

Vite con Rolldown es la elección para proyectos nuevos. Desde Vite 8, Rolldown (escrito en Rust) es el bundler unificado para desarrollo y producción: arranque en milisegundos y HMR sin recargar la página completa en dev, build optimizado en prod, todo con el mismo motor. Rolldown reemplaza al dúo anterior —esbuild para la transformación en desarrollo y Rollup para el bundle de producción— que verás citado en tutoriales y configuraciones anteriores a Vite 8. La capa de transformación y minificación de Rolldown se apoya en Oxc (un toolchain en Rust), no en esbuild.

webpack no está muerto. Miles de proyectos en producción llevan una configuración de webpack construida durante años con plugins, loaders y ajustes que no tienen equivalente directo en Vite. Migrar esa configuración tiene un coste que a menudo no se justifica. Para esos proyectos, rspack es una alternativa interesante: es compatible con la API de webpack (mismos conceptos, misma estructura de configuración) pero escrito en Rust, lo que acelera los builds de forma significativa sin reescribir la configuración desde cero.

Turbopack tiene sentido solo si ya usas Next.js. Fue Next.js 16 quien lo hizo estable y lo activó por defecto tanto en next dev como en next build; en la versión 15 era opt-in. Si no usas Next.js, no hay razón para elegirlo.

Los ficheros que orquestan el build#

Con Vite, toda la configuración de build vive en un solo fichero: vite.config.ts en la raíz del proyecto. Este es un ejemplo con las opciones más habituales para producción:

typescript
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  // plugins: extensiones que añaden capacidades al pipeline de Vite.
  // @vitejs/plugin-react habilita JSX, Fast Refresh en desarrollo,
  // y el soporte de React en el servidor de desarrollo.
  plugins: [react()],

  build: {
    // sourcemap: genera ficheros .map para depurar producción
    sourcemap: true,

    // rolldownOptions configura el bundler de Vite 8 (Rolldown).
    rolldownOptions: {
      output: {
        // codeSplitting agrupa los módulos en chunks con reglas declarativas.
        codeSplitting: {
          // cada grupo da nombre a un chunk y define qué módulos entran en él.
          groups: [
            // todo lo que venga de node_modules (React, react-dom y demás
            // dependencias) va a un chunk "vendor" con su propio hash, estable
            // entre deploys mientras no actualices dependencias.
            { name: "vendor", test: /node_modules/ },
          ],
        },
      },
    },
  },
});

Ese codeSplitting es la opción nativa de Rolldown, la forma actual en Vite 8. La que verás en la mayoría de configs y tutoriales todavía es la de la API de Rollup, rollupOptions.output.manualChunks: una función que recibe el id de cada módulo y devuelve el nombre de su chunk ((id) => id.includes('node_modules') ? 'vendor' : undefined). Rolldown la mantiene por compatibilidad, pero está deprecada; el concepto —agrupar las dependencias en su propio chunk— es idéntico. En un proyecto nuevo usa codeSplitting; cuando abras un vite.config.ts heredado, reconocerás manualChunks.

Para comparar, este es el equivalente de webpack: la misma idea (entry, output, loaders para TypeScript y JSX, plugins) pero con mucha más configuración explícita:

javascript
// webpack.config.js — lo mismo que el vite.config.ts de arriba, pero manual
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  // punto de entrada de la aplicación
  entry: "./src/main.tsx",

  output: {
    // carpeta de salida y nombre del bundle con hash de contenido
    path: path.resolve(__dirname, "dist"),
    filename: "[name].[contenthash].js",
    clean: true,
  },

  module: {
    rules: [
      {
        // loader que transpila TypeScript y JSX a JavaScript
        test: /\.(ts|tsx)$/,
        use: "ts-loader",
        exclude: /node_modules/,
      },
    ],
  },

  optimization: {
    splitChunks: {
      cacheGroups: {
        // separa node_modules en un chunk vendor, igual que manualChunks de Vite
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: "vendor",
          chunks: "all",
        },
      },
    },
  },

  plugins: [
    // genera el index.html con las referencias a los chunks automáticamente
    new HtmlWebpackPlugin({ template: "./index.html" }),
  ],

  resolve: {
    // permite importar ficheros .ts y .tsx sin escribir la extensión
    extensions: [".ts", ".tsx", ".js"],
  },
};

La diferencia de complejidad es real. Con Vite, los loaders para TypeScript y JSX, la generación del HTML con los assets hasheados, y el source map están activos por defecto o con una línea de configuración. Con webpack, cada pieza se configura de forma explícita. Esa explicitud es útil cuando necesitas control fino; es innecesaria cuando el comportamiento por defecto es el que quieres.

Comprueba lo que sabes#

Pregunta 1 de 7

¿Cuál es la diferencia entre transpilar y minificar?

Tu turno#

El ejercicio se hace en local, sobre el Team Builder de React que vienes construyendo desde el Nivel 7. No hay playground en esta página: mirar el output de un bundler de verdad requiere un proyecto de verdad, con dependencias reales y un proceso de build que genere ficheros que puedas inspeccionar en tu sistema de ficheros. Los ficheros de solución están para que los leas después: la comparación entre lo que hiciste y la solución es donde está el aprendizaje.

Ejercicio · hazlo en local

Inspeccionar y optimizar el bundle del Team Builder

Sobre el Team Builder de React que vienes construyendo desde el Nivel 7, construye el bundle de producción con Vite y lee lo que hay dentro. Después aplica code-splitting con React.lazy y configura Vite para separar las dependencias en un chunk estable. Las soluciones muestran cada tier con su explicación. Haz el tuyo primero y luego léelas: la comparación es donde está el aprendizaje.

Paso 1: Construir e inspeccionar el bundle

  • Ejecutas `npm run build` y el proceso termina sin errores.
  • Listas `dist/assets/` e identificas al menos un chunk JS y un fichero CSS, ambos con hash en el nombre.
  • Abres `dist/index.html` y localizas las etiquetas `<script>` y `<link>` que referencian esos assets con el nombre exacto con hash.
  • Puedes explicar por qué los nombres llevan hash y qué relación tiene con la caché del navegador.
  • Compruebas que `dist/assets/*.js | wc -l` devuelve 1: todo el código de la app va en un único chunk inicial.

Cómo hacerlo en local

Clona el repositorio del curso, entra en la carpeta del ejercicio y abre el index.html en tu navegador. Toda tu solución va en solucion.js.

git clone <repo>
cd exercises/nivel-9/bundling-a-fondo
# abre index.html en el navegador y edita solucion.js
Ver soluciones
#!/usr/bin/env bash
# Tier ok: construir el bundle e inspeccionar el output.
# Demuestra qué genera Vite al hacer un build de producción: ficheros JS y CSS
# con hash en el nombre, un HTML que los referencia, y un único chunk inicial
# que contiene toda la lógica de la aplicación.

# --- 1. Construir el bundle de producción ---

# vite build lee tu vite.config.ts, transpila TypeScript, aplica tree-shaking,
# minifica el resultado y escribe todo en dist/
npm run build


# --- 2. Ver la estructura completa de dist/ ---

# lista recursivamente todos los ficheros generados en el directorio de salida
ls -R dist

# lo que verás tiene esta forma (los hashes cambian en cada build):
#
# dist/:
# assets/  index.html
#
# dist/assets:
# index-B3kXp2mN.js   <- chunk inicial: toda tu app en un solo fichero
# index-CqR7vWlA.css  <- todos tus estilos en un solo fichero


# --- 3. Ver los tamaños reales de cada fichero ---

# du (disk usage) con -h (human readable) muestra kilobytes y megabytes legibles
# el asterisco expande todos los ficheros dentro de assets/
du -h dist/assets/*

# lo que verás:
# 142K  dist/assets/index-B3kXp2mN.js   <- JS minificado: React + tu app juntos
#   8K  dist/assets/index-CqR7vWlA.css


# --- 4. Mirar cómo index.html referencia los assets ---

# cat vuelca el contenido del fichero en el terminal
cat dist/index.html

# lo que verás en el <head>:
#   <link rel="stylesheet" crossorigin href="/assets/index-CqR7vWlA.css">
#
# y al final del <body>:
#   <script type="module" crossorigin src="/assets/index-B3kXp2mN.js"></script>
#
# Vite inyecta los nombres exactos con hash en el HTML automáticamente.
# No necesitas actualizarlos a mano: el HTML siempre apunta al fichero correcto.


# --- 5. Entender por qué los nombres llevan hash ---

# el hash (B3kXp2mN) es una huella del CONTENIDO del fichero.
# si el contenido no cambia entre dos builds, el hash no cambia.
# eso significa que el navegador puede guardar el fichero en caché con una
# cabecera "Cache-Control: max-age=31536000" (un año) sin miedo a servir
# una versión vieja: si el código cambia, el hash cambia, el nombre cambia,
# y el navegador descarga el fichero nuevo.
# si el código no cambia (por ejemplo, la librería de React), el hash es el
# mismo y el navegador sirve el fichero desde su caché local sin hacer ninguna
# petición de red.

# cuentas cuántos ficheros JS hay en assets/ para comprobar que solo hay uno
ls dist/assets/*.js | wc -l

# devuelve: 1
# todo el código (React, react-dom, tus componentes, tus hooks, los datos de
# héroes) va empaquetado en ese único fichero. el navegador tiene que descargarlo
# entero antes de que la app pueda arrancar, aunque el usuario solo vaya a ver
# la página de inicio y nunca abra el detalle de un héroe.


# --- Límite de este tier ---
# Inspeccionar el output es el primer paso, pero no cambia nada del bundle.
# El problema de este tier es que hay un solo chunk JS: si la app crece
# (más componentes, más vistas, más lógica), ese fichero único se hace cada
# vez más grande y el tiempo de carga inicial crece con él. El usuario paga
# el coste de descargar código que quizá no necesita en esa visita.
# El tier "mejor" resuelve esto partiendo el bundle en trozos que el navegador
# descarga solo cuando los necesita.

Por qué este nivel

  • El primer paso siempre es mirar lo que genera el bundler antes de intentar optimizarlo. Este tier enseña a leer el output de Vite: los nombres con hash, la estructura de `dist/`, y cómo `index.html` referencia los assets generados.
  • Su límite: todo el código (React, react-dom, tus componentes, tus datos de héroes) va en un único chunk JS. Cuanto más crece la app, más tarda la primera carga. El usuario que solo visita la lista paga el coste de descargar el código del detalle que quizá nunca abre. El tier "mejor" parte ese bundle en trozos más pequeños.