Antes de los módulos ES, JavaScript no tenía un sistema de módulos integrado. Había etiquetas script en el orden correcto, patrones IIFE para simular la encapsulación, y luego una década de CommonJS en Node.js. Los módulos ES — estandarizados en ES2015 y ahora soportados en todas partes — son la solución real. Esta guía cubre todo, desde los fundamentos de los exports con nombre vs por defecto hasta las importaciones dinámicas, el tree-shaking y los patrones que mantienen las grandes bases de código mantenibles.
Exports con nombre vs Exports por defecto
Hay dos formas de exportar desde un módulo. Entender la diferencia — y los compromisos — es la base para todo lo demás:
// utils/currency.js
// Named exports — explicit names, multiple per file
export function formatPrice(amount, currency = 'USD') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}
export function parseCents(cents) {
return cents / 100;
}
export const DEFAULT_CURRENCY = 'USD';// Importing named exports — names must match
import { formatPrice, parseCents } from './utils/currency.js';
// You can rename on import
import { formatPrice as fmt } from './utils/currency.js';
// Import the entire namespace as an object
import * as Currency from './utils/currency.js';
Currency.formatPrice(49.99); // works// components/ProductCard.js
// Default export — one per file, no name required
export default function ProductCard({ product }) {
return `<div class="card">${product.name}</div>`;
}// Importing a default export — you choose the name
import ProductCard from './components/ProductCard.js';
import Card from './components/ProductCard.js'; // same thing, different nameArchivos barrel — Útiles y peligrosos
Un archivo barrel es un index.js que re-exporta desde múltiples módulos, dando a los consumidores una ruta de importación más limpia. Están en todas partes en proyectos grandes:
// utils/index.js — the barrel
export { formatPrice, parseCents, DEFAULT_CURRENCY } from './currency.js';
export { validateEmail, validatePhone } from './validation.js';
export { debounce, throttle } from './timing.js';
export { fetchWithRetry, buildQueryString } from './http.js';// Consumer — clean import path, no need to know the file structure
import { formatPrice, debounce, fetchWithRetry } from './utils/index.js';
// or with bundlers that resolve index.js automatically:
import { formatPrice, debounce, fetchWithRetry } from './utils';El peligro: los archivos barrel pueden destruir el tree-shaking. Si importas una función de un barrel que re-exporta 40 módulos, algunos bundlers arrastran los 40 — incluso los que no usas. La solución es ser selectivo con lo que pones en el barrel, y asegurarte de que tu bundler soporte sideEffects: false en package.json.
import() dinámico — División de código bajo demanda
Las importaciones estáticas (import x from '...') se resuelven en el momento del bundle. El import() dinámico es una función que carga un módulo de forma diferida en tiempo de ejecución, devolviendo una Promise. Así es como implementas la división de código basada en rutas:
// Load a heavy chart library only when the user navigates to the analytics page
async function loadAnalyticsDashboard() {
const { Chart } = await import('chart.js');
const { buildDashboardData } = await import('./analytics/data.js');
const data = await buildDashboardData();
const canvas = document.getElementById('dashboard-chart');
new Chart(canvas, { type: 'bar', data });
}
// Attach to a route change
router.on('/analytics', loadAnalyticsDashboard);// Feature flags — only load a module if the feature is enabled
async function initFeature(featureName) {
const flags = await getFeatureFlags();
if (flags[featureName]) {
const module = await import(`./features/${featureName}.js`);
module.init();
}
}Webpack, Rollup y Vite entienden el import() dinámico y dividirán automáticamente el módulo cargado en un chunk separado. El resultado: un bundle inicial más pequeño, una primera carga más rápida.
import.meta — Metadatos del módulo
import.meta es un objeto que te proporciona metadatos sobre el módulo actual. La propiedad más útil es import.meta.url — la URL del archivo del módulo actual:
// Get the directory of the current module (Node.js)
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Now you can do path resolution the same way you would with CommonJS
const configPath = join(__dirname, '../config/app.json');// In Vite projects, import.meta.env gives you environment variables
const apiBase = import.meta.env.VITE_API_BASE_URL;
const isProd = import.meta.env.PROD; // boolean
// import.meta.glob — Vite-specific, import multiple files at once
const modules = import.meta.glob('./pages/*.js');
for (const path in modules) {
const module = await modules[path]();
console.log(path, module);
}Módulos ES vs CommonJS
Aún encontrarás CommonJS (require()) en bases de código antiguas de Node.js y algunos paquetes npm. Aquí hay una referencia rápida de las diferencias que confunden a la gente:
// CommonJS (CJS) — still common in Node.js
const express = require('express');
const { join } = require('path');
module.exports = { myFunction };
module.exports.default = MyClass;
// ES Modules (ESM) — the standard
import express from 'express';
import { join } from 'path';
export { myFunction };
export default MyClass;Diferencias clave: los módulos ES son analizables estáticamente (las importaciones se resuelven antes de que se ejecute el código), mientras que los require de CommonJS se ejecutan en tiempo de ejecución. ESM utiliza carga async por defecto; CJS es síncrono. En Node.js, los archivos con extensión .mjs o "type": "module" en package.json se tratan como ESM.
import de un módulo CommonJS desde un archivo ESM (Node.js lo maneja), pero no puedes hacer require() de un módulo ESM desde un archivo CommonJS. Si necesitas hacerlo, usa una llamada import() dinámica en su lugar — funciona en ambos sistemas de módulos.Tree-Shaking y exports con nombre
El tree-shaking es el proceso del bundler de eliminar código que importas pero nunca usas. Requiere exports con nombre y declaraciones de importación estáticas — las importaciones dinámicas y CommonJS son mucho más difíciles de tree-shake. Así es como escribir módulos que se tree-shaken bien:
// ✅ Tree-shakeable — bundler can see exactly what's exported
export function formatDate(date) { /* ... */ }
export function parseDate(str) { /* ... */ }
export function addDays(date, n) { /* ... */ }
// If you only import formatDate, parseDate and addDays are excluded from the bundle
import { formatDate } from './utils/dates.js';// ❌ Harder to tree-shake — the whole object is one export
const dateUtils = {
formatDate: (date) => { /* ... */ },
parseDate: (str) => { /* ... */ },
addDays: (date, n) => { /* ... */ }
};
export default dateUtils;
// Even if you only use dateUtils.formatDate, the whole object may be bundledPara las bibliotecas publicadas en npm, el soporte de tree-shaking es la diferencia entre una biblioteca que la gente recomienda y una que evitan. La guía de módulos de MDN cubre las reglas completas de análisis estático que lo hacen posible.
Módulos ES nativos en el navegador
Puedes usar módulos ES directamente en el navegador — sin bundler necesario. Agrega type="module" a tu etiqueta script:
<!-- index.html -->
<!-- type="module" enables ES Module semantics -->
<!-- deferred by default — runs after HTML is parsed -->
<script type="module" src="./app.js"></script>// app.js — can use import/export directly
import { initRouter } from './router.js';
import { setupAuth } from './auth.js';
import { loadDashboard } from './dashboard.js';
async function main() {
await setupAuth();
initRouter();
await loadDashboard();
}
main();Los scripts de módulos siempre están diferidos y se ejecutan en modo estricto por defecto. Cada módulo solo se ejecuta una vez, incluso si lo importan múltiples otros módulos — el navegador almacena en caché la instancia del módulo. Esto vale la pena saber cuando tienes estado compartido en módulos.
Un ejemplo real de refactoring
Así es como un archivo utils típico crece fuera de control y se divide correctamente en módulos:
// Before: one giant utils.js with everything
export function formatPrice(amount) { /* ... */ }
export function validateEmail(email) { /* ... */ }
export function debounce(fn, ms) { /* ... */ }
export function fetchWithRetry(url, opts) { /* ... */ }
export function parseCSV(text) { /* ... */ }
export function buildQueryString(params) { /* ... */ }
// After: split by concern
// utils/
// currency.js → formatPrice, parseCents
// validation.js → validateEmail, validatePhone
// timing.js → debounce, throttle
// http.js → fetchWithRetry, buildQueryString
// csv.js → parseCSV, formatCSV
// index.js → re-exports all of the above (optional barrel)Dividir por responsabilidad significa que cada archivo tiene una sola responsabilidad, las pruebas son más fáciles de escribir y localizar, y los bundlers pueden hacer tree-shake a nivel de archivo. El archivo barrel mantiene las importaciones limpias para los consumidores que prefieren la ruta de importación única.
Herramientas útiles
Al construir JavaScript modular, el Formateador JS mantiene tus bloques de importación limpios y consistentes. Para entender qué hay en tu bundle final, herramientas como Webpack Bundle Analyzer o el visualizador de Rollup muestran qué módulos están ocupando espacio. La especificación ECMAScript Modules de TC39 tiene la semántica de resolución exacta si necesitas entender casos límite.
Conclusión
Los módulos ES son la base de la arquitectura JavaScript moderna. Prefiere los exports con nombre para las funciones de utilidad — son explícitos, tree-shakeables y amigables con el IDE. Usa el import() dinámico para separar el código pesado de tu bundle inicial. Ten cuidado con los archivos barrel en grandes bases de código. Y si todavía usas CommonJS en un nuevo proyecto Node.js, es hora de migrar — el ecosistema ha cambiado completamente a ESM.