import() dinámico y top-level await
Cómo ESM recupera el dinamismo sin perder la estática: el operador import() que carga módulos bajo demanda y devuelve una promesa, y el top-level await que permite esperar en el nivel superior de un módulo.
El import estático es rígido a propósito: todo se conoce antes de ejecutar. Pero a veces necesitas cargar código según lo que ocurra en runtime. Ahí entran dos piezas: import() dinámico, que trae un módulo bajo demanda devolviendo una promesa, y el top-level await, que deja a un módulo esperar en su nivel superior sin envolverse en una función async.
- Usar
import()dinámico: sintaxis, la promesa y el namespace resultante. - Aplicarlo a casos reales: code splitting, carga condicional, plugins e i18n.
- Emplear top-level await para esperar en el nivel superior del módulo.
- Entender cómo el TLA convierte módulos en asíncronos y propaga la espera.
import() como operador, no como función
import() parece una llamada a función, pero es un operador sintáctico del lenguaje. Devuelve una promesa que resuelve al objeto de namespace del módulo (lo mismo que daría import * as). Acepta un especificador calculado en runtime y funciona tanto en ESM como en CommonJS:
// esperar el módulo bajo demanda
const { render } = await import("./editor.ts");
render();
// o encadenando la promesa
import("./editor.ts").then((m) => m.render());
El objeto que resuelve la promesa es el namespace del módulo: sus propiedades son las exportaciones, incluida default si la hay. Por eso, para un módulo con export por defecto, se escribe (await import("./x.ts")).default — un detalle que sorprende la primera vez.
A diferencia del import estático, el operador import() funciona dentro de un módulo CommonJS. Es, de hecho, la vía canónica para que código CJS cargue un paquete que solo se publica como ESM. Por eso import() es el verdadero puente universal entre los dos mundos, no solo una herramienta de code splitting.
Cuando el bundler ve un import() con un literal de cadena, lo trata como un punto de corte: separa ese módulo en un chunk propio que se descarga solo cuando la promesa se ejecuta. Eso es code splitting, y es el mecanismo detrás del lazy loading por ruta y por componente.
Como devuelve una promesa, import() también te da manejo de errores de carga —imposible con el import estático, que aborta el módulo entero si la resolución falla—:
try {
const mod = await import("./editor.ts");
mod.render();
} catch (e) {
mostrarFallback(); // la red falló: degrada con gracia
}
Las dos formas de importar cubren necesidades opuestas y coexisten en el mismo archivo:
| Aspecto | import estático |
import() dinámico |
|---|---|---|
| Momento | antes de evaluar | en runtime, bajo demanda |
| Devuelve | bindings vivos | promesa de un namespace |
| Especificador | literal fijo | puede ser calculado |
| Errores | rompe el módulo | capturables con try/catch |
| Efecto en el bundle | mismo chunk | frontera de chunk |
Si el especificador es una plantilla variable como ./locales/${lang}.js, el bundler no puede saber qué archivos existen. Para eso Vite ofrece import.meta.glob, que genera un mapa de import() a partir de un patrón, dándote dinamismo sin perder el análisis del grafo.
Junto al dinamismo de import() viaja import.meta, un objeto con información del módulo en runtime. import.meta.url da su URL, base para resolver rutas relativas; import.meta.resolve("m") devuelve la URL resuelta de un especificador; e import.meta.hot es la puerta al HMR de Vite. Es la contraparte moderna de __dirname.
Casos de uso reales
Code splitting
Cada import() con literal genera un chunk aparte; el usuario descarga solo lo que su ruta necesita.
Carga condicional
Cargar un polyfill o una implementación pesada solo si el runtime lo requiere, tras una detección de capacidades.
Plugins
Descubrir extensiones en runtime y cargarlas por su nombre, sin conocerlas al empaquetar.
i18n
Traer el paquete de idioma del usuario y nada más, difiriendo el resto de locales.
Los frameworks envuelven este mecanismo en APIs ergonómicas: React.lazy(() => import("./Panel.tsx")) o el defineAsyncComponent de Vue no son más que import() con un envoltorio que gestiona el estado de carga. Astro va más lejos con sus directivas client:*, que deciden en tiempo de build qué islas se hidratan con un import() diferido y cuáles no envían JavaScript alguno.
Top-level await: esperar en el nivel superior
Antes, todo await debía vivir dentro de una función async. El top-level await (TLA) permite usarlo directamente en el cuerpo de un módulo ESM:
// config.ts
const respuesta = await fetch("/config.json");
export const config = await respuesta.json();
Un uso legítimo y frecuente en 2026 es inicializar un módulo WebAssembly antes de exportar su API, de modo que quien lo importe reciba algo ya listo para usar:
// hash.ts
const wasm = await WebAssembly.instantiateStreaming(fetch("/hash.wasm"));
export const hash = wasm.instance.exports.hash;
El efecto es profundo: cualquier módulo que use TLA se vuelve asíncrono, y todos los que lo importen esperan a que termine su evaluación antes de ejecutar la suya. La espera se propaga hacia arriba por el grafo:
flowchart TD A[config.ts con await] --> B[servicio.ts que lo importa] B --> C[main.ts raiz] A -->|bloquea la evaluacion| B B -->|propaga la espera| C style A fill:#f9e2af,color:#11111b style B fill:#89b4fa,color:#11111b style C fill:#a6e3a1,color:#11111b
El coste: cascadas en el grafo
El TLA es potente pero tiene un peligro: si un módulo profundo del árbol espera, puede retrasar la evaluación de todo lo que dependa de él, creando una cascada (waterfall). El motor evalúa en paralelo las ramas independientes, pero una dependencia lineal con TLA serializa el arranque. La regla práctica: reserva el TLA para la raíz o para inicializaciones genuinamente asíncronas (conexiones, config remota, WASM), no para cualquier módulo intermedio.
Conviene distinguir dos ejes que se confunden a menudo: import() difiere qué código se carga y cuándo; el top-level await difiere cuándo termina de evaluarse un módulo ya cargado. Uno actúa sobre el grafo de dependencias, el otro sobre el orden temporal de evaluación. Combinarlos —cargar con import() un módulo que a su vez usa TLA— es válido: la promesa de import() simplemente no resuelve hasta que el TLA interno acaba.
El precio del code splitting es una petición de red extra en el momento de la carga. Para ocultar esa latencia, los frameworks emiten una etiqueta link rel=modulepreload o hacen prefetch del chunk en cuanto el usuario muestra intención (hover sobre un enlace, viewport cercano), de modo que el módulo ya esté en caché cuando la promesa se ejecute.
Dividir en exceso genera decenas de chunks minúsculos y un aluvión de peticiones que, incluso con HTTP/2, puede salir más caro que un bundle mayor. Dividir de menos infla la carga inicial. El equilibrio de 2026: separar por ruta siempre, por componente solo cuando es pesado o poco frecuente (un editor, un visor de gráficas), y dejar que el bundler agrupe las dependencias compartidas en un chunk común.
Node ejecuta TLA en módulos ESM sin problema; CommonJS no lo admite, porque require es síncrono. Los bundlers (Rollup, Rolldown) lo soportan al emitir formato esm. En navegadores es baseline desde hace años dentro de <script type="module">.
import() y el top-level await resuelven la tensión central del diseño de ESM: cómo recuperar la flexibilidad de runtime de CommonJS sin sacrificar el grafo analizable. La respuesta es elegante — el dinamismo se encapsula en fronteras explícitas. Un import() no disuelve la estática: crea un límite nítido (el chunk) que el bundler sí puede ver y optimizar; el código dentro sigue siendo ESM plenamente analizable, solo que su momento de carga es decisión del runtime. El TLA hace lo análogo en el eje del tiempo: marca un módulo como asíncrono de forma declarativa, así que el motor puede ordenar la evaluación del grafo respetando esas esperas sin adivinar nada. En vez de elegir entre “estático y rígido” o “dinámico y opaco”, ESM te da estática por defecto con puertas de dinamismo explícitas. Diseñar bien en 2026 es elegir dónde poner esas puertas: cada import() es una decisión de arquitectura sobre qué carga el usuario y cuándo.
- Convierte un
importestático de un componente pesado enimport()y confirma en el build que genera un chunk separado. - Carga un módulo solo tras una condición de runtime y verifica que no aparece en el bundle inicial.
- Usa
import.meta.globpara cargar dinámicamente uno de varios archivos de idioma por su nombre. - Añade top-level await en un módulo y observa cómo sus importadores esperan; luego razona dónde podría generar una cascada.
- Carga con
import()un módulo que use top-level await y comprueba que la promesa no resuelve hasta que la espera interna termina.