. En Node, usa la extensión .mjs o añade \"type\": \"module\" en tu package.json."}},{"@type":"Question","name":"¿Qué diferencia hay entre export default y named exports?","acceptedAnswer":{"@type":"Answer","text":"Un módulo puede tener varios named exports (export function foo() {}), pero solo un export por defecto (export default ...). Los named exports se importan con el mismo nombre entre llaves: import { foo } from './x.js'. El default, en cambio, se puede importar con el nombre que quieras: import loQueSea from './x.js'."}},{"@type":"Question","name":"¿Qué es el import() dinámico en JavaScript?","acceptedAnswer":{"@type":"Answer","text":"import() llamado como función devuelve una promesa que resuelve con los exports del módulo. A diferencia del import estático, se ejecuta en el momento de la llamada, así que puedes cargar código de forma condicional o bajo demanda. Es la base del code-splitting y del lazy loading."}},{"@type":"Question","name":"¿Hace falta poner la extensión del archivo en los imports?","acceptedAnswer":{"@type":"Answer","text":"En módulos ES nativos —tanto en el navegador como en el loader ESM de Node— sí. Tienes que escribir ./utils.js, no ./utils. Bundlers como Vite o webpack son más permisivos y resuelven rutas sin extensión, pero depender de eso hace que tu código deje de ser portable."}}]}
Menu

Módulos ES en JavaScript: import, export y carga dinámica

Cómo funcionan los módulos ES en JavaScript: exports nombrados y por defecto, sintaxis de import, import() dinámico y qué diferencia a un módulo de un script clásico.

Un módulo es un archivo con su propio ámbito

Antes de que existieran los módulos ES, cada etiqueta <script> volcaba sus variables al espacio global y el orden de carga decidía qué veía qué. Los módulos ES de JavaScript solucionan esto haciendo que cada archivo tenga su propio ámbito. Nada sale del archivo a menos que hagas export de forma explícita, y nada entra a menos que hagas import de forma explícita.

Dos archivos: uno exporta, el otro importa:

index.js
Output
Click Run to see the output here.

add y multiply viven en math.js. Solo se vuelven visibles en main.js gracias al import. Todo lo demás que haya en math.js —funciones auxiliares, constantes, lo que sea— queda fuera del alcance desde afuera.

De aquí salen dos reglas que conviene interiorizar cuanto antes:

  • Los módulos corren en modo estricto automáticamente. No hace falta escribir 'use strict'.
  • El this de nivel superior es undefined, no el objeto global.

Named exports: exporta sobre la marcha

Es la forma más habitual. Basta con anteponer export a cualquier function, class, const o let para que pase a formar parte de la API pública del módulo:

index.js
Output
Click Run to see the output here.

Los nombres entre llaves tienen que coincidir exactamente con los nombres exportados: import { circlearea } fallaría. Si un nombre choca con algo que ya tienes, renómbralo al importar con as:

index.js
Output
Click Run to see the output here.

También puedes listar los exports al final del archivo en vez de hacerlo en línea. A algunos les resulta más cómodo así, porque deja a la vista una especie de "API pública" del módulo:

index.js
Output
Click Run to see the output here.

Ambos estilos hacen lo mismo. Elige uno y mantenlo en todo el proyecto.

Export default en JavaScript: uno por módulo

Un módulo también puede tener un único export default. El export default se usa en archivos que realmente giran en torno a una sola cosa: un componente, una clase, un objeto de configuración:

index.js
Output
Click Run to see the output here.

Tres detalles importantes:

  • No hay llaves alrededor de log al importarlo.
  • El nombre con el que lo importas es el que tú quieras. import shout from './logger.js' funcionaría exactamente igual.
  • Solo se permite un export default por archivo. Si intentas añadir un segundo, el archivo ni siquiera se parsea.

Los named exports y el default export pueden convivir sin problema:

index.js
Output
Click Run to see the output here.

Primero el default, después los named entre llaves. El orden no es negociable.

¿Cuál conviene usar? Los named exports son más fáciles de refactorizar: renombrar algo en todo el proyecto se resuelve con un buscar y reemplazar, porque cada import usa el mismo nombre. Los default son flexibles, pero permiten que cada quien le ponga el nombre que quiera al importarlos, y eso complica hacer grep. La mayoría de las guías de estilo modernas se inclinan por los named exports y dejan el export default para módulos que realmente hacen una sola cosa.

Importar todo, reexportar y efectos secundarios

Hay algunas formas más de import con las que te vas a cruzar.

Para meter todos los named exports dentro de un único objeto namespace:

index.js
Output
Click Run to see the output here.

Reexportar desde otro módulo sin traerlo al ámbito actual:

index.js
Output
Click Run to see the output here.

Ese patrón es el que usan los entry points de las librerías para juntar piezas de distintos archivos internos y exponerlas como una única API pública.

Y por último, un import sin ningún binding — pensado para módulos cuyo único propósito son los efectos secundarios (polyfills, CSS-in-JS, registrar handlers):

index.js
Output
Click Run to see the output here.

El archivo se ejecuta una vez y no se importa nada por nombre.

Los imports son estáticos y vivos

Hay dos características de import que a veces pillan por sorpresa.

Estáticos. Las declaraciones import se resuelven antes de que se ejecute cualquier línea de tu código. No puedes meter una dentro de un if, de una función ni de un try. La ruta tiene que ser un literal de cadena, nunca una variable. Justo eso es lo que permite que las herramientas analicen los imports sin ejecutar el código: bundlers, chequeadores de tipos y tree-shakers dependen de esa garantía.

// Not allowed — SyntaxError.
if (userWantsFancy) {
  import { fancy } from './fancy.js';
}

Si necesitas carga condicional, usa import() (lo vemos a continuación).

Vivas. Un binding importado es una referencia de solo lectura al export original, no una copia congelada. Si el módulo que exporta reasigna el valor, quienes lo importan ven el nuevo valor:

index.js
Output
Click Run to see the output here.

Tampoco puedes reasignar un import desde el lado del consumidor: hacer count = 5 en main.js lanzaría un error. Los imports son vistas de solo lectura.

import() dinámico: cargar módulos bajo demanda

Cuando necesitas decidir en tiempo de ejecución si cargar un módulo (funcionalidades pesadas, code splitting por rutas, polyfills condicionales), usa import() como si fuera una función. Devuelve una promesa que se resuelve con las exportaciones del módulo:

index.js
Output
Click Run to see the output here.

Como es una llamada a función normal, puedes:

  • Usar await dentro de una función async.
  • Pasar una variable como ruta.
  • Meterlo dentro de un if o un try/catch.

Desestructurar el objeto que devuelve la promesa funciona igual que con un import estático:

index.js
Output
Click Run to see the output here.

default es la clave del export por defecto cuando lo destructuras. Renómbralo como quieras.

Los casos de uso reales son el code-splitting (cargar la librería de gráficos solo cuando el usuario pulsa "ver gráfico"), polyfills condicionales según las capacidades del navegador y plugins que se descubren en tiempo de ejecución.

Cómo ejecutar módulos ES: navegador y Node.js

La sintaxis es la misma en todas partes; lo que cambia es cómo el runtime localiza y carga el archivo.

En el navegador, marca el script de entrada como módulo:

<script type="module" src="./main.js"></script>

Con type="module", el navegador respeta import/export, ejecuta el código en modo estricto y pospone la ejecución hasta que el HTML esté parseado. Las rutas deben ser relativas (./, ../) o URLs absolutas — los especificadores simples como import 'lodash' no funcionan sin un import map o un bundler.

En Node, hay dos formas de activarlo:

  • Nombrar el archivo con extensión .mjs, o
  • Poner "type": "module" en el package.json más cercano, lo que convierte a cada archivo .js en un módulo.

Node además exige rutas completas con extensión: import './utils.js', no import './utils'.

// package.json
{
  "type": "module",
  "main": "./index.js"
}

Ambos entornos requieren extensiones explícitas cuando usas ESM nativo. Los bundlers (Vite, webpack, esbuild) resuelven las rutas sin extensión por ti en desarrollo — cómodo, sí, pero depender de eso significa que tu código fuente no correrá sin el paso de build.

Errores comunes al usar módulos ES

Hay varios detalles que suelen hacer tropezar a la gente:

  • Olvidar type="module" en el navegador. Sin eso, el <script> se ejecuta como script clásico y import lanza un error de sintaxis.
  • Omitir la extensión del archivo en Node. import './utils' falla; import './utils.js' funciona. Los bundlers lo disimulan, los runtimes nativos no.
  • Esperar que existan __dirname o require en un módulo ES. Esos son exclusivos de CommonJS. En ESM tienes que usar import.meta.url y convertirlo cuando necesites una ruta.
  • Imports circulares que acceden a valores antes de tiempo. Que dos módulos se importen entre sí es legal, pero leer un export que todavía no se ha asignado te devuelve undefined. Estructura el código para que el ciclo no se toque durante la inicialización, o sepáralo.
  • Intentar hacer un import condicional. La sentencia import estática no lo permite. Para cualquier cosa que dependa del runtime, usa import() dinámico.

Siguiente: CommonJS vs ESM

Los módulos ES son el estándar, pero todavía hay muchísimo código Node en circulación que usa CommonJS — require, module.exports y un conjunto distinto de reglas sobre cuándo se ejecuta el código. Conocer ambos, y cómo interoperan, es el tema de la siguiente página.

Preguntas frecuentes

¿Cómo se usan los módulos ES en JavaScript?

Exporta valores desde un archivo con export o export default y luego tráelos a otro con import. En el navegador, carga el archivo de entrada con <script type="module" src="main.js"></script>. En Node, usa la extensión .mjs o añade "type": "module" en tu package.json.

¿Qué diferencia hay entre export default y named exports?

Un módulo puede tener varios named exports (export function foo() {}), pero solo un export por defecto (export default ...). Los named exports se importan con el mismo nombre entre llaves: import { foo } from './x.js'. El default, en cambio, se puede importar con el nombre que quieras: import loQueSea from './x.js'.

¿Qué es el import() dinámico en JavaScript?

import() llamado como función devuelve una promesa que resuelve con los exports del módulo. A diferencia del import estático, se ejecuta en el momento de la llamada, así que puedes cargar código de forma condicional o bajo demanda. Es la base del code-splitting y del lazy loading.

¿Hace falta poner la extensión del archivo en los imports?

En módulos ES nativos —tanto en el navegador como en el loader ESM de Node— sí. Tienes que escribir ./utils.js, no ./utils. Bundlers como Vite o webpack son más permisivos y resuelven rutas sin extensión, pero depender de eso hace que tu código deje de ser portable.

Aprende a programar con Coddy

COMENZAR