wandres.dev
CODE SPLITTING · dividir el bundle

import() dinámico como punto de corte

Cada import() con un especificador que el bundler puede ver marca una frontera: el módulo destino y todo lo que solo él alcanza se separan en un chunk que se descarga cuando la promesa se ejecuta. Es la unidad atómica del code splitting, la instrucción con la que le dices al bundler dónde partir el grafo.

⏱ 16 min

El bundler no adivina dónde quieres dividir: se lo dices tú, y la forma de decirlo es import() dinámico. Donde un import estático funde el módulo destino en el mismo chunk, un import() traza una frontera: aquí termina un chunk y empieza otro, cargado bajo demanda. Toda la maquinaria del code splitting —lazy loading por ruta, componentes diferidos, plugins— se reduce a colocar bien estos puntos de corte en el grafo.

🎯 Al terminar esta lección sabrás
  • Entender por qué import() es la única frontera de chunk que el bundler reconoce.
  • Ver cómo el bundler parte el grafo de módulos a partir de los puntos de corte.
  • Distinguir un especificador analizable de uno opaco y sus consecuencias.
  • Colocar cortes múltiples con import.meta.glob sin perder el análisis estático.

La frontera que el bundler sí ve

Un import estático es una instrucción de fusión: el bundler resuelve el módulo, lo mete en el mismo chunk que el importador y sigue. Un import() dinámico es lo contrario, una instrucción de corte: el bundler ve la llamada, entiende que ese módulo debe cargarse por separado y emite un chunk independiente para él.

// import estatico: Editor viaja en el MISMO chunk que su importador
import { Editor } from "./editor.ts";

// import() dinamico: Editor se separa en su PROPIO chunk, cargado al invocar
const { Editor } = await import("./editor.ts");

La diferencia visible en el build es directa: la primera forma no genera archivos nuevos; la segunda hace aparecer un editor-a1b2c3.js que el navegador solo pide cuando esa línea se ejecuta. Nada más cambia el momento de carga; la semántica del módulo es idéntica.

📝
El corte es del bundler, no del lenguaje

En el navegador, import() simplemente carga un módulo por URL en runtime; no sabe nada de chunks. Es el bundler quien, al ver un import() en tu fuente, decide materializar esa frontera como un archivo aparte durante el build. Por eso el mismo import() que en desarrollo carga tu módulo tal cual, en producción dispara toda la lógica de chunking. La sintaxis es del lenguaje; el chunk es una decisión de la herramienta.

Del punto de corte al grafo de chunks

Un chunk no es un módulo: es un conjunto de módulos. Cuando el bundler encuentra un import(), no separa solo el módulo destino, sino todo el subgrafo al que ese módulo lleva y que no es alcanzable de otra forma desde la entrada. El criterio es la alcanzabilidad: un módulo va al chunk asíncrono si solo se llega a él cruzando esa frontera dinámica.

flowchart TD
E[entry main] --> A[app comun]
E -.import dinamico.-> V[editor]
V --> P[parser markdown]
V --> H[resaltador sintaxis]
A --> U[utilidades]
style E fill:#a6e3a1,color:#11111b
style V fill:#f9e2af,color:#11111b
style P fill:#f9e2af,color:#11111b
style H fill:#f9e2af,color:#11111b

En el diagrama, el parser y el resaltador caen en el chunk del editor porque solo el editor los alcanza. Si en cambio las utilidades fueran usadas tanto por la entrada como por el editor, el bundler no las duplicaría: las promovería a un chunk compartido —el tema del siguiente nivel—. La regla mental es que cada import() propone una partición, y el bundler la reconcilia con las demás para no duplicar lo común.

De aquí sale una taxonomía útil de chunks. El entry chunk es el que arranca la aplicación, referenciado directamente desde el HTML; los async chunks son los que nacen de cada import() y se cargan bajo demanda; y los shared chunks son los que el bundler emancipa cuando varios de los anteriores comparten módulos. Un mismo módulo nunca se duplica entre ellos: pertenece al chunk más específico desde el que sigue siendo alcanzable por todos sus consumidores.

Para que esto funcione en runtime, el bundler emite un manifiesto: un mapa que relaciona cada punto de corte con el archivo real, con su hash, y con los chunks que deben cargarse antes que él. Cuando tu import() se ejecuta, el runtime consulta ese mapa para saber qué URL pedir y qué dependencias precargar. Tú escribes una ruta lógica al módulo; el manifiesto la traduce a la ruta física del chunk versionado.

💡
Nombrar los chunks para leerlos

Por defecto un async chunk sale con un nombre derivado del módulo y un hash: editor-a1b2c3.js. Con Rollup y Rolldown puedes darle un nombre estable mediante chunkFileNames o, por corte, con las opciones de salida; en el ecosistema histórico existían comentarios mágicos dentro del import() para bautizar el chunk. Un buen esquema de nombres convierte tu carpeta de salida en un mapa legible de la arquitectura de carga de la app, en vez de una sopa de hashes.

Especificador analizable frente a opaco

Todo el poder del corte depende de que el bundler pueda ver qué importas en tiempo de build. Un literal de cadena es transparente; una variable que se resuelve en runtime es opaca, y ante lo opaco el bundler no puede crear un chunk con antelación.

// Analizable: literal. El bundler crea el chunk del modulo destino.
const mod = await import("./features/export-pdf.ts");

// Opaco: el argumento se decide en runtime; el bundler no sabe que empaquetar.
const nombre = obtenerFeature();
const mod2 = await import(nombre); // sin chunk predecible; puede avisar o fallar

Ante un especificador opaco, un bundler moderno emite un aviso, porque no puede garantizar que el módulo exista en el build ni prepararlo como chunk. La salida cuando necesitas dinamismo real sobre un conjunto conocido de archivos es declarar el patrón, no ocultarlo: así el bundler ve todas las variantes posibles y genera un chunk por cada una.

La lección de fondo es que el bundler necesita cerrar el conjunto de destinos posibles en build. Un literal cierra el conjunto a un elemento; un patrón glob lo cierra a los archivos que casan; una variable arbitraria lo deja abierto, y sobre un conjunto abierto no hay chunking posible. Cada vez que un import() deja de compilar en el chunk que esperabas, la pregunta correcta es qué parte de su especificador quedó abierta al runtime y cómo cerrarla sin perder el dinamismo que necesitas.

💡
import.meta.glob: muchos cortes de una vez

Cuando quieres cargar uno de entre varios archivos por su nombre —idiomas, temas, páginas de contenido— import.meta.glob de Vite genera un mapa de import() a partir de un patrón. Cada archivo que casa el patrón se convierte en su propio chunk, y tú eliges cuál invocar en runtime. Consigues dinamismo sobre un conjunto conocido sin dejar ciego al bundler, que sigue viendo el grafo completo.

// Un chunk por cada archivo de idioma, elegido en runtime por su clave
const locales = import.meta.glob("./locales/*.ts");
const cargar = locales[`./locales/${lang}.ts`];
const dict = await cargar();

El patrón glob te da lo mejor de ambos mundos: dinamismo de runtime sobre un conjunto que el bundler conoce por entero en build. Con la opción eager puedes además decidir si esos módulos se cortan en chunks perezosos —el defecto— o se incluyen de golpe; y con query e import puedes pedir solo una exportación concreta de cada archivo, afinando qué entra en cada chunk. Es la herramienta canónica para catálogos de contenido, sistemas de plugins internos y rutas basadas en el sistema de archivos.

Todo esto deja una consecuencia mental útil: puedes leer el mapa de chunks de un build como el negativo de sus import(). Cada async chunk corresponde a un punto de corte que alguien escribió; cada shared chunk, a un solapamiento que el bundler reconcilió. El artefacto de salida es la sombra fiel de las decisiones de división tomadas en el código fuente.

Los frameworks envuelven el corte

Rara vez escribirás import() a mano para dividir una ruta: los frameworks lo esconden tras APIs ergonómicas que, por dentro, no son más que un import() con gestión del estado de carga.

⚛️

React

lazy(() => import("./Panel.tsx")) con Suspense para el fallback mientras el chunk viaja.

🟢

Vue

defineAsyncComponent(() => import("./Panel.vue")), con estados de carga y error integrados.

🏝️

Astro

Las directivas client:* deciden en build qué islas se hidratan con un import() diferido y cuáles no envían JavaScript.

🧭

Routers

La carga por ruta de cualquier router moderno es un import() por pantalla, resuelto al navegar.

Diferir carga introduce un modo de fallo que el monolito no tenía: la petición del chunk puede fracasar —red caída, despliegue nuevo que invalidó el hash antiguo, usuario con una pestaña vieja—. Como import() devuelve una promesa, ese fallo es capturable, y una app seria lo trata: reintenta con backoff, o fuerza una recarga cuando detecta que el chunk pedido ya no existe tras un despliegue.

⚠️
El chunk que desaparece tras desplegar

Un fallo clásico en producción: el usuario tiene abierta una versión antigua, despliegas una nueva con hashes distintos, y al navegar su import() pide un chunk que el CDN ya no sirve. La promesa se rechaza y la ruta no carga. Las mitigaciones habituales son conservar los chunks viejos durante una ventana de gracia, y capturar el error de carga para ofrecer una recarga limpia. Es el precio de que el momento de carga viva en runtime: hay que diseñar para el instante en que la red falla.

Diferir con gracia también significa reintentar. En redes inestables, envolver el import() en una pequeña función de reintento evita que un fallo transitorio deje una ruta inaccesible para siempre:

// Reintenta un import() con un respiro creciente entre intentos
async function importarConReintento(cargar, intentos = 3) {
  try {
    return await cargar();
  } catch (e) {
    if (intentos <= 1) throw e;
    await new Promise((r) => setTimeout(r, 400));
    return importarConReintento(cargar, intentos - 1);
  }
}

Es un patrón pequeño pero revelador: como el momento de carga vive en runtime, el splitting te obliga a tratar la carga de código con el mismo cuidado que cualquier otra operación de red que puede fallar. El monolito nunca fallaba a media navegación porque ya estaba todo cargado; el código dividido, sí.

import() es la costura entre el grafo estático y el grafo temporal

El diseño de ESM apostó por un grafo de módulos estático y analizable: todo lo que un módulo importa se conoce antes de ejecutar, y esa rigidez es justo lo que permite tree shaking, resolución adelantada y bundling fiable. Pero el rendimiento de arranque exige lo contrario, dinamismo: no cargar hasta que haga falta. import() es la pieza que reconcilia ambas exigencias, y por eso es el corazón del code splitting y no un mero azúcar sintáctico. Lo que hace es introducir una frontera explícita y localizada en el grafo: a un lado, todo sigue siendo estático y optimizable; al cruzarla, el momento de carga pasa a manos del runtime. El bundler puede ver la frontera —está escrita en tu código como una llamada con un literal— y por eso puede prepararla de antemano, emitiendo un chunk que existe pero espera. Esto explica de golpe por qué el especificador opaco es tan grave: no rompe la sintaxis, rompe la visibilidad, y sin visibilidad el bundler no puede materializar el chunk antes de tiempo, que es precisamente su trabajo. Interiorizar que cada import() es una costura visible entre lo estático y lo temporal te da el criterio para colocarlos bien: los pones donde el grafo de tu producto tiene una junta natural de uso —una ruta, una función pesada y opcional, una capacidad detectada en runtime— y los mantienes analizables para que la herramienta pueda hacer su parte. Un import() no es una optimización que se añade; es una decisión de arquitectura que declara, en el propio código, dónde tu aplicación deja de ser una sola cosa y pasa a ser varias que llegan por separado.

⚔️ Traza tus cortes
  1. Convierte un import estático de un componente pesado en import() y confirma en el build que aparece un chunk nuevo con su hash.
  2. Añade a ese componente una dependencia que solo él use y verifica que cae en el mismo chunk asíncrono, no en la entrada.
  3. Introduce un especificador opaco a propósito y observa el aviso del bundler; luego reescríbelo como literal o con import.meta.glob.
  4. Usa import.meta.glob para cargar uno de varios archivos de idioma y cuenta cuántos chunks genera el patrón.
  5. Sustituye tu import() manual por la API de lazy loading de tu framework y comprueba en la red que el chunk se pide en el mismo momento.