Inicio / Artículos / CommonJS vs Módulos ES: La división estructural detrás de los errores de importación en Node

CommonJS vs Módulos ES: La división estructural detrás de los errores de importación en Node

Aprenda por qué require e import son sistemas fundamentalmente diferentes, cómo el análisis estático influye en el proceso de tree-shaking, y por qué las exportaciones predeterminadas y las importaciones circulares se comportan de manera inconsistente en ambos.

1364 palabras

Casi todos los errores confusos que se presentan al trabajar con Node se deben a un hecho que rara vez se explica claramente. Mensajes como aquellos en los que una referencia indica que require no existe en el ámbito del módulo, o errores de sintaxis por usar import fuera de un módulo, o paquetes que se comportan de manera inconsistente según cómo se incluyan, todos apuntan a la misma causa raíz: CommonJS y ES Modules no son simplemente dos formas de escribir la misma idea. Son dos sistemas verdaderamente distintos. Uno se basa en llamadas a funciones que se ejecutan en el momento en que se invocan; el otro se basa en una estructura que el motor puede inspeccionar *antes* de que algo se ejecute realmente. Casi todos los problemas que surgen cuando estos dos sistemas coexisten son un efecto directo de esa separación.

CommonJS: require es simplemente una llamada a función

Es fácil olvidar, una vez que has escrito require() mil veces, que no hay nada mágico en ello. Se trata de una función sencilla, y module.exports es un objeto sencillo: ambos son proporcionados por Node en tiempo de ejecución, no forman parte integrante del lenguaje en sí.

// math.js
function add(a, b) { return a + b; }
module.exports = { add };

// app.js
const math = require("./math.js"); // a plain function call, evaluated when this line runs
console.log(math.add(2, 3));

Dado que require se comporta como cualquier otra función, puedes llamarla de forma condicional: dentro de un bloque if, dentro de un try/catch, o con una ruta calculada a partir de una variable; cualquier cosa que permita una llamada normal a función.

const driver = require(process.env.DB_DRIVER === "postgres" ? "./pg-driver" : "./sqlite-driver");

Esa flexibilidad es realmente útil, y también es precisamente lo que decidieron sacrificar los Módulos ES.

Módulos ES: El motor lee la estructura antes de ejecutar cualquier código

import no es una llamada a función, sino una declaración; además, existe una regla que sorprende a casi todos la primera vez: debe encontrarse en el nivel más alto de un archivo. No se puede colocar dentro de una condición, un bucle ni el cuerpo de una función.

// math.mjs
export function add(a, b) { return a + b; }

// app.mjs
import { add } from "./math.mjs"; // not evaluated like a function call
console.log(add(2, 3));
if (needsMath) {
  import { add } from "./math.mjs"; // SyntaxError, this is not allowed
}

Esa restricción no es una exigencia arbitraria. Existe porque los Módulos ES están diseñados para ser analizables estáticamente: antes de que se ejecute siquiera una línea de su programa, el motor recorre cada import y export de todo el grafo de módulos y crea un mapa completo de qué depende de qué. Ese mapa estático es lo que permite el tree-shaking: un bundler puede inspeccionar el grafo y eliminar de forma segura el código que se exporta pero nunca se importa en ningún lugar, ya que las relaciones de dependencia se conocen de antemano y no son algo que solo se vuelve visible a medida que avanza la ejecución. CommonJS no puede ofrecer esa misma garantía, ya que las llamadas a require() pueden ser condicionales, calculadas o estar ocultas dentro de lógica que solo se resuelve mientras el programa está en ejecución; esa misma flexibilidad hace que sea imposible conocer el grafo de dependencias de CommonJS.

Avance.

Las exportaciones predeterminadas no significan lo mismo en ambos sistemas

Aquí es donde la interoperabilidad comienza realmente a causar problemas. En CommonJS, escribir module.exports = something simplemente reemplaza todo lo que representa el módulo; no existe un concepto separado de “exportación predeterminada” que esté distinto de cualquier otra exportación:

// legacy.js
module.exports = function greet(name) {
  return `Hello, ${name}`;
};

Por otro lado, ESM trata la exportación predeterminada como un concepto explícito y estructuralmente separado:

// modern.mjs
export default function greet(name) {
  return `Hello, ${name}`;
}

Cuando la capa de interoperabilidad de Node carga un archivo CommonJS desde código ESM, toma todo el valor de module.exports y lo envuelve como la exportación predeterminada. Ese suele ser el comportamiento adecuado, pero también genera exactamente ese tipo de discrepancia sutil que confunde a las personas:

import greet from "./legacy.js"; // works: greet is the whole module.exports value
import { greet } from "./legacy.js"; // fails silently or throws, depending on the module
// named destructuring assumes CommonJS explicitly attached named properties,
// which module.exports = function... never did

Esa única ambigüedad —si lo que se está importando es todo el módulo o solo una parte específica de él— explica una gran parte de los errores del tipo “¿por qué está indefinido?” en cuanto un código mezcla paquetes CommonJS antiguos con código más reciente basado en ESM.

Las importaciones circulares se resuelven de manera diferente, y eso realmente importa

Que dos módulos se importen mutuamente ya es una situación delicada en cualquier sistema de módulos, pero CommonJS y ESM manejan esa fragilidad de formas distintas, lo que significa que un código con apariencia similar puede fallar de manera diferente según el sistema en el que se ejecute.

// a.js (CommonJS)
const b = require("./b.js");
console.log("b's value:", b.value);
module.exports = { value: "from a" };

// b.js (CommonJS)
const a = require("./a.js");
console.log("a's value:", a.value); // undefined — a hasn't finished exporting yet
module.exports = { value: "from b" };

CommonJS maneja esto devolviendo lo que sea que esté siendo devuelto por module.exports del módulo requerido de forma circular, en ese instante exacto, incluso si dicho módulo aún no ha terminado de ejecutarse. Por eso a.value aparece como undefined cuando se lee desde dentro de b.js: a.js aún no había llegado a la asignación de su module.exports para cuando b.js lo solicitó.

ESM adopta un enfoque diferente a través de lo que se conoce como enlaces en tiempo real: referencias que permanecen vinculadas al módulo exportador y se actualizan automáticamente una vez que ese módulo termina de evaluarse, en lugar de un estado congelado en el momento de la importación. Eso significa que ciertos patrones circulares funcionan correctamente bajo ESM, mientras que bajo CommonJS producirían silenciosamente el valor undefined. Pero esto no convierte a las importaciones circulares en una buena idea en ninguno de los dos sistemas; simplemente cambia la forma en que se manifiesta el error, en lugar de eliminarlo.

La trampa práctica: mezclarlos en un mismo proyecto

A diario, el problema no es realmente conceptual; se reduce a un conjunto específico y recurrente de errores:

SyntaxError: Cannot use import statement outside a module
ReferenceError: require is not defined in ES module scope
ReferenceError: exports is not defined

Estos errores aparecen porque Node debe determinar en qué formato de módulo está escrito un archivo dado, y realiza esa comprobación mediante varios indicadores: si el archivo termina en .mjs, si termina en .cjs, o, si ninguno de estos casos es aplicable, qué indica el package.json más cercano a través de su campo "type". Cada vez que la sintaxis real de un archivo no coincide con la forma en que Node ha decidido interpretarlo, se producen exactamente estos errores. Un paquete publicado únicamente en formato ESM simplemente no puede ser incluido con require() desde código de CommonJS. Para utilizarlo, un proyecto necesita bien el comando asíncrono import() —que, a diferencia de la palabra clave estática import, se comporta como una verdadera llamada a función y puede usarse en cualquier lugar, incluyendo en estructuras condicionales— o bien una migración completa del código que lo utiliza al formato ESM.

// this works from CommonJS, because import() is a dynamic function call, not a static declaration
async function loadEsmOnlyPackage() {
  const mod = await import("esm-only-package");
  return mod.default;
}

El hecho fundamental detrás de todo esto

Cada punto de fricción individual —la exigencia de que import esté en el nivel más alto, el hecho de que el tree-shaking sea viable en un sistema pero no en otro, las exportaciones predeterminadas incompatibles y el comportamiento diferente de las importaciones circulares— se remonta a una sola causa subyacente: CommonJS construye su gráfico de módulos dinámicamente, a medida que el programa se ejecuta, mientras que ESM construye su gráfico de forma estática, antes de que se ejecute cualquier código. Ningún enfoque representa un defecto en el diseño del otro; ambos responden a la misma pregunta —“¿cómo dependen los archivos unos de otros?”— pero con restricciones y garantías realmente diferentes. La fricción que sientes al mezclarlos no se debe a que Node esté defectuoso. Se debe a que dos sistemas internamente coherentes se ven obligados a comunicarse en el punto de encuentro entre ellos.

Lectura relacionada