Módulo 13: Módulos
Objetivos del módulo
- Entender qué problemas causan los scripts que comparten el ámbito global
- Conocer la evolución de los sistemas de módulos: IIFE, CommonJS, AMD y módulos ES
- Exportar e importar con todas las variantes: nombrado, por defecto, listas, renombrado, namespace y re-exportación
- Usar módulos en el navegador y saber por qué necesitan un servidor
- Aprovechar
import.meta, elimport()dinámico y los import maps - Importar JSON con import attributes (ES2025)
- Trabajar con módulos en Node.js y su convivencia con CommonJS, incluido
require(esm) - Reconocer las dependencias circulares
- Organizar un proyecto real en módulos (utilidades, servicios y componentes)
1. El problema de los scripts globales
Hasta ahora casi todo tu código cabía en un fichero. En una aplicación real tendrás decenas o cientos: utilidades, acceso a datos, componentes de la interfaz… ¿Cómo se repartía el código antes de que existieran los módulos?
<!-- index.html: la forma "antigua" -->
<script src="utilidades.js"></script>
<script src="carrito.js"></script>
<script src="app.js"></script>
// utilidades.js
var total = 0; // ⚠ variable global
function formatear(precio) {
return precio.toFixed(2) + ' €';
}
// carrito.js
var total = 3; // ⚠ ¡pisa el "total" de utilidades.js sin avisar!
function formatear(producto) {
return producto.nombre; // ⚠ ¡y redefine formatear()!
}
Todos los scripts clásicos comparten un único ámbito global (window en el navegador). Eso provoca:
| Problema | Qué ocurre |
|---|---|
| Colisiones de nombres | Dos ficheros declaran total o formatear y el último gana, en silencio |
| Dependencias implícitas | app.js usa funciones de carrito.js, pero nada lo indica: solo lo sabes leyendo el código |
| Orden frágil | Si cambias el orden de las etiquetas script, todo se rompe |
| Nada es privado | Cualquier script puede leer y modificar las variables de cualquier otro |
| Código muerto | No sabes qué funciones se usan, así que nadie se atreve a borrar nada |
📘 Concepto: Un módulo es un fichero con su propio ámbito: lo que declaras dentro es privado salvo que lo exportes explícitamente, y para usar algo de otro módulo tienes que importarlo explícitamente. Es como un piso con su puerta: dentro haces lo que quieras, y solo sale por la puerta lo que tú decides.
2. Un poco de historia
JavaScript nació sin módulos (1995) y la comunidad fue inventando soluciones hasta que llegó el estándar.
| Sistema | Año aprox. | Dónde | Sintaxis | Estado hoy |
|---|---|---|---|---|
| IIFE (patrón módulo) | ~2005 | Navegador | (function () { ... })() | Solo en código antiguo |
| CommonJS (CJS) | 2009 | Node.js | require() / module.exports | Muy extendido en Node, en retirada |
| AMD (RequireJS) | 2011 | Navegador | define([...], function () {}) | Obsoleto |
| UMD | ~2013 | Ambos | Mezcla de AMD y CJS | Obsoleto |
| Módulos ES (ESM) | 2015 (ES6) | Todos | import / export | El estándar actual |
IIFE: el “patrón módulo”
Una IIFE (Immediately Invoked Function Expression) es una función que se ejecuta nada más definirse. Como las funciones crean ámbito (módulo 05), sus variables quedan privadas y solo se expone lo que se devuelve:
const Contador = (function () {
let cuenta = 0; // privada: nadie puede tocarla desde fuera
return {
incrementar() {
cuenta++;
},
valor() {
return cuenta;
},
};
})();
Contador.incrementar();
console.log(Contador.valor()); // 1
console.log(Contador.cuenta); // undefined
Funcionaba, pero las dependencias seguían siendo globales (Contador) y el orden de los script seguía importando.
CommonJS: los módulos de Node.js
// matematicas.cjs
function sumar(a, b) {
return a + b;
}
module.exports = { sumar };
// app.cjs
const { sumar } = require('./matematicas.cjs');
console.log(sumar(2, 3)); // 5
require es síncrono (lee el fichero del disco y lo ejecuta en ese momento), algo aceptable en un servidor pero inviable en un navegador, donde cada fichero es una petición de red. Por eso surgió AMD (define(['dep'], function (dep) { ... })), con carga asíncrona, pero su sintaxis era farragosa.
Módulos ES: el estándar
En 2015 el propio lenguaje incorporó import y export. Hoy los entienden todos los navegadores, Node.js, Deno y Bun, y las herramientas como Vite (módulo 20) trabajan sobre ellos. Es lo que usarás siempre en código nuevo.
💡 Consejo: Seguirás encontrando
require()en tutoriales y proyectos de Node.js antiguos. Saber leerlo es útil; escribirlo en código nuevo, no.
3. Exportar
Para que algo de un módulo sea visible desde fuera hay que exportarlo. Hay dos tipos de exportación: nombradas (tantas como quieras) y por defecto (como mucho una por módulo).
Exportaciones nombradas
Pon export delante de la declaración:
// matematicas.js
export const PI = 3.14159;
export function sumar(a, b) {
return a + b;
}
export class Calculadora {
#memoria = 0;
guardar(valor) {
this.#memoria = valor;
}
}
// Esto NO se exporta: es privado del módulo
function redondear(n) {
return Math.round(n * 100) / 100;
}
Exportar una lista al final
Muchos equipos prefieren declarar normalmente y exportar todo junto al final del fichero, para ver de un vistazo la “interfaz pública” del módulo:
// formato.js
const IVA = 0.21;
function precio(valor) {
return `${valor.toFixed(2)} €`;
}
function precioConIva(valor) {
return precio(valor * (1 + IVA));
}
export { IVA, precio, precioConIva };
Renombrar con as
function calcularPrecioFinalConDescuento(precio, descuento) {
return precio * (1 - descuento);
}
export { calcularPrecioFinalConDescuento as aplicarDescuento };
Exportación por defecto
export default marca el valor principal del módulo. Puede ser anónimo:
// saludar.js
export default function (nombre) {
return `Hola, ${nombre}`;
}
// Usuario.js: un módulo cuya razón de ser es una clase
export default class Usuario {
constructor(nombre) {
this.nombre = nombre;
}
}
También puedes exportar por defecto una expresión cualquiera: export default { modo: 'oscuro', idioma: 'es' };.
Un módulo puede tener a la vez una exportación por defecto y varias nombradas:
// api.js
export const URL_BASE = 'https://jsonplaceholder.typicode.com';
export function construirUrl(ruta) {
return `${URL_BASE}${ruta}`;
}
export default async function obtener(ruta) {
const respuesta = await fetch(construirUrl(ruta));
return respuesta.json();
}
💡 Consejo: ¿Nombrado o por defecto? Muchas guías profesionales recomiendan preferir las exportaciones nombradas: el nombre es el mismo en todo el proyecto (más fácil de buscar), el editor autocompleta mejor y renombrar es más seguro. Con
default, cada fichero que importa puede llamarlo como quiera, y eso acaba siendo confuso. Reservadefaultpara módulos con un único elemento muy claro (un componente, una clase).
4. Importar
Importación nombrada
Los nombres van entre llaves y deben coincidir con los exportados:
import { PI, sumar } from './matematicas.js';
console.log(sumar(PI, 1)); // 4.14159
Renombrar al importar
Útil para evitar choques de nombres o acortar:
import { precio as formatearPrecio } from './formato.js';
import { precio as precioDeCatalogo } from './catalogo.js';
Importación por defecto
Sin llaves, y con el nombre que tú elijas:
import saludar from './saludar.js';
import Usuario from './Usuario.js';
import obtener, { URL_BASE } from './api.js'; // por defecto + nombradas a la vez
Importación de espacio de nombres (namespace)
import * as nombre agrupa todas las exportaciones en un objeto:
import * as mates from './matematicas.js';
console.log(mates.PI); // 3.14159
console.log(mates.sumar(2, 3)); // 5
// La exportación por defecto, si la hay, está en mates.default
Importar solo por sus efectos
Algunos módulos no exportan nada: se importan para que se ejecuten (registrar algo, aplicar un polyfill…):
import './polyfills.js';
Reglas importantes de import
| Regla | Detalle |
|---|---|
| Las importaciones estáticas van en el nivel superior | No puedes poner import { x } from '...' dentro de un if o una función (para eso existe import(), sección 8) |
| Se elevan (hoisting) | Se procesan antes de ejecutar el módulo, aunque las escribas al final |
| La ruta es una cadena literal | No puedes construirla con variables en un import estático |
| La extensión es obligatoria en navegador y Node | './formato.js', no './formato' (las herramientas como Vite sí lo permiten) |
Rutas con ./, ../, / o URL completa | 'formato.js' sin ./ es un “especificador desnudo” (bare specifier) y en el navegador falla sin un import map |
| Cada módulo se ejecuta una sola vez | Aunque lo importen diez ficheros, todos reciben la misma instancia |
Las importaciones son enlaces vivos y de solo lectura
📘 Concepto: Lo que importas no es una copia del valor, sino un enlace vivo (live binding) a la variable del módulo original. Si el módulo la cambia, tú ves el cambio. Pero tú no puedes reasignarla desde fuera.
// contador.js
export let cuenta = 0;
export function incrementar() {
cuenta++;
}
// main.js
import { cuenta, incrementar } from './contador.js';
console.log(cuenta); // 0
incrementar();
incrementar();
console.log(cuenta); // 2 ← ¡ve el valor actualizado!
cuenta = 10; // TypeError: Assignment to constant variable.
5. Re-exportar y barrel files
Un módulo puede reenviar exportaciones de otros sin usarlas él mismo, con export ... from:
// utils/index.js
export { precio, precioConIva } from './formato.js'; // re-exporta nombradas
export { sumar as suma } from './matematicas.js'; // re-exporta renombrando
export * from './fechas.js'; // re-exporta todas las nombradas (no la default)
export * as validacion from './validacion.js'; // agrupa en un namespace
export { default as Usuario } from './Usuario.js'; // convierte una default en nombrada
A un fichero index.js que solo re-exporta se le llama barrel file (“barril”): ofrece un único punto de entrada a una carpeta.
// Sin barrel: una línea por fichero
import { precio } from './utils/formato.js';
import { sumar } from './utils/matematicas.js';
// Con barrel: una sola importación
import { precio, suma, validacion } from './utils/index.js';
⚠️ Importante: Los barrel files son cómodos, pero tienen coste: al importar una sola función del barril, el navegador (sin herramienta de build) descarga y ejecuta todos los módulos que este re-exporta. En proyectos grandes pueden ralentizar la carga y los tests. Úsalos para agrupar la API pública de una carpeta, no para re-exportar todo el proyecto en cascada.
6. Módulos en el navegador
type="module"
Para que el navegador trate un fichero como módulo se usa type="module":
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Mi app con módulos</title>
<script type="module" src="js/main.js"></script>
</head>
<body>
<h1>Hola, módulos</h1>
</body>
</html>
También puedes escribir el módulo en línea: <script type="module">import { sumar } from './js/matematicas.js'; ...</script>. Basta con un único punto de entrada: el navegador descarga solo el resto del árbol de dependencias siguiendo los import.
Diferencias con un script clásico
| Característica | <script> clásico | <script type="module"> |
|---|---|---|
| Ámbito | Global (var y funciones van a window) | Propio del módulo |
| Modo estricto | Solo con 'use strict' | Siempre |
| Carga | Bloquea el análisis del HTML (salvo defer/async) | Diferido por defecto (como defer) |
this en el nivel superior | window | undefined |
| Ejecución | Cada vez que aparece la etiqueta | Una sola vez, aunque se importe muchas veces |
import / export | No permitidos | Permitidos |
| Top-level await | No | Sí |
| Origen cruzado | Se carga sin restricciones | Sigue las reglas CORS |
file:// | Funciona | No funciona |
Consecuencias prácticas:
- Al ser diferido, el módulo se ejecuta cuando el HTML ya está analizado: puedes poner el
scripten elheady acceder al DOM sin esperar aDOMContentLoaded(lo verás en el módulo 14). - Al tener ámbito propio, una función declarada en un módulo no está en
window. Si unonclick="miFuncion()"en el HTML deja de funcionar al pasar a módulos, es por esto (y es una razón más para usaraddEventListener, módulo 15). - El modo estricto convierte en errores cosas que antes fallaban en silencio (asignar a una variable no declarada, por ejemplo).
Por qué necesitas un servidor
Si abres el index.html con doble clic (dirección file:///C:/...), verás en la consola un error parecido a:
Access to script at 'file:///C:/proyecto/js/main.js' from origin 'null' has been blocked by CORS policy
Los módulos se cargan con las reglas de CORS (las verás en el módulo 17) y las páginas abiertas desde file:// no tienen un origen válido. La solución es servir la carpeta con un servidor local:
# Opción 1: extensión "Live Server" de VS Code (botón "Go Live" en la barra de estado)
# Opción 2: con Node.js, sin instalar nada (desde la carpeta del proyecto)
npx serve .
# Abre la dirección que indica (por defecto http://localhost:3000)
# Opción 3: si tienes Python
python -m http.server 8000
⚠️ Importante: El servidor debe enviar los
.jscon un tipo MIME de JavaScript (text/javascript). Todos los servidores de desarrollo citados lo hacen. Si ves “Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of…”, revisa la configuración del servidor.
nomodule: compatibilidad con navegadores antiguos
<script nomodule src="antiguo.js"></script> solo lo ejecutan los navegadores que no entienden módulos. Hoy todos los navegadores actuales los soportan, así que apenas se usa, pero lo verás en proyectos antiguos.
7. import.meta
import.meta es un objeto con información sobre el módulo actual. Solo existe dentro de módulos.
import.meta.url
La URL completa del módulo. Sirve para construir rutas relativas al propio fichero (no a la página que lo carga):
// En el navegador, si este módulo está en https://misitio.es/js/componentes/mapa.js
console.log(import.meta.url); // https://misitio.es/js/componentes/mapa.js
const urlIcono = new URL('../img/marcador.svg', import.meta.url);
console.log(urlIcono.href); // https://misitio.es/js/img/marcador.svg
import.meta.resolve()
Resuelve un especificador igual que lo haría import (teniendo en cuenta el import map) y devuelve la URL como cadena, sin cargar el módulo:
console.log(import.meta.resolve('./utilidades.js'));
// https://misitio.es/js/componentes/utilidades.js
En Node.js: dirname y filename
En CommonJS existían las variables __dirname y __filename. En los módulos ES de Node no existen; en su lugar tienes:
// C:\proyecto\src\config.mjs
console.log(import.meta.url); // file:///C:/proyecto/src/config.mjs
console.log(import.meta.filename); // C:\proyecto\src\config.mjs
console.log(import.meta.dirname); // C:\proyecto\src
console.log(import.meta.main); // true si es el fichero que se ejecutó con "node config.mjs"
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
const texto = await readFile(join(import.meta.dirname, 'datos.txt'), 'utf8');
💡 Consejo:
import.meta.filenameyimport.meta.dirnamesolo existen para módulos cargados desde el disco en Node.js (no en el navegador).import.meta.maines una incorporación reciente de Node.js (disponible en Node 24); compruébalo antes de usarlo en versiones antiguas.
8. import() dinámico: carga bajo demanda
Los import estáticos se resuelven antes de ejecutar el código, siempre. A veces quieres cargar un módulo solo cuando haga falta: un editor que solo se usa si el usuario pulsa “Editar”, una librería de gráficos pesada, un idioma concreto…
📘 Concepto:
import(ruta)es una expresión (no una declaración) que se puede usar en cualquier sitio y devuelve una promesa que se cumple con el objeto namespace del módulo.
const boton = document.querySelector('#ver-grafico');
boton.addEventListener('click', async () => {
// El módulo (y sus dependencias) solo se descargan la primera vez que se pulsa
const { dibujarGrafico } = await import('./graficos.js');
dibujarGrafico(document.querySelector('#lienzo'));
});
Más usos:
// Ruta calculada: cargar el idioma del usuario
const idioma = navigator.language.startsWith('es') ? 'es' : 'en';
const { default: textos } = await import(`./i18n/${idioma}.js`);
console.log(textos.bienvenida);
// Carga condicional
if (!('IntersectionObserver' in window)) {
await import('./polyfills/intersection-observer.js');
}
// Varios módulos en paralelo
const [mapa, rutas] = await Promise.all([import('./mapa.js'), import('./rutas.js')]);
// Con gestión de errores (sin conexión, fichero inexistente...)
try {
const modulo = await import('./opcional.js');
modulo.iniciar();
} catch (error) {
console.error('No se pudo cargar el módulo opcional', error);
}
💡 Consejo:
import()también funciona en scripts clásicos y en CommonJS, no solo en módulos. Es la puerta de entrada a los módulos ES desde código antiguo. Las herramientas de build como Vite usan cadaimport()para dividir el código en trozos (code splitting) que se descargan bajo demanda.
import estático | import() dinámico | |
|---|---|---|
| Dónde | Solo en el nivel superior | En cualquier sitio |
| Ruta | Cadena literal | Cualquier expresión |
| Cuándo carga | Siempre, antes de ejecutar | Cuando se evalúa la expresión |
| Devuelve | Enlaces a las exportaciones | Una promesa con el namespace |
| Análisis estático | Sí (las herramientas saben qué se usa) | Limitado |
9. Import maps en el navegador
En Node o con Vite escribes import { debounce } from 'lodash-es' (un especificador desnudo, sin ruta) y la herramienta sabe dónde buscarlo. El navegador, por sí solo, no: necesita una URL. Un import map le dice al navegador qué URL corresponde a cada nombre.
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Import maps</title>
<!-- El import map debe ir ANTES de cualquier módulo que lo use -->
<script type="importmap">
{
"imports": {
"lodash-es": "https://cdn.jsdelivr.net/npm/lodash-es@4.18.1/+esm",
"@utils/": "/js/utils/"
}
}
</script>
<script type="module" src="/js/main.js"></script>
</head>
<body></body>
</html>
// js/main.js
import { capitalize } from 'lodash-es'; // → la URL de jsDelivr
import { precio } from '@utils/formato.js'; // → /js/utils/formato.js (la "/" final mapea prefijos)
console.log(capitalize('hola')); // Hola
console.log(precio(9.5)); // 9.50 €
- Las claves que acaban en
/mapean prefijos:"@utils/"sustituye el principio de cualquier ruta que empiece así. - La sección opcional
"scopes"permite mapear distinto según qué carpeta importe (útil con varias versiones de una librería). +esmes un servicio de jsDelivr que entrega cualquier paquete de npm como módulo ES.
💡 Consejo: Los import maps son perfectos para prototipos y proyectos pequeños sin herramienta de build. En proyectos medianos o grandes usarás npm + Vite (módulo 20), que resuelven los paquetes, empaquetan y optimizan por ti.
10. Import attributes y módulos JSON (ES2025)
Desde ES2025 puedes importar un fichero JSON directamente como módulo, indicando su tipo con la cláusula with:
{
"nombre": "Tienda Demo",
"moneda": "EUR",
"categorias": ["libros", "música", "juegos"]
}
// main.js
import config from './config.json' with { type: 'json' };
console.log(config.nombre); // Tienda Demo
console.log(config.categorias.length); // 3
// También con import() dinámico: el segundo argumento lleva los atributos
const { default: traducciones } = await import('./i18n/es.json', { with: { type: 'json' } });
📘 Concepto: Un import attribute es información extra que acompaña a la importación.
type: 'json'es una medida de seguridad: el navegador comprueba que el servidor realmente envía JSON (tipo MIMEapplication/json). Sin ella, un servidor comprometido podría devolver JavaScript ejecutable donde tú esperabas datos.
Detalles a tener en cuenta:
- Un módulo JSON solo tiene exportación por defecto (el objeto completo):
import { nombre } from './config.json' with { type: 'json' }no funciona. - Si olvidas
with { type: 'json' }, falla. En Node.js el error esTypeError [ERR_IMPORT_ATTRIBUTE_MISSING]: Module ".../config.json" needs an import attribute of "type: json". - El objeto importado es compartido por todos los módulos que lo importan: si uno lo modifica, los demás ven el cambio. Trátalo como solo lectura.
- Lo soportan las versiones actuales de Chrome, Edge, Safari, Firefox y Node.js.
- Quizá veas
assert { type: 'json' }: es la sintaxis antigua de una versión previa de la propuesta. Ya no se usa; escribe siemprewith.
💡 Consejo: Hay otros tipos en camino, como
with { type: 'css' }para importar hojas de estilo (CSS module scripts), pero no forman parte de ECMAScript y no todos los navegadores los soportan. Comprueba su compatibilidad antes de usarlos.
11. Módulos en Node.js
Node.js nació con CommonJS y añadió los módulos ES más tarde, así que tiene que decidir qué sistema usa cada fichero:
| Fichero | Se trata como |
|---|---|
.mjs | Siempre módulo ES |
.cjs | Siempre CommonJS |
.js con "type": "module" en el package.json más cercano | Módulo ES |
.js con "type": "commonjs" (o sin "type") | CommonJS… salvo que contenga sintaxis import/export, en cuyo caso Node lo detecta y lo reinterpreta como ESM mostrando un aviso |
Lo recomendable en proyectos nuevos es declararlo explícitamente:
{
"name": "mi-proyecto",
"version": "1.0.0",
"type": "module"
}
⚠️ Importante: Si no pones
"type", Node 24 ejecutará igualmente un.jsconimport, pero mostrará el aviso[MODULE_TYPELESS_PACKAGE_JSON] Warning: ... Reparsing as ES module because module syntax was detected. This incurs a performance overhead.Añade"type": "module"y desaparece.
Diferencias de los módulos ES en Node
- Las rutas relativas necesitan la extensión:
import { x } from './util.js'. Sin ella obtendrásERR_MODULE_NOT_FOUND. - No existen
require,module,exports,__dirnameni__filename(usaimport.meta.dirnameyimport.meta.filename). - Los módulos internos se importan con el prefijo
node:(import { readFile } from 'node:fs/promises'), que deja claro que no es un paquete de npm. - Puedes usar top-level await.
Importar CommonJS desde un módulo ES
Funciona siempre: module.exports se recibe como exportación por defecto.
// lib.cjs
exports.sumar = (a, b) => a + b;
exports.nombre = 'cjs';
// main.mjs
import lib from './lib.cjs'; // module.exports completo
import { sumar } from './lib.cjs'; // ✅ funciona: Node detecta "exports.sumar = ..."
console.log(lib.nombre, sumar(2, 3)); // cjs 5
Node intenta detectar las exportaciones nombradas analizando el código CommonJS, pero solo reconoce patrones sencillos (como exports.x = ...). Si el módulo hace module.exports = { sumar: (a, b) => a + b }, la importación nombrada falla con SyntaxError: Named export 'sumar' not found. The requested module './lib.cjs' is a CommonJS module, which may not support all module.exports as named exports. y tendrás que usar la exportación por defecto: import lib from './lib.cjs'; const { sumar } = lib;.
Importar módulos ES desde CommonJS: require(esm)
Durante años, desde CommonJS solo se podía cargar un módulo ES con import() dinámico (asíncrono). Las versiones modernas de Node permiten hacerlo con require(): llegó sin bandera en Node 22.12 y 20.19, y está marcado como estable desde Node 25.4 y 24.15.
// util.mjs
export const PI = 3.14;
export default function saludar(nombre) {
return `Hola, ${nombre}`;
}
// app.cjs
const util = require('./util.mjs'); // ✅ Node 24 actual
console.log(util.PI); // 3.14
console.log(util.default('Ana')); // Hola, Ana
La única limitación importante: no funciona si el módulo ES (o alguna de sus dependencias) usa top-level await, porque require es síncrono. En ese caso obtendrás Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await. Use import() instead.
💡 Consejo: Si necesitas
requiredentro de un módulo ES (por ejemplo, para un paquete muy antiguo), puedes crearlo:import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);.
| Quiero… | Cómo |
|---|---|
| Usar CJS desde ESM | import lib from './lib.cjs' (y nombradas si Node las detecta) |
| Usar ESM desde CJS | require('./mod.mjs') en Node moderno, o await import('./mod.mjs') |
require dentro de ESM | createRequire(import.meta.url) |
| Rutas del fichero actual en ESM | import.meta.dirname, import.meta.filename |
12. Dependencias circulares
Hay una dependencia circular cuando el módulo A importa B y B importa A (directa o indirectamente). Los módulos ES lo permiten, pero el orden de evaluación puede sorprenderte:
// a.mjs
import { b } from './b.mjs';
export const a = 'A';
console.log('a.mjs ve b =', b);
// b.mjs
import { a } from './a.mjs';
export const b = 'B';
try {
console.log('b.mjs ve a =', a);
} catch (error) {
console.log('b.mjs:', error.name, error.message);
}
Al ejecutar node a.mjs:
b.mjs: ReferenceError Cannot access 'a' before initialization
a.mjs ve b = B
Node empieza por a.mjs, ve que necesita b.mjs y lo evalúa primero; en ese momento a todavía no se ha inicializado (está en la “zona muerta temporal” que viste con let y const).
💡 Consejo: Si te encuentras un ciclo, normalmente indica un problema de diseño. Soluciones habituales: mover el código compartido a un tercer módulo del que dependan ambos, o acceder al valor dentro de una función que se llame más tarde (cuando ya está inicializado), no en el nivel superior.
13. Ejemplo completo: organizar un proyecto en módulos
Vamos a construir un directorio de usuarios que consume la API pública y gratuita JSONPlaceholder (https://jsonplaceholder.typicode.com), que devuelve datos de prueba (usuarios, publicaciones, comentarios) sin registro. El código se reparte en capas con responsabilidades claras:
directorio/
├── index.html
└── js/
├── main.js ← punto de entrada: conecta las piezas
├── config.js ← constantes de configuración
├── utils/
│ ├── index.js ← barrel: API pública de utils
│ ├── dom.js ← crear elementos
│ └── formato.js ← formatear textos
├── servicios/
│ └── usuarios.js ← acceso a la API (sin DOM)
└── componentes/
├── tarjetaUsuario.js ← pinta una tarjeta
└── detalleUsuario.js ← se carga con import() al pedir el detalle
flowchart TD
main["main.js"] --> servicios["servicios/usuarios.js"]
main --> tarjeta["componentes/tarjetaUsuario.js"]
main -.->|"import() bajo demanda"| detalle["componentes/detalleUsuario.js"]
servicios --> config["config.js"]
tarjeta --> utils["utils/index.js"]
detalle --> utils
detalle --> servicios
utils --> dom["utils/dom.js"]
utils --> formato["utils/formato.js"]
📘 Concepto: Regla de oro de la organización: las dependencias van en una sola dirección. Los
utilsno conocen a nadie; losservicioshablan con la API pero no tocan el DOM; loscomponentespintan pero no saben de dónde vienen los datos;main.jslo conecta todo.
index.html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Directorio de usuarios</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 60rem; margin: 2rem auto; padding: 0 1rem; }
#lista { display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: 1rem; }
.tarjeta { border: 1px solid #ccc; border-radius: 0.5rem; padding: 1rem; }
.tarjeta h2 { margin-top: 0; font-size: 1.1rem; }
.detalle { margin-top: 0.5rem; font-size: 0.9rem; }
</style>
<script type="module" src="js/main.js"></script>
</head>
<body>
<h1>Directorio de usuarios</h1>
<p id="estado">Cargando...</p>
<section id="lista"></section>
</body>
</html>
js/config.js
export const URL_API = 'https://jsonplaceholder.typicode.com';
export const TIEMPO_MAXIMO_MS = 8000;
js/utils/formato.js
export function iniciales(nombreCompleto) {
return nombreCompleto
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((palabra) => palabra[0].toUpperCase())
.join('');
}
export function pluralizar(cantidad, singular, plural = `${singular}s`) {
return `${cantidad} ${cantidad === 1 ? singular : plural}`;
}
js/utils/dom.js
// Crea un elemento con atributos y contenido de forma segura (textContent, nunca innerHTML)
export function crearElemento(etiqueta, { clase, texto, ...atributos } = {}, ...hijos) {
const elemento = document.createElement(etiqueta);
if (clase) elemento.className = clase;
if (texto) elemento.textContent = texto;
for (const [nombre, valor] of Object.entries(atributos)) {
elemento.setAttribute(nombre, valor);
}
elemento.append(...hijos);
return elemento;
}
js/utils/index.js (barrel)
export { crearElemento } from './dom.js';
export * from './formato.js';
js/servicios/usuarios.js
import { URL_API, TIEMPO_MAXIMO_MS } from '../config.js';
async function pedir(ruta) {
const respuesta = await fetch(`${URL_API}${ruta}`, {
signal: AbortSignal.timeout(TIEMPO_MAXIMO_MS),
});
if (!respuesta.ok) {
throw new Error(`Error ${respuesta.status} al pedir ${ruta}`);
}
return respuesta.json();
}
export function obtenerUsuarios() {
return pedir('/users');
}
export function obtenerPublicaciones(idUsuario) {
return pedir(`/posts?userId=${idUsuario}`);
}
js/componentes/tarjetaUsuario.js
import { crearElemento, iniciales } from '../utils/index.js';
export function crearTarjetaUsuario(usuario, { alPedirDetalle }) {
const boton = crearElemento('button', { texto: 'Ver publicaciones', type: 'button' });
boton.addEventListener('click', () => alPedirDetalle(usuario, tarjeta), { once: true });
const tarjeta = crearElemento(
'article',
{ clase: 'tarjeta' },
crearElemento('h2', { texto: `${iniciales(usuario.name)} · ${usuario.name}` }),
crearElemento('p', { texto: `✉ ${usuario.email}` }),
crearElemento('p', { texto: `🏢 ${usuario.company.name} (${usuario.address.city})` }),
boton,
);
return tarjeta;
}
js/componentes/detalleUsuario.js (solo se descarga cuando se necesita)
import { obtenerPublicaciones } from '../servicios/usuarios.js';
import { crearElemento, pluralizar } from '../utils/index.js';
export async function mostrarDetalle(usuario, contenedor) {
const detalle = crearElemento('div', { clase: 'detalle', texto: 'Cargando publicaciones...' });
contenedor.append(detalle);
const publicaciones = await obtenerPublicaciones(usuario.id);
const lista = crearElemento('ul');
for (const publicacion of publicaciones.slice(0, 3)) {
lista.append(crearElemento('li', { texto: publicacion.title }));
}
detalle.replaceChildren(
crearElemento('strong', { texto: pluralizar(publicaciones.length, 'publicación', 'publicaciones') }),
lista,
);
}
js/main.js
import { obtenerUsuarios } from './servicios/usuarios.js';
import { crearTarjetaUsuario } from './componentes/tarjetaUsuario.js';
import { pluralizar } from './utils/index.js';
const estado = document.querySelector('#estado');
const lista = document.querySelector('#lista');
async function alPedirDetalle(usuario, tarjeta) {
try {
// Carga bajo demanda: el módulo de detalle no se descarga hasta el primer clic
const { mostrarDetalle } = await import('./componentes/detalleUsuario.js');
await mostrarDetalle(usuario, tarjeta);
} catch (error) {
tarjeta.append(`No se pudo cargar el detalle: ${error.message}`);
}
}
try {
const usuarios = await obtenerUsuarios(); // top-level await: estamos en un módulo
lista.append(...usuarios.map((u) => crearTarjetaUsuario(u, { alPedirDetalle })));
estado.textContent = `${pluralizar(usuarios.length, 'usuario')} encontrados`;
} catch (error) {
estado.textContent = `No se pudieron cargar los usuarios: ${error.message}`;
}
Sirve la carpeta con npx serve . (o Live Server), abre la página y verás diez tarjetas. Abre la pestaña Red (Network) de las DevTools: al cargar solo se descargan los módulos estáticos; detalleUsuario.js aparece la primera vez que pulsas “Ver publicaciones”.
✏️ Ejercicio: Añade un módulo
componentes/buscador.jsque exportecrearBuscador(alBuscar)y devuelva un campo de texto. Úsalo enmain.jspara filtrar las tarjetas por nombre. ¿Qué capa debería hacer el filtrado: el componente, el servicio omain.js?
14. Ejercicios
Ejercicio 1: De global a módulos
Toma este código de un solo fichero y repártelo en tres módulos (precios.js, carrito.js y main.js) con exportaciones nombradas, de forma que IVA y redondear queden privados de precios.js:
const IVA = 0.21;
function redondear(n) { return Math.round(n * 100) / 100; }
function conIva(precio) { return redondear(precio * (1 + IVA)); }
const carrito = [];
function anadir(producto) { carrito.push(producto); }
function total() { return carrito.reduce((suma, p) => suma + conIva(p.precio), 0); }
anadir({ nombre: 'Libro', precio: 20 });
console.log(total());
Ejercicio 2: Todas las formas de importar
Crea geometria.js con exportaciones nombradas areaCirculo, areaRectangulo y PI, y una exportación por defecto describir(figura). En main.js impórtalo de cuatro formas distintas: nombradas, renombrando una con as, la de por defecto junto con una nombrada, y como namespace. Ejecútalo con Node (.mjs o "type": "module").
Ejercicio 3: Un barrel para utils
Crea una carpeta utils con cadenas.js (capitalizar, truncar), numeros.js (entre, aleatorio) y fechas.js (hoyISO). Escribe utils/index.js que re-exporte todo, con numeros.js agrupado en un namespace llamado numeros. Importa desde main.js usando una sola línea.
Ejercicio 4: Carga bajo demanda
Haz una página con dos botones: “Modo calculadora” y “Modo conversor”. Cada modo vive en su propio módulo y exporta una función iniciar(contenedor). Usa import() para cargar cada módulo solo al pulsar su botón, y comprueba en la pestaña Red de las DevTools que no se descargan antes. Gestiona el error si el módulo no se puede cargar.
Ejercicio 5: JSON de configuración
Crea config.json con el nombre de una tienda, su moneda y una lista de productos (nombre, precio). Desde un módulo de Node, impórtalo con with { type: 'json' }, calcula el precio medio y muestra el producto más caro. Después haz lo mismo en el navegador con un <script type="module"> y un servidor local.
Ejercicio 6: Import map
Crea una página sin herramientas de build que use un import map para mapear "lodash-es" a jsDelivr (+esm) y "@app/" a tu carpeta /js/. Usa debounce de lodash para mostrar en un párrafo lo que se escribe en un campo de texto, solo cuando el usuario deja de teclear durante 500 ms.
Ejercicio 7: Interoperabilidad en Node
Crea un proyecto con "type": "module". Escribe un fichero legado.cjs que exporte con module.exports = { saludar } y otro moderno.js con exportaciones ES. Comprueba: (a) importar legado.cjs desde moderno.js con importación por defecto y con nombrada (¿qué ocurre y por qué?); (b) hacer require('./moderno.js') desde otro .cjs; (c) qué pasa si añades un await de nivel superior a moderno.js.
Resumen
| Concepto | Sintaxis / clave |
|---|---|
| Módulo | Fichero con ámbito propio; solo sale lo que exportas |
| Export nombrado | export const x = 1; export function f() {} |
| Export de lista | export { a, b as alias }; |
| Export por defecto | export default function () {} (uno por módulo) |
| Import nombrado | import { a, b as otro } from './mod.js'; |
| Import por defecto | import loQueSea from './mod.js'; |
| Import namespace | import * as mod from './mod.js'; |
| Import por efectos | import './polyfills.js'; |
| Re-exportar | export { a } from './x.js'; export * from './y.js'; export * as z from './z.js'; |
| Barrel file | index.js que solo re-exporta la API pública de una carpeta |
| Enlaces vivos | Las importaciones reflejan cambios del original y no se pueden reasignar |
| Navegador | <script type="module">: diferido, estricto, ámbito propio, CORS, necesita servidor |
import.meta | url, resolve(); en Node también dirname, filename, main |
| Import dinámico | const mod = await import('./mod.js'); (devuelve una promesa) |
| Import maps | <script type="importmap">{ "imports": { "nombre": "url" } }</script> |
| Módulos JSON | import datos from './d.json' with { type: 'json' }; (ES2025) |
| Node: tipo de módulo | "type": "module" en package.json, .mjs (ESM), .cjs (CommonJS) |
| CJS desde ESM | import lib from './lib.cjs'; |
| ESM desde CJS | require('./mod.mjs') (Node moderno, sin top-level await) o import() |
| Dependencias circulares | Evitarlas; mover lo común a un tercer módulo |