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:
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
thisde nivel superior esundefined, 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:
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:
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:
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:
Tres detalles importantes:
- No hay llaves alrededor de
logal 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 defaultpor archivo. Si intentas añadir un segundo, el archivo ni siquiera se parsea.
Los named exports y el default export pueden convivir sin problema:
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:
Reexportar desde otro módulo sin traerlo al ámbito actual:
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):
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:
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:
Como es una llamada a función normal, puedes:
- Usar
awaitdentro de una funciónasync. - Pasar una variable como ruta.
- Meterlo dentro de un
ifo untry/catch.
Desestructurar el objeto que devuelve la promesa funciona igual que con un import estático:
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 elpackage.jsonmás cercano, lo que convierte a cada archivo.jsen 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 yimportlanza 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
__dirnameorequireen un módulo ES. Esos son exclusivos de CommonJS. En ESM tienes que usarimport.meta.urly 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
importcondicional. La sentenciaimportestática no lo permite. Para cualquier cosa que dependa del runtime, usaimport()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.