首页 / 文章 / CommonJS与ES模块:Node导入错误背后的结构差异

CommonJS与ES模块:Node导入错误背后的结构差异

了解为何 require 和 import 是本质不同的机制,静态分析如何影响树摇动优化,以及为何默认导出和循环导入在这两种机制中的表现存在差异。

1364 词

在使用 Node 时遇到的几乎所有令人困惑的模块错误,都源于一个很少被明确说明的事实。诸如有消息指出模块作用域中不存在 require,或是有关在模块外部使用 import 的语法错误,又或者某个包的运行行为会因引入方式的不同而变化——所有这些现象都指向同一个根本原因:CommonJS 和 ES Modules 并非只是同一概念的不同写法,而是两种截然不同的系统。前者基于一经调用就会立即执行的函数调用机制;后者则建立在引擎能够在任何代码实际运行之前对其进行检查的结构之上。当这两种系统相互结合时所产生的几乎所有问题,都是这种差异直接导致的副作用。

CommonJS:require 只是一种函数调用

一旦你输入了上千次 require(),就很容易忘记它其实并没有什么特别之处。它只是一个普通的函数,而 module.exports 也是一个普通对象——这两者都是 Node 在运行时提供的,并非内嵌在语言本身之中。

// 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));

由于 require 的行为与其他函数相同,你可以根据条件来调用它:在 if 语句块中、在 try/catch 结构中,或者通过变量计算出的路径来调用——凡是普通函数调用允许的方式都可以。

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

这种灵活性确实非常实用,但这也正是 ES Modules 所选择放弃的。

ES Modules:引擎在运行任何代码之前都会先读取其结构

import 不是函数调用——而是一种声明,且存在一条几乎会让所有人初次使用时措手不及的规则:它必须位于文件的顶层。不能将其放在条件语句、循环或函数体内部。

// 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
}

这种限制并非随意设定的苛刻条件。它存在的原因是 ES Modules 旨在实现静态分析:在程序的任何代码行实际执行之前,编译器就会遍历整个模块图中的所有 importexport 语句,从而构建出完整的依赖关系映射。正是这种静态映射使得代码压缩功能成为可能——打包工具可以查看该依赖图,安全地移除那些被导出但从未被任何地方导入的代码,因为依赖关系在程序运行之前就已明确,而非只有在执行过程中才会显现。CommonJS 则无法做到这一点,因为 require() 调用可能是条件性的、计算得出的,或者隐藏在只有在程序运行时才会解析的逻辑中——正是这种灵活性使得 CommonJS 的依赖图无法提前知晓。

继续前进。

两种系统中的默认导出并不具有相同含义

这就是跨框架互操作开始带来问题的地方。在 CommonJS 中,编写 module.exports = something 仅仅是将整个模块的内容替换掉——并不存在独立于其他导出的“默认导出”概念:

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

而 ESM 则将默认导出视为一个明确且结构上独立的概念:

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

当 Node 的互操作层从 ESM 代码中加载 CommonJS 文件时,它会将整个 module.exports 的值作为默认导出。这通常是合理的行为,但也会导致那种容易让人犯错的细微差异:

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

正是这种不确定性——即所导入的是整个模块还是其中某个特定部分——导致了当代码库将旧的 CommonJS 包与新的以 ESM 为优先的代码混用时,出现大量“为何该变量未定义”的错误。

循环导入的处理方式不同,且这确实很重要

在任何模块系统中,两个模块互相导入本身就已经是一种较为复杂的结构,而 CommonJS 和 ESM 对这种复杂性的处理方式各不相同,因此相同结构的代码在不同系统中可能会出现不同的故障表现。

// 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 的处理方式是返回该循环依赖模块在确切的那一时刻module.exports 值,即便该模块尚未执行完成。这就是为什么从 b.js 中读取 a.value 时会得到 undefined ——在 b.js 请求它时,a.js 还没有执行到 module.exports 的赋值语句。

ESM采用了一种不同的方法,即所谓的实时绑定:引用会始终与导出模块保持关联,一旦该模块的求值完成就会自动更新,而非在导入时形成固定不变的快照。这意味着在ESM下某些循环结构能够正常工作,而在CommonJS中则会导致undefined的结果。不过这并不会让循环导入在两种系统中都成为可行方案——它只是改变了错误的表现形式,而非彻底消除错误。

实际隐患:在同一个项目中混用两种机制

在实际使用中,问题并不在于概念层面——归根结底是一系列特定且反复出现的错误:

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

出现这些问题的原因是 Node 需要判断某个文件采用何种模块格式编写,它会通过多种信号来进行判断:文件是否以 .mjs 结尾,是否以 .cjs 结尾;如果以上两种情况都不满足,则根据最近的 package.json 文件中 "type" 字段所指定的格式来判断。每当文件的实际语法与 Node 所设定的解析方式不一致时,就会出现这类错误。仅以 ESM 格式发布的包无法通过 CommonJS 代码中的 require() 函数引入。若要使用它,项目要么需要使用异步的 import() 语句——与静态的 import 关键字不同,它表现为真正的函数调用,可在任何地方使用,包括条件语句中——要么就需要将调用该包的代码完全迁移到 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;
}

这一切背后的一个事实

每一个具体的问题点——要求import必须位于最顶层、在某些系统中可行而在其他系统中不可行的树摇剪机制、不匹配的默认导出,以及行为不同的循环导入——都源于同一个根本原因:CommonJS会在程序运行时动态构建模块图,而ESM则会在任何代码执行之前静态构建模块图。这两种方式并非彼此设计上的缺陷;它们都在回答同一个问题——“文件之间如何相互依赖?”——只不过所面临的约束条件和提供的保证截然不同。当将这两种方式混合使用时所产生的问题,并非Node出了故障,而是两个内在逻辑自洽的系统被迫在交汇处进行交互。

相关阅读