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, el import() 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. Reserva default para 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 script en el head y acceder al DOM sin esperar a DOMContentLoaded (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 un onclick="miFuncion()" en el HTML deja de funcionar al pasar a módulos, es por esto (y es una razón más para usar addEventListener, 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 .js con 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.filename y import.meta.dirname solo existen para módulos cargados desde el disco en Node.js (no en el navegador). import.meta.main es 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 cada import() 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).
  • +esm es 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 MIME application/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 es TypeError [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 siempre with.

💡 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 .js con import, 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ás ERR_MODULE_NOT_FOUND.
  • No existen require, module, exports, __dirname ni __filename (usa import.meta.dirname y import.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 require dentro 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 utils no conocen a nadie; los servicios hablan con la API pero no tocan el DOM; los componentes pintan pero no saben de dónde vienen los datos; main.js lo 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.js que exporte crearBuscador(alBuscar) y devuelva un campo de texto. Úsalo en main.js para filtrar las tarjetas por nombre. ¿Qué capa debería hacer el filtrado: el componente, el servicio o main.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