在上一篇文章中,我们讨论了 Node.js 如何把 JavaScript 引擎与文件、网络、进程等宿主能力组织成一个运行时。不过,一个真实程序很少只有一个文件。当我们把读取配置、处理请求、访问数据的代码分别放进不同文件时,新的问题就出现了:这些文件怎样连接起来?文件中的变量会不会相互影响?同一个文件被使用两次,里面的代码也会执行两次吗?
我们经常用 require() 或 import 连接这些文件。它们看起来都在做“导入”,但导入的不只是几行代码,还涉及模块的作用域、依赖关系、初始化时机,以及导出值在不同文件之间如何被访问。
本文以 Node.js 24 为背景,示例在 Node.js v24.16.0 中验证。我们先使用 .cjs 和 .mjs 明确区分两种模块格式,把注意力放在执行行为上,再讨论 Node.js 怎样判断格式和查找文件。除非特别说明,每组示例都在一个独立目录中运行,同组文件放在同一目录,使用 node app.cjs 或 node app.mjs 启动;不经过 TypeScript、转译器或打包工具。
1. 模块与模块系统
1.1 文件拆开之后,还需要什么
假设一个程序负责统计请求次数。最初,我们可以把计数和输出写在同一个文件中。程序变大后,希望把计数逻辑放在 counter 模块,把调用逻辑放在 app 模块。
拆分文件只是第一步。我们还需要约定:哪些变量只在 counter 内部使用,哪些能力允许外部访问,app 怎样表达对 counter 的依赖,以及这两个模块以什么顺序完成初始化。
模块是组织代码、限定作用域和声明接口的单位;模块系统负责把这些单位连接起来。 在本文的普通文件示例中,一个 JavaScript 文件对应一个模块。但模块不一定来自磁盘文件,例如 Node.js 的内置模块也可以通过模块接口使用。
这里的作用域隔离,不是进程隔离,也不是安全沙箱。两个模块通常仍在同一个 Node.js 进程、同一个 JavaScript 执行线程中运行;它们可以通过导出的对象共享状态,也可以访问各自有权使用的宿主 API。
模块与包也不是一回事。一个包可以包含多个模块,package.json 用来描述包的信息和入口等规则。npm install 负责准备依赖包;程序运行时,Node.js 根据模块请求查找并加载已经存在的内容。require("some-package") 不会替我们到 npm 仓库下载缺失的包。
1.2 Node.js 中的两套模块系统
CommonJS 是 Node.js 长期使用的模块系统,通常简称 CJS。它通过 require() 请求模块,通过 module.exports 提供导出值:
// add.cjs
module.exports = function add(a, b) {
return a + b;
};
// app.cjs
const add = require("./add.cjs");
console.log(add(1, 2)); // 3
ECMAScript Modules 是语言标准定义的模块系统,通常简称 ESM。对应的写法是:
// add.mjs
export function add(a, b) {
return a + b;
}
// app.mjs
import { add } from "./add.mjs";
console.log(add(1, 2)); // 3
两段程序都能完成加法,但不能因此把 import 理解为 require() 的另一种拼写。接下来,我们分别看看它们怎样建立模块之间的联系。
2. CommonJS 的加载与导出
2.1 执行到 require() 时发生了什么
先在两个文件中加入输出:
// counter.cjs
console.log("counter: 初始化");
module.exports = { count: 0 };
// app.cjs
console.log("app: 开始");
const counter = require("./counter.cjs");
console.log("app: 取得 count =", counter.count);
运行 node app.cjs,得到:
app: 开始
counter: 初始化
app: 取得 count = 0
require() 是一次在运行过程中发生的函数调用。当程序执行到它时,Node.js 查找目标模块;如果需要首次执行这个 CommonJS 模块,就先执行其顶层代码,再把导出值返回给调用者。调用者取得结果后,继续执行下一行。
所以,在这个例子中,app 的执行暂时停在了 require() 上,直到 counter 完成同步初始化。若模块的顶层代码包含耗时的同步计算,这段计算同样会占用当前 JavaScript 线程。把计算放进另一个文件,并没有让它自动进入另一个线程。
已经加载过的模块通常会被复用。我们先记住这一点,第 5 节再解释它与缓存、循环依赖的关系。
2.2 为什么每个模块都有自己的变量
CommonJS 文件执行前,Node.js 会把文件内容包装在一个函数中。可以用下面的结构理解它:
(function (exports, require, module, __filename, __dirname) {
// 当前 CommonJS 文件的代码放在这里
});
这是包装结构的示意,不是一段可替代 Node.js 加载器的实现。Node.js 会为这个模块提供相应的参数。
其中,module 表示当前模块,module.exports 保存对外提供的值;require 用于发起模块请求;__filename 和 __dirname 分别表示当前模块文件的绝对路径和所在目录。exports 与 module.exports 的关系稍后展开。
文件中声明的局部变量,位于这个包装函数的作用域内,并不会因为放在文件顶层就自动成为全局变量。这与我们在 JavaScript 作用域中讨论的函数作用域是一致的。
注意:这些名称是 Node.js 提供给 CommonJS 模块的,不是所有 JavaScript 文件都天然具有的全局变量。ESM 中默认没有 require、module、exports、__filename 和 __dirname。在 Node.js 24 的本地文件 ESM 中,可以通过 import.meta.url、import.meta.filename 和 import.meta.dirname 取得对应的模块位置信息。
2.3 module.exports 导出的不一定是对象
前面的计数器导出了一个对象:
module.exports = { count: 0 };
但 CommonJS 不要求导出值一定是“包含很多方法的对象”。第 1 节的 add.cjs 直接导出了一个函数,调用方拿到函数后就能调用,不需要再访问某个 add 属性。
我们甚至可以直接导出一个数字:
// port.cjs
module.exports = 3000;
// app.cjs
const port = require("./port.cjs");
console.log(port); // 3000
console.log(typeof port); // number
require() 取得什么,取决于这个模块的 module.exports 保存了什么。 它可以是对象、函数、数组、字符串、数字,也可以是其他 JavaScript 值。module 是对象,不意味着它的 exports 属性必须保存对象。
这也不是 CommonJS 独有的“数据类型能力”:ESM 的默认导出同样可以导出任意 JavaScript 值。两者更值得关注的区别,是导出接口和导入绑定的建立方式。
2.4 exports 与 module.exports 为什么会“分开”
模块开始执行时,exports 和 module.exports 引用同一个初始对象。因此下面的代码能够正常导出:
// profile.cjs
exports.name = "Guang";
这不是因为两者会自动保持同步,而是因为 exports.name = ... 修改了它们共同引用的对象。
为了看清区别,我们先只用普通 JavaScript 模拟初始关系。下面两段可以放在同一个 .mjs 文件中运行,避免与真实 CommonJS 包装函数中的同名参数冲突:
const originalObject = {};
const module = { exports: originalObject };
let exports = originalObject;
exports.name = "Guang";
console.log(module.exports.name); // Guang
console.log(exports === module.exports); // true
如果接着执行:
exports = { name: "Greg" };
console.log(exports.name); // Greg
console.log(module.exports.name); // Guang
console.log(exports === module.exports); // false
赋值语句左边写的是局部变量 exports。这次操作让它引用了另一个对象,却没有给 module.exports 这个属性赋值,所以正式的导出值仍是原来的对象。
回到真实的 CommonJS 文件,如果只写:
// profile.cjs
exports = { name: "Guang" };
调用者取得的仍是初始空对象,而不是新创建的对象。希望替换整个导出值,应当写成:
// profile.cjs
module.exports = { name: "Guang" };
反过来也一样:module.exports = ... 不会让局部变量 exports 自动指向新值。替换导出值之后继续写 exports.age = 32,修改的可能还是已经不再对外提供的旧对象。
我们可以把三个操作分别理解为:
exports.name = ...:修改exports当前引用的对象。exports = ...:改变局部变量exports保存的值。module.exports = ...:替换模块正式对外提供的值。
这里并没有一套特殊的赋值规则。它仍然是普通 JavaScript 中“修改对象属性”和“给变量重新赋值”的区别。实际编写一个模块时,选择属性导出或整体替换的方式并保持一致,通常更容易理解。
2.5 解构之后,为什么 count 不再变化
现在让计数器真正递增:
// counter.cjs
const counter = {
count: 0,
increment() {
counter.count += 1;
},
};
module.exports = counter;
// app.cjs
const counter = require("./counter.cjs");
const { count } = counter;
counter.increment();
console.log(counter.count); // 1
console.log(count); // 0
第一行取得导出对象。第二行则读取这个对象当时的 count 属性,把数字 0 赋给一个新的局部变量。它相当于:
const count = counter.count;
因此,后续 counter.count 变成 1,不影响局部变量 count 已经保存的 0。对于这个数字,说“按值取得”或者“值拷贝”是可以的;错误在于把它推广成“CommonJS 会复制整个导出对象”。
即使不用任何模块,对普通对象做同样的解构也会出现相同行为。如果属性本身是对象,解构得到的值则是对象引用:
const counter = { state: { count: 0 } };
const { state } = counter;
counter.state.count = 1;
console.log(state.count); // 1
这里没有复制第二个 state 对象。state 与 counter.state 指向同一个对象,所以修改对象内容是可见的;但如果以后把 counter.state 替换为另一个对象,局部变量 state 也不会自动跟着切换。
理解 CommonJS 时,需要把两步分开:require() 取得模块提供的值;之后的属性读取、赋值和解构,遵循普通 JavaScript 规则。
3. ESM 的依赖与绑定
3.1 import 声明不是执行到这里才调用的函数
我们把刚才观察执行顺序的例子改为 ESM:
// counter.mjs
console.log("counter: 初始化");
export let count = 0;
// app.mjs
console.log("app: 开始");
import { count } from "./counter.mjs";
console.log("app: 取得 count =", count);
运行后,输出顺序变成:
counter: 初始化
app: 开始
app: 取得 count = 0
尽管 import 在文件中写在第一条 console.log 后面,counter 仍先于 app 的模块体执行。这是因为静态 import 是依赖声明,不是执行到这一行时才发生的普通函数调用。
ESM 本身具有模块作用域,并默认采用严格模式。它不靠 CommonJS 的包装参数提供模块接口,而是通过语言定义的导入、导出语法建立联系。
对于这里没有循环依赖、也没有顶层 await 的模块图,可以把过程分成三个层次理解:
- 加载与解析:读取入口及其静态依赖,识别导入、导出,逐步发现依赖图。
- 链接:把导入名称连接到对应的导出绑定,并检查这些名称是否存在。
- 求值:按照依赖关系执行模块体,完成需要通过执行代码进行的初始化。
这不是说整个程序永远只经历一次整齐的三步流程。动态导入会在运行时引入新的加载请求,循环依赖和顶层 await 也会影响求值过程。这里先用简单依赖图建立基本认识。
静态 import 必须写在模块顶层,不能放进 if 或普通函数体中。依赖关系能在执行模块体之前被识别,不意味着模块中的业务代码已经“编译时执行”了。实际计算、赋值和函数调用仍发生在求值过程中。
3.2 命名导入连接的是绑定
再写一个完整的计数器:
// counter.mjs
export let count = 0;
export function increment() {
count += 1;
}
// app.mjs
import { count, increment } from "./counter.mjs";
console.log(count); // 0
increment();
console.log(count); // 1
这一次,app 中的 count 会反映导出模块中的变化。原因不是“把数字改成了引用类型”,而是 import { count } 为导入方建立了一个连接到导出方变量的绑定。每次读取它,取得的是这个绑定当前对应的值。这就是 live binding(实时绑定)。
这里的花括号容易让人联想到对象解构,但二者不是同一种语法:
import { count } from "./counter.mjs"; // 导入声明:连接导出绑定
const { count } = counter; // 解构声明:读取属性并初始化局部变量
上面两行是对照示意,不是在同一个作用域中重复声明 count。
导入方不能给这个导入绑定重新赋值。在前面的 app.mjs 中执行 count = 10 会抛出 TypeError;需要通过导出模块提供的 increment() 等接口修改状态。
但是,“导入绑定只读”不等于“导入的对象被冻结”。例如:
// options.mjs
export const options = { verbose: false };
// app.mjs
import { options } from "./options.mjs";
options.verbose = true;
console.log(options.verbose); // true
这次修改的是对象属性,并没有给导入名称 options 重新赋值。对象是否允许修改,仍取决于对象自身的约束。
3.3 模块命名空间与默认导出
ESM 还可以把一组导出放在模块命名空间对象下访问。沿用第 3.2 节的 counter.mjs,把调用文件改为:
// app.mjs
import * as counter from "./counter.mjs";
const { count } = counter;
counter.increment();
console.log(counter.count); // 1
console.log(count); // 0
读取 counter.count 会取得导出绑定的当前值;但 const { count } = counter 又回到了普通属性读取和局部变量初始化,所以这里的局部 count 保留了数字 0。使用 ESM,不意味着后续所有解构都自动具有实时绑定。
默认导出则是一个名为 default 的导出,导入方可以给它取自己的局部名称:
// add.mjs
export default function add(a, b) {
return a + b;
}
// app.mjs
import sum from "./add.mjs";
console.log(sum(1, 2)); // 3
这里还有一个与实时绑定相关的小区别:
// values.mjs
let count = 0;
export default count;
export { count as currentCount };
count = 1;
// app.mjs
import initialCount, { currentCount } from "./values.mjs";
console.log(initialCount); // 0
console.log(currentCount); // 1
export default count 在执行这一导出表达式时,取得 count 当时的值,并初始化默认导出;它没有把默认导出继续连接到局部变量 count。如果希望默认导出连接这个变量,应写成 export { count as default }。
这不推翻实时绑定:导入方依然连接着相应的导出绑定,只是这两种写法建立的默认导出不同。
3.4 动态导入与顶层 await
如果只有满足某个条件时才需要模块,就可以使用 import()。它是动态导入表达式,返回 Promise:
// app.mjs,继续使用第 3.2 节的 counter.mjs
const counter = await import("./counter.mjs");
counter.increment();
console.log(counter.count); // 1
Promise 兑现时取得模块命名空间对象。如果加载或求值失败,Promise 会拒绝。它可以放在函数或条件分支中,也能在 CommonJS 中使用;但不会因为再次调用就默认重新执行同一个模块,更不会自动把模块中的 CPU 计算转移到其他线程。
上例中的 await 写在模块顶层,而不是 async 函数内部。ESM 支持这样的顶层 await(Top-Level Await):
// config.mjs
import { setTimeout as delay } from "node:timers/promises";
console.log("config: 开始");
await delay(10);
export const port = 3000;
console.log("config: 就绪");
// app.mjs
import { port } from "./config.mjs";
console.log("app:", port);
输出依次为:
config: 开始
config: 就绪
app: 3000
config 的模块求值需要等待异步操作,依赖它的 app 也要等待相应的依赖求值完成后才执行模块体。等待期间不是整个 Node.js 进程被“冻住”了:事件循环仍可以处理其他可运行的工作。若把 await delay(10) 换成耗时的同步循环,同步循环照样会阻塞当前 JavaScript 线程。
因此,不能把两种模块系统简单概括成“CommonJS 是同步代码,ESM 是异步代码”。更准确的是:require() 是同步调用,ESM 静态依赖先建立链接再求值,import() 提供异步导入接口,顶层 await 则让模块图的求值可以包含异步等待。
4. 模块格式与路径解析
前面的例子明确写出了目标文件。但在真实项目中,我们还会看到 .js、包名,以及 package.json 中的各种字段。要理解这些规则,需要先区分两个问题:请求最终指向哪里,以及目标文件应该按什么模块格式解释。
4.1 .js 文件究竟属于哪一种模块
对于本文讨论的普通 JavaScript 文件,可以先记住显式规则:
| 文件 | 模块格式 |
|---|---|
.cjs |
CommonJS |
.mjs |
ESM |
.js,最近的父级 package.json 指定 "type": "commonjs" |
CommonJS |
.js,最近的父级 package.json 指定 "type": "module" |
ESM |
“最近的父级”包含文件所在目录。例如:
project/
├── package.json { "type": "module" }
├── app.js ESM
├── legacy.cjs CommonJS
└── tools/
├── package.json { "type": "commonjs" }
├── task.js CommonJS
└── helper.mjs ESM
tools/task.js 受 tools/package.json 控制,而不是一直向上寻找最外层项目的配置。.cjs 和 .mjs 则明确指定自身格式,不随 type 改变。依赖包也有自己的包作用域,不能把项目根目录的 type 当成整个 node_modules 的统一设置。
如果没有控制当前文件的 package.json,或者最近的 package.json 没有 type,情况并不是“.js 永远按 CommonJS 执行”。Node.js 24 会对这类未明确格式的输入进行语法检测:遇到静态 import、export、import.meta、顶层 await 等 ESM 专属语法时,可能将其识别为 ESM;普通的 CommonJS 代码仍按 CommonJS 处理。动态 import() 在两种模块中都合法,所以仅出现它不会迫使文件变成 ESM。
实际项目最好明确写出 type,或使用 .cjs、.mjs 表达例外,避免依赖隐式检测。这些格式规则以 Node.js 24 的包文档为准;本文不展开命令行字符串输入、自定义加载钩子和其他文件类型。
另外,调用方使用 require(),不等于目标必然是 CommonJS;使用 import,也不等于目标必然是 ESM。请求方式会影响解析规则和互操作方式,但不会把一个明确的 .cjs 文件改写成 ESM。第 6 节会给出相互加载的例子。
4.2 Node.js 怎样理解模块路径
导入语句里的字符串称为模块说明符(module specifier)。日常最常见的是下面几类:
| 请求 | 含义 |
|---|---|
node:fs |
Node.js 内置模块 |
./counter.mjs、../utils/add.cjs |
相对当前发起请求的模块定位文件 |
demo-kit |
请求一个包的主入口 |
demo-kit/math |
请求包提供的一个子路径 |
#config |
请求当前包在 imports 中定义的内部映射 |
相对模块路径通常不是相对 process.cwd()。假设从 project 目录执行 node src/app.cjs,那么 app.cjs 中的 require("./counter.cjs") 寻找的是 src/counter.cjs。相比之下,把普通相对路径交给 fs.readFileSync("./config.json") 时,通常是相对进程当前工作目录解释。模块解析与文件 API 的路径规则不能混为一谈。
对于第三方裸包名,Node.js 通常从发起请求的模块所在位置开始,沿父目录的 node_modules 查找。例如从 /project/src/app.cjs 请求 demo-kit,可能依次检查 /project/src/node_modules、/project/node_modules,再继续向上。找到包之后,还要遵守它的入口映射;内置模块、包自引用等也有各自的处理规则。
因此,同样写着 require("demo-kit"),来自不同目录的模块也可能找到不同安装位置的包。字符串相同,不代表最后得到的模块身份一定相同。
4.3 为什么有时可以省略扩展名,有时不行
CommonJS 的传统相对路径解析允许尝试补全文件名:
// 假设同目录下存在 counter.js
const counter = require("./counter");
如果没有精确匹配到文件,它可以尝试 .js、.json、.node 等传统候选,也支持传统的目录入口解析。但是,它不会因此自动尝试 .cjs 或 .mjs。如果文件叫 counter.cjs,应明确写 require("./counter.cjs")。
Node.js 原生 ESM 对相对和绝对文件路径采用更明确的规则:
import { count } from "./counter.mjs";
import { add } from "./utils/index.js";
这里不能依赖自动把 ./counter 补成 ./counter.mjs,或把 ./utils 补成 ./utils/index.js。这不代表裸包名也必须带扩展名:import "demo-kit" 走的是包解析,它可以由包的入口映射找到具体文件。
如果想检查 CommonJS 请求究竟会找到哪个文件,可以使用 require.resolve("./counter.cjs")。在 ESM 中,import.meta.resolve("./counter.mjs") 可以给出解析后的 URL,但对本地文件路径返回 URL 不等于已经确认目标文件存在,更不等于执行过模块。
4.4 main、exports 与 imports 分别管理什么
package.json 中的 main 是传统主入口字段;exports 可以同时定义主入口、公开子路径和条件入口;imports 则为包内部定义以 # 开头的映射。
例如,假设已经安装的 demo-kit 包包含下面的配置和对应文件:
{
"name": "demo-kit",
"type": "module",
"main": "./legacy.cjs",
"exports": {
".": "./index.js",
"./math": "./math.js"
},
"imports": {
"#config": "./internal/config.js"
}
}
从包外请求 demo-kit,会通过 exports 的 "." 进入 index.js,而不是 main 指定的 legacy.cjs。请求 demo-kit/math,会进入 math.js。两者都是 .js,再根据这个包的 "type": "module" 按 ESM 解释。
如果请求 demo-kit/internal/config.js,即使磁盘上有这个文件,也会因为它没有被 exports 公开而得到 ERR_PACKAGE_PATH_NOT_EXPORTED。定义了 exports 之后,不能把它理解为“找不到就继续回退到 main 或包内任意文件”。
包内模块则可以使用 import config from "#config" 访问内部映射,前提是目标文件确实提供默认导出。这个 #config 只属于定义它的包,不是整个 Node.js 进程的全局别名,也不是其他消费者项目自动继承的配置。
这里的 exports 字段还要与前面的 module.exports 分开:前者管理包路径能指向哪些入口,后者保存一个 CommonJS 模块对外提供的 JavaScript 值。名称相同,职责不同。
exports是包的公共接口边界,不是安全沙箱。它限制正常包说明符能访问的路径,并不等于隐藏磁盘文件,也不能替代权限控制。
4.5 Node.js 与浏览器的模块加载差异
浏览器和 Node.js 使用 ESM 时,命名导入、实时绑定等核心语言语义是一致的,主要区别在宿主如何定位和加载模块。
| 问题 | Node.js 原生模块环境 | 浏览器原生模块环境 |
|---|---|---|
| CommonJS | 原生支持 require()、module.exports |
默认不提供这套机制 |
| ESM 的入口与定位 | 文件、包入口、内置模块等 | 通过模块脚本加载,通常按 URL 获取 |
| 裸说明符如何解释 | Node.js 的包解析规则 | 通常需要 import map 等映射 |
是否读取项目的 node_modules、package.json#exports |
按 Node.js 规则使用 | 不原生执行这套 Node.js 包解析 |
浏览器不会自动把一个 URL 补成磁盘上的 .js 文件。如果服务器对一个无扩展名 URL 返回合法的 JavaScript 模块和正确的响应类型,它仍可能被加载;不能把 Node.js 本地文件的扩展名规则原样套在所有 HTTP URL 上。浏览器的映射机制可参考 HTML 标准中的 import map。
至于开发工具如何改写源码和组织构建产物,是另一层问题。本文只讨论直接交给 Node.js 的文件,不用框架项目中的运行结果反推 Node.js 原生规则。
5. 模块缓存与循环依赖
5.1 两次请求,会得到两个计数器吗
先看一个 CommonJS 模块:
// counter.cjs
console.log("counter: 初始化");
module.exports = { count: 0 };
// app.cjs
const first = require("./counter.cjs");
const second = require("./counter.cjs");
first.count += 1;
console.log(first === second); // true
console.log(second.count); // 1
counter: 初始化 只输出一次。两次请求解析到同一个模块,后一次复用缓存中的结果,first 和 second 因而引用同一个导出对象。
对普通 CommonJS 文件,缓存以解析后的文件名为依据,可以通过 require.cache 观察。ESM 则由自己的加载机制管理缓存,不通过 require.cache 操作,它以解析后的 URL 区分模块。例如:
// counter.mjs
console.log("counter: 初始化");
export const state = { count: 0 };
// app.mjs
const first = await import("./counter.mjs?version=1");
const again = await import("./counter.mjs?version=1");
const second = await import("./counter.mjs?version=2");
console.log(first === again); // true
console.log(first.state === second.state); // false
这里会输出两次初始化信息。相同 URL 的请求复用同一个模块,不同查询参数让 URL 不同,即使背后读取的是同一个文件,也会形成不同的 ESM 实例;片段部分的变化也会影响身份。
所以,“模块只执行一次”需要限定范围:在同一加载环境中,正常复用同一身份且已经成功初始化的模块时,其顶层代码通常不会再次执行。重复安装在不同位置的包、不同 URL、不同进程或 Worker,以及手动干预缓存等情况,都不能简单归入这句话。
共享模块状态不是跨进程单例。如果需要每次创建独立计数器,应导出一个 createCounter() 工厂函数并主动调用,而不是期待反复导入能产生新对象。也不应把不断添加 URL 参数当作没有成本的通用热重载方案。
5.2 CommonJS 循环依赖为什么可能读到半成品
假设 a 依赖 b,b 又依赖 a:
// a.cjs
exports.ready = false;
const b = require("./b.cjs");
exports.ready = true;
console.log("a: ready =", exports.ready);
console.log("a: b 记录的值 =", b.seenAReady);
// b.cjs
const a = require("./a.cjs");
exports.seenAReady = a.ready;
console.log("b: 看到 a.ready =", a.ready);
以 node a.cjs 启动,输出:
b: 看到 a.ready = false
a: ready = true
a: b 记录的值 = false
如果 Node.js 等一个模块完全执行完才登记缓存,这两个模块就会在相互请求中不断重复进入。实际的 CommonJS 加载过程会在执行模块体之前登记模块记录。当 b 再请求仍在执行中的 a 时,可以取得它当时尚未完成初始化的导出值。
在 b 读取时,a 只执行到了 exports.ready = false,所以读到 false;之后 a 把属性改成 true,但 b.seenAReady 已经保存了先前取得的布尔值,不会自动更新。
这里有两个同时起作用的机制:缓存允许返回尚未完成的导出,普通赋值又保存了读取当时的值。如果 a 后续不是修改旧对象,而是整体替换 module.exports,先前取得旧对象的模块也不会自动改为引用新对象。第 2 节的引用关系,在循环依赖里仍然成立。
5.3 ESM 有绑定,为什么仍会报错
ESM 可以在链接阶段建立循环依赖中的导入关系,但“已经连接到变量”不等于“变量已经初始化”。
// a.mjs
import { b } from "./b.mjs";
export const a = "A";
console.log(b);
// b.mjs
import { a } from "./a.mjs";
export const b = "B";
console.log(a);
执行 node a.mjs,会得到 ReferenceError: Cannot access 'a' before initialization。
在这个入口与依赖关系下,b 的模块体先开始执行;它读到的 a 绑定已经存在,但 a.mjs 中的 export const a = "A" 尚未执行,所以触发了暂时性死区。这与 JavaScript 变量声明和初始化的规则相连,并不是“ESM 不支持循环依赖”。
如果把读取推迟到初始化完成之后,这个例子就可以正常工作:
// a.mjs
import { readA } from "./b.mjs";
export const a = "A";
console.log(readA()); // A
// b.mjs
import { a } from "./a.mjs";
export function readA() {
return a;
}
b 在求值时只是提供函数,没有立即读取 a;等 a 初始化为 "A" 后再调用 readA(),读取才是安全的。
不过,“包进函数就行”并不是普遍修复方法。如果函数依然在过早的时机被调用,问题仍然存在;换成 var 或函数声明,也会因为初始化规则不同而出现其他结果。判断循环依赖,应追踪哪个绑定在什么时刻被读取。在业务设计上,把公共部分提取到第三个模块,或把相互依赖改为显式传入协作者,通常能让初始化关系更清楚。
6. CommonJS 与 ESM 的互操作
两套系统共存时,实际项目常常需要相互加载。这里仍以 Node.js v24.16.0 的默认行为为准,不把旧版本限制或转译器的特殊处理混进来。
6.1 ESM 怎样使用 CommonJS
最直接的方式是通过默认导入取得 CommonJS 的 module.exports:
// counter.cjs
exports.count = 0;
exports.increment = function increment() {
exports.count += 1;
};
// app.mjs
import counter, { count } from "./counter.cjs";
counter.increment();
console.log(counter.count); // 1
console.log(count); // 0
这个例子刻意同时使用了两种导入方式。默认导入 counter 取得导出对象,因此其属性变化可见。命名导入 count 则不是前面 ESM 导出变量的实时绑定:Node.js 尝试静态分析 CommonJS 源码,从一些常见写法中推断可用的导出名称,再把相应的属性值提供给 ESM。
这种推断并不能识别所有动态写法,也不会持续同步 CommonJS 对象属性的后续变化。不能因为这个例子支持 { count },就认为任意 module.exports 对象都能被可靠地命名导入。消费 CommonJS 包时,默认导入再访问属性通常更稳妥;若导出的是函数,默认导入拿到的就是函数。
默认导入也不是指向 module.exports 属性的永久追踪器:已经取得的导出对象不会因为 CommonJS 模块日后整体替换 module.exports 而自动换成新对象。上述行为可参考 Node.js 的 CommonJS 命名空间说明。
6.2 CommonJS 怎样使用 ESM
通用方式是使用动态 import()。CommonJS 不能直接写静态 import 声明,也不能直接使用顶层 await,但可以在异步函数中等待导入:
// app.cjs,使用第 3.4 节的 config.mjs
async function main() {
const { port } = await import("./config.mjs");
console.log("app:", port);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
这里能够等待 config.mjs 中的顶层 await 完成。解构发生在动态导入完成之后,得到的 port 是普通局部变量,不是静态命名导入绑定。
在本文验证的 Node.js 24 版本中,require() 也可以同步加载满足条件的 ESM:
// math.mjs
export const version = 1;
export default function add(a, b) {
return a + b;
}
// app.cjs
const math = require("./math.mjs");
console.log(math.version); // 1
console.log(math.default(1, 2)); // 3
通常返回模块命名空间对象,默认导出放在 .default 下,而不是让 require() 自动只返回默认导出。Node.js 还提供名为 "module.exports" 的特殊 ESM 导出用于定制这一互操作结果,本文的例子不使用该定制能力。
同步加载的关键限制是:目标 ESM 及其静态依赖图不能包含顶层 await。 如果把上面的请求换成 require("./config.mjs"),第 3.4 节的异步配置模块会触发 ERR_REQUIRE_ASYNC_MODULE,需要改用 import()。即使顶层 await 只出现在更深一层的静态依赖中,也会影响同步加载。
导出一个 async function 并不等于模块包含顶层 await:只有以后调用这个函数时才开始的异步工作,不会仅凭函数声明就让整个模块图变为异步求值。详细边界见 Node.js 的 require(ESM) 文档。
6.3 ESM 中为什么还会出现 createRequire
ESM 可以直接导入 CommonJS,但两种加载接口的能力并不完全相同。例如,在本文采用的 Node.js 24 默认配置下,import 不能直接加载以 .node 为扩展名的已编译原生扩展,而 require() 可以。遇到这类需求时,可以通过 Node.js 提供的 createRequire(),在 ESM 中构造一个 require 函数。相关限制可参考 Node.js 的原生扩展加载说明。
下面沿用第 6.1 节的计数器演示 createRequire() 的基本用法。这个普通的 .cjs 文件也可以直接通过默认导入使用,并不要求改用 createRequire():
// app.mjs,使用第 6.1 节的 counter.cjs
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const counter = require("./counter.cjs");
counter.increment();
console.log(counter.count); // 1
import.meta.url 指定这个 require 解析相对路径的起点。app.mjs 仍然是 ESM,createRequire() 只是让它能够显式使用 require() 的解析与加载规则。
到这里,可以把常见互操作方式归纳为:
| 调用方 → 目标 | 常见方式 | 重点 |
|---|---|---|
| CommonJS → CommonJS | require() |
取得 module.exports |
| ESM → ESM | 静态 import 或动态 import() |
静态命名导入连接绑定;动态导入兑现为命名空间 |
| ESM → CommonJS | 默认 import |
取得 CommonJS 导出值;合成命名导出存在限制 |
| CommonJS → ESM | 动态 import() |
可以等待异步模块求值 |
| CommonJS → 同步 ESM 图 | Node.js 24 中的 require() |
同步返回,通常是命名空间;不能含顶层 await |
6.4 同一个包为什么可能有两份状态
第 4 节介绍了 exports 的路径映射,它还可以根据请求条件选择入口:
{
"name": "demo-kit",
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.cjs",
"default": "./index.mjs"
}
}
}
在这个配置中,静态 import 或动态 import() 选择 import 分支,require() 选择 require 分支。default 是通用后备条件,通常放在最后;条件对象的键顺序有意义,不能随意调换。
注意,分支名称描述的是匹配条件,不是强制转换目标格式。require 分支也可以指向一个满足同步加载条件的 .mjs 文件,目标格式仍要按文件自身的规则判断。
如果 index.mjs 与 index.cjs 分别创建自己的计数器状态,它们就是不同入口、不同实例。在同一个进程中,一部分代码通过 import 使用一份状态,另一部分通过 require 使用另一份状态,虽然两边都说自己在使用 demo-kit,却不一定共享计数器。
这是包入口和缓存身份共同作用的结果,不是两套模块系统每次互操作都会复制状态。让两个入口包装同一份有状态实现,就可能共享它;但这需要包作者明确设计,不能由“包名相同”推断出来。
7. 总结:沿着一次模块请求理解整个过程
现在重新看最初的程序:
const counter = require("./counter.cjs");
调用方发出请求,Node.js 根据发起位置和相应解析规则定位目标。.cjs 明确了目标格式;若需要首次加载,就建立模块记录,并在 CommonJS 包装作用域中执行代码。模块通过 module.exports 提供值,调用方取得后按照普通 JavaScript 规则使用它。后续请求若命中同一缓存身份,通常复用已有结果。
再看另一种写法:
import { count } from "./counter.mjs";
这里声明的是静态依赖。Node.js 加载相关模块,ESM 机制建立导入与导出的链接,再对模块图求值。导入名称连接到导出绑定;读到什么既取决于绑定当前的值,也取决于它是否已经初始化。动态导入和顶层 await 会影响请求发生的时机以及求值何时完成。
两种机制的区别,可以用下面几组问题来记忆:
| 问题 | CommonJS | ESM |
|---|---|---|
| 依赖如何表达 | 执行过程中调用 require() |
静态 import 声明依赖,也支持动态 import() |
| 对外提供什么 | module.exports 保存的值 |
按名称组织的导出绑定,包括可选的 default |
| 导入后如何读取 | 取得普通 JavaScript 值,再进行属性读取和赋值 | 静态导入连接绑定;命名空间读取反映相应导出 |
| 怎样复用 | 普通文件按解析后的文件名缓存 | 按解析后的 URL 管理模块身份 |
| 循环依赖要关注什么 | 可能取得尚未完成的导出值 | 已连接的绑定可能尚未初始化 |
尤其不要用“CommonJS 是值拷贝,ESM 是引用拷贝”替代这些机制。数字的赋值、对象引用的传递、对象属性的变化,与模块导入绑定,是几个相关但不同的问题。把它们分别看清楚,exports 为什么失效、解构为什么不更新、循环依赖为什么读到半成品,就都能找到具体原因。