wandres.dev
CODE SPLITTING · Cargar lo que hace falta

import() dinámico: qué genera de verdad el empaquetador

La semántica del import dinámico, qué código emite el empaquetador alrededor, por qué una ruta de importación variable produce resultados sorprendentes, y cómo se manejan los fallos de carga de un fragmento.

⏱ 18 min

import() parece una función y no lo es: es una forma sintáctica que el empaquetador reconoce y reescribe en tiempo de compilación. Esa diferencia explica casi todo el comportamiento que desconcierta a la gente, desde por qué una ruta construida con una variable no funciona como espera hasta por qué el fragmento a veces se descarga antes de que el código llegue a la línea. Entender qué emite el empaquetador convierte el troceado de algo mágico en algo predecible.

🎯 Al terminar esta lección sabrás
  • Describir la semántica de import() según la especificación y qué garantiza.
  • Reconocer el código que el empaquetador emite alrededor de cada importación dinámica.
  • Escribir rutas de importación que el análisis estático pueda resolver.
  • Manejar el fallo de carga de un fragmento sin dejar la interfaz colgada.

Qué es y qué garantiza

import() está en la especificación del lenguaje y devuelve una promesa que se resuelve con el objeto de espacio de nombres del módulo: un objeto con una propiedad por cada exportación, incluida default si la hay.

const mod = await import('./formato.js');
mod.formatearMoneda(12.5);          // exportacion nombrada
mod.default;                        // exportacion por defecto, si existe
const { formatearMoneda } = await import('./formato.js');   // desestructurado

Tres garantías del lenguaje que conviene tener claras porque de ellas dependen los patrones que funcionan.

El módulo se evalúa una sola vez. Aunque llames a import() con la misma especificación cincuenta veces, el módulo se instancia y se ejecuta una vez, y las cincuenta promesas resuelven al mismo objeto. La memorización manual con ??= que se usa en la práctica no es para evitar la doble evaluación —eso ya lo garantiza el registro de módulos— sino para evitar el trabajo posterior de crear la instancia del componente dos veces.

Se puede usar en cualquier contexto, incluido un script clásico, no solo dentro de un módulo. Un import estático solo vale dentro de un módulo; el dinámico funciona en cualquier sitio.

El objeto devuelto es de solo lectura y sus propiedades son enlaces vivos. Si el módulo reasigna una variable exportada, el valor que ves cambia. No es una copia.

Y una limitación que cuesta descubrir: import() no permite cancelar. Una vez lanzada la petición, no hay forma de abortarla. Si el usuario cierra el modal antes de que llegue el fragmento, la descarga continúa. Lo único que puedes hacer es ignorar el resultado, comprobando en el then si sigue teniendo sentido montarlo.

Lo que emite el empaquetador

En el fuente escribes una línea. En la salida hay bastante más. Aproximadamente esto, adaptado a la forma que genera un empaquetador moderno con salida en módulos ES:

// Tu fuente
const { crearEditor } = await import('./editor/EditorRico.js');
// Aproximadamente lo que sale
const { crearEditor } = await __cargarFragmento(
  () => import('./chunks/EditorRico-a3f9c1.js'),   // ruta con hash de contenido
  ['./chunks/vendor-quill-7b2e40.js']              // dependencias que hay que cargar antes
);

Las dos partes importantes son estas.

La ruta se reescribe al nombre final con hash. Por eso no puedes construir la ruta con una cadena arbitraria en tiempo de ejecución: el empaquetador no sabría a qué fichero corresponde y no podría reescribirla.

Se emite una lista de dependencias del fragmento. Si el módulo diferido importa otro fragmento compartido, el ayudante los carga en paralelo antes de evaluar. Esto es lo que evita una cascada de peticiones encadenadas, y es una de las cosas más valiosas que hace el empaquetador por ti.

Además, muchos empaquetadores emiten en el HTML los enlaces de precarga de los fragmentos, o los insertan dinámicamente al pedir uno, de forma que el navegador pueda empezar la descarga con la prioridad correcta. Es el tema de precargar el chunk.

Rutas variables: lo que funciona y lo que no

El análisis estático necesita saber, en tiempo de compilación, qué ficheros pueden ser el destino. Con una ruta literal es trivial. Con una variable, depende de la forma.

No funciona: una ruta completamente dinámica.

// El empaquetador no puede saber que fichero es. No genera fragmento.
const ruta = obtenerRutaDelServidor();
const mod = await import(ruta);

Con salida en módulos ES esto se deja tal cual y el navegador intenta resolver la URL en tiempo de ejecución. Puede funcionar si la URL es real y accesible, y no se beneficia de nada: ni hash, ni precarga, ni recorte.

Funciona con matices: una plantilla con prefijo y sufijo constantes.

// El empaquetador genera un fragmento por cada fichero que encaje con el patron
const idioma = detectarIdioma();
const mensajes = await import(`./i18n/${idioma}.js`);

Esto sí lo entienden los empaquetadores modernos: interpretan el patrón como ./i18n/*.js e incluyen todos los ficheros que encajen, cada uno en su fragmento. El comportamiento es útil y tiene una trampa: si en ese directorio hay treinta idiomas, has generado treinta fragmentos, y aunque solo se descargue uno, los treinta están en tu directorio de salida y en tu manifiesto.

La forma explícita, que siempre funciona y es la que recomiendo:

const cargadores = {
  es: () => import('./i18n/es.js'),
  en: () => import('./i18n/en.js'),
  pt: () => import('./i18n/pt.js'),
};

async function cargarIdioma(codigo) {
  const cargar = cargadores[codigo] ?? cargadores.es;
  return (await cargar()).default;
}

Es más verboso y a cambio es explícito, analizable, refactorizable por el editor, y comprobable por el sistema de tipos. En un mapa de rutas o de idiomas, esta forma es la correcta.

Un fragmento que no se puede descargar rompe la funcionalidad para siempre, y ocurre en cada despliegue

Este es el fallo de producción más común del troceado, y casi ninguna aplicación lo maneja.

La secuencia. Un usuario tiene tu página abierta. Despliegas. Los ficheros nuevos tienen hashes nuevos y los viejos se borran del origen o se purgan de la CDN. El usuario, que sigue con la pestaña abierta con el HTML antiguo, pulsa un botón que dispara import('./chunks/Editor-a3f9c1.js'). Ese fichero ya no existe. La promesa se rechaza. Y como en la mayoría del código el await está dentro de un manejador sin captura, el botón deja de funcionar y no vuelve a funcionar hasta que el usuario recargue, cosa que no se le ocurre porque el resto de la página va bien.

En una aplicación con sesiones largas y despliegues frecuentes, esto le pasa a un porcentaje pequeño pero constante de usuarios, todos los días. Y no aparece en la telemetría salvo que la busques, porque el rechazo no genera un error de JavaScript visible.

La solución completa tiene tres capas.

Capa uno: no borres los ficheros viejos inmediatamente. Mantén al menos dos o tres despliegues de artefactos en el origen. Es barato —son kilobytes— y elimina la mayor parte de los casos.

Capa dos: reintenta una vez con la caché saltada. Muchos fallos son de red transitoria, no de fichero inexistente.

async function importarConReintento(cargar, intentos = 2) {
  let ultimo;
  for (let i = 0; i < intentos; i++) {
    try {
      return await cargar();
    } catch (e) {
      ultimo = e;
      await new Promise((r) => setTimeout(r, 250 * (i + 1)));
    }
  }
  throw ultimo;
}

Capa tres, la que de verdad cierra el problema: detecta el fallo de fragmento y ofrece recargar. Un fallo de importación dinámica se distingue por el mensaje del error, que varía entre navegadores pero contiene siempre una referencia al módulo o al import dinámico.

function esFalloDeFragmento(e) {
  const m = String(e?.message ?? '');
  return /Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed/i.test(m);
}

window.addEventListener('unhandledrejection', (e) => {
  if (!esFalloDeFragmento(e.reason)) return;
  e.preventDefault();
  mostrarAviso({
    texto: 'Hay una versión nueva de la aplicación disponible.',
    accion: 'Recargar',
    alPulsar: () => location.reload(),
  });
});

Ese aviso convierte un fallo silencioso e irrecuperable en una recuperación de un clic, y es probablemente veinte líneas de código con el mejor retorno de todo este nivel.

Un matiz que evita un bucle infinito: si la recarga tampoco arregla el problema porque el HTML también viene de una caché vieja, el usuario acaba recargando en círculo. Sirve marcar la recarga en el almacenamiento de sesión y, si ya se recargó una vez por este motivo, mostrar un mensaje distinto en lugar de recargar otra vez.

Anotaciones para el empaquetador

Los empaquetadores aceptan comentarios mágicos dentro de la llamada para ajustar el comportamiento. Los dos más útiles con webpack:

const mod = await import(
  /* webpackChunkName: "editor" */
  /* webpackPrefetch: true */
  './editor/EditorRico.js'
);

webpackChunkName da al fragmento un nombre legible en lugar de un número, lo cual mejora mucho la lectura de los informes y de los perfiles. webpackPrefetch inserta un <link rel="prefetch"> para que el navegador descargue el fragmento en tiempo ocioso.

Rollup y Vite no usan esa sintaxis; el nombre del fragmento se controla desde la configuración de salida, y la precarga se gestiona con su propio mecanismo. Si migras de uno a otro, esos comentarios se quedan como comentarios inertes y conviene limpiarlos para no dar la impresión de que hacen algo.

Y una advertencia sobre webpackPrefetch que se pasa por alto: la precarga en tiempo ocioso compite con recursos reales en conexiones lentas. Marcar diez fragmentos con precarga en una aplicación que se ve en móvil convierte una optimización en un desperdicio de datos del usuario. Úsalo con dos o tres, los de mayor probabilidad de uso, no con todos.

⚔️ Reto práctico

Busca en tu código todas las llamadas a import() con ruta construida dinámicamente y comprueba en el directorio de salida cuántos fragmentos genera cada una. Si alguna genera más de diez, conviértela a la forma explícita con mapa de cargadores. Después implementa el manejador de fallo de fragmento y verifícalo de la forma honesta: carga la página, borra un fichero de fragmento del directorio servido, y pulsa el botón que lo carga.