wandres.dev
MÓDULOS JS · ESM vs CommonJS

CommonJS: require, module.exports y el runtime

El sistema de módulos original de Node: require y module.exports, el wrapper de función, la carga síncrona y dinámica, la caché de módulos, y por qué su naturaleza en tiempo de ejecución limita el análisis estático.

⏱ 15 min

CommonJS (CJS) nació en 2009 con Node, años antes de que el lenguaje tuviera módulos propios. No forma parte de ECMAScript: es una convención de runtime construida sobre funciones y objetos. Entenderlo por dentro —el wrapper, la caché, la semántica de valor— explica por qué sigue vivo y por qué el ecosistema migra hacia ESM.

🎯 Al terminar esta lección sabrás
  • Ver el wrapper de módulo y la relación entre module.exports y exports.
  • Entender la carga síncrona: qué implica y por qué en el servidor encaja.
  • Usar la carga dinámica: require en cualquier punto y con rutas calculadas.
  • Comprender por qué CJS es un sistema en tiempo de ejecución y qué pierde.

El wrapper: de dónde salen require y module

Antes de ejecutar un archivo .cjs o .js de tipo CommonJS, Node lo envuelve en una función. Las variables “mágicas” no son globales: son parámetros de ese wrapper:

(function (exports, require, module, __filename, __dirname) {
  // tu código vive aquí dentro
});

Por eso require, module y exports existen en cada archivo. module.exports es el objeto que realmente se exporta; exports es solo un alias que apunta al mismo objeto al empezar:

// bien: mutar el objeto compartido
exports.sumar = (a, b) => a + b;
module.exports.PI = 3.14159;

// romper el alias: reasignar exports NO cambia lo exportado
exports = { restar: (a, b) => a - b };   // ← inútil

// exportar algo entero: hay que tocar module.exports
module.exports = function crear() { /* ... */ };

Los cinco parámetros del wrapper son todo el “entorno” implícito de un módulo CJS:

Parámetro Qué es
exports alias inicial de module.exports
require función para cargar otros módulos
module el objeto del módulo, con .exports
__filename ruta absoluta del archivo
__dirname directorio del archivo

__filename y __dirname no existen en ESM —allí se derivan de import.meta.url—, y son una de las razones por las que cierto código de scripting y herramientas de build aún prefiere CommonJS.

require: síncrono y dinámico

require(especificador) hace todo en el acto: resuelve la ruta, ejecuta el módulo si no estaba cargado, y devuelve el valor de su module.exports. Es síncrono —bloquea hasta terminar— lo cual en un servidor que lee del disco local es aceptable. Y como es una función normal, puede llamarse en cualquier sitio, dentro de condicionales y con rutas construidas al vuelo:

const fs = require("node:fs");

let plugin;
if (process.env.MODO === "extendido") {
  const nombre = elegirPlugin();
  plugin = require(`./plugins/${nombre}.js`);  // ruta dinámica en runtime
}

Esta flexibilidad es imposible en el import estático de ESM: ahí las rutas deben ser literales conocidas antes de ejecutar.

require resuelve el especificador con un algoritmo bien definido: si empieza por ./ o ../ es relativo al archivo; si es un nombre desnudo, recorre los node_modules desde el directorio actual hacia la raíz; y si lleva el prefijo node:, es un módulo interno como node:fs. El resultado se normaliza a una ruta absoluta, que es justo la clave con la que se indexa la caché.

La caché de módulos

require memoiza. Cada módulo se evalúa una sola vez; las siguientes llamadas devuelven el mismo module.exports desde require.cache, indexado por la ruta resuelta. Es el patrón singleton integrado en el sistema:

const a = require("./config.js");
const b = require("./config.js");
console.log(a === b); // true — mismo objeto cacheado

delete require.cache[require.resolve("./config.js")]; // forzar recarga

Esta caché es la base del patrón singleton en Node: un módulo que exporta una conexión a base de datos o una configuración se comparte por identidad entre todos los que lo requieren. También es la causa de bugs sutiles al testear, cuando un módulo con estado sobrevive entre pruebas por seguir cacheado.

⚠️
Semántica de valor, no de binding

require te entrega una referencia al objeto exportado en ese instante. Si el módulo reasigna después una variable primitiva exportada, tú no lo verás: capturaste una foto, no una ventana. Es justo lo contrario a los bindings vivos de ESM y una fuente clásica de confusión al migrar.

Por qué es un sistema en tiempo de ejecución

La forma de un módulo CJS solo se conoce ejecutándolo. module.exports puede recibir cualquier cosa, de forma condicional, en medio de un bucle o tras un cálculo:

if (soporteNativo()) {
  module.exports = require("./impl-nativa.js");
} else {
  module.exports = require("./impl-js.js");
}

Los ciclos en CJS son más traicioneros que en ESM. Como require devuelve module.exports tal como esté en ese instante, si a.js requiere b.js a mitad de su evaluación y b.js vuelve a requerir a.js, recibe un module.exports incompleto —solo lo asignado hasta la línea del primer require—. No hay error: hay un objeto a medio poblar que provoca fallos silenciosos difíciles de rastrear.

flowchart LR
R[require ruta] --> S[resolver a ruta absoluta]
S --> C{esta en cache}
C -->|si| V[devolver exports cacheado]
C -->|no| W[envolver en wrapper]
W --> E[evaluar y poblar module.exports]
E --> G[guardar en cache]
G --> V
style R fill:#89b4fa,color:#11111b
style E fill:#f9e2af,color:#11111b
style V fill:#a6e3a1,color:#11111b

Ningún analizador puede saber, sin correr el código, qué exporta ese archivo. Por eso el tree shaking sobre CJS es pobre: los bundlers deben ser conservadores e incluir el módulo entero. Herramientas como cjs-module-lexer intentan detectar exportaciones con heurísticas sintácticas, pero es una aproximación, no una garantía.

🧩

Rutas dinámicas

require(variable) con una ruta calculada es idiomático; el bundler no puede saber qué archivo entra.

📸

Semántica de valor

Recibes el module.exports de ese instante, no una ventana viva a las variables del módulo.

🌳

Tree shaking pobre

Sin forma estática conocida, el bundler incluye el módulo entero por seguridad.

Nada de esto hace a CommonJS “malo”: durante quince años fue el único sistema de módulos de Node y sostiene una fracción enorme del npm que usas hoy. Pero su modelo —funciones que se ejecutan para revelar su forma— es incompatible con el análisis estático del que vive el tooling moderno, y esa es la razón técnica, no ideológica, de la migración a ESM.

Flexibilidad de runtime, ceguera de tooling

CommonJS y ESM encarnan un intercambio filosófico. CJS pone toda la potencia en tiempo de ejecución: require es una función, así que puedes ramificar, calcular rutas, recargar borrando la caché y reensamblar module.exports según el entorno. Esa dinámica resolvió el problema real de 2009 —módulos en el servidor, sin build step— y aún hoy da soluciones elegantes a la carga condicional de binarios nativos. Pero el precio es la opacidad: como la forma del módulo solo emerge al ejecutar, ninguna herramienta puede razonar sobre él sin correrlo, y el tree shaking, el análisis de tipos entre archivos y el bundling determinista se vuelven aproximaciones frágiles. La lección profunda es que “dinámico” y “analizable” son un compromiso, no una escala de mejor a peor: CJS eligió lo primero, ESM lo segundo, y por eso el ecosistema de 2026 —optimizado por bundlers— gravita hacia ESM.

⚔️ Disecciona un módulo CJS
  1. Escribe un módulo que exporte con exports.x y otro que reasigne module.exports = ...; comprueba desde fuera la diferencia.
  2. Reasigna exports = {...} y verifica que no cambia lo importado; explica por qué mirando el wrapper.
  3. Requiere el mismo módulo dos veces y confirma con === que es el mismo objeto cacheado.
  4. Borra su entrada de require.cache, vuelve a requerirlo y observa que se re-evalúa.
  5. Monta un ciclo entre dos módulos CJS y comprueba que uno recibe un module.exports a medio poblar, sin ningún error.