Node.js 的模块系统经历了一次重大转折:从早期完全基于 CommonJS 规范,到如今全面支持 ES Modules(ESM)。两种模块系统可以并存,但它们的底层机制、使用方式和设计哲学存在本质区别。理解这些差异,不仅能避免实际开发中的坑,也有助于在项目中选择合适的模块方案。
1. 语法层面:require vs import
CommonJS 使用 require() 函数加载模块,使用 module.exports 或 exports 导出成员:
// math.js
const add = (a, b) => a + b;
module.exports = { add };
// app.js
const math = require('./math');
console.log(math.add(1, 2));
ESM 则使用 import 和 export 关键字,属于语言层面的静态语法:
// math.mjs 或 package.json 中 "type": "module"
export const add = (a, b) => a + b;
// app.mjs
import { add } from './math.js';
console.log(add(1, 2));
语法上的差异带来的最直接影响是:CommonJS 的 require 可以在条件语句中动态调用,而 ESM 的 import 语句必须放在模块顶层,不能在运行时按需加载。例如:
// CommonJS 动态加载 — 合法
if (condition) {
const module = require('./special');
}
// ESM 静态语法 — 非法
if (condition) {
import { something } from './special'; // ❌ SyntaxError
}
如果需要动态加载,ESM 提供了 import() 函数,它返回一个 Promise,可以在异步流程中使用。
2. 加载时机:运行时 vs 编译时
CommonJS 模块的加载发生在代码运行时。当执行到 require() 时,Node.js 会同步地解析路径、读取文件、执行模块代码,然后返回 module.exports。这种机制意味着依赖关系只有在实际执行那一刻才能确定,无法在运行前进行静态分析。
ESM 的 import 声明在设计上支持静态分析。现代 JavaScript 引擎可以在解析代码时识别出模块依赖图,而不必执行代码。这是 Tree Shaking 等技术能够实现的基础——打包工具(如 Webpack、Rollup)可以分析哪些导出被实际使用,然后删除未引用的代码。对 Node.js 运行时而言,静态 import 同样有助于优化模块加载顺序和性能。
3. 值的绑定:拷贝 vs 动态引用
这是两种模块系统最容易被忽视但实际影响很大的差异。
CommonJS 导出的是值的拷贝。当通过 require() 导入一个模块时,得到的是 module.exports 对象的一个引用,但基本类型的值已经被复制了一份。如果被导出的变量在原始模块中后来发生了变化,导入方并不会感知到:
// counter.js
let count = 0;
const increment = () => count++;
module.exports = { count, increment };
// main.js
const counter = require('./counter');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 仍然是 0,因为 count 是原始模块值的拷贝
ESM 导出的是值的动态绑定。导入方实际上持有对原始模块内部变量的“活绑定”,当原模块的值变化时,导入方看到的值也会随之更新:
// counter.mjs
export let count = 0;
export const increment = () => count++;
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1,因为 count 是活绑定
这种动态绑定机制使得 ESM 在需要共享可变状态时更加直观。
4. 对循环引用的处理结果不同
CommonJS 和 ESM 都支持循环引用,但处理方式不同导致运行时表现差异很大。
CommonJS 的循环引用解决方案是“提前返回半成品”。当 require() 遇到一个已经加载但尚未执行完的模块时,Node.js 会返回当前已导出的部分内容(module.exports 的当前快照)。这可能导致循环引用的模块获取到未初始化的值。
ESM 利用静态分析预先建立模块依赖图,并采用“先构建、再执行”的策略。引擎会扫描所有 import 语句,先确定模块之间的依赖关系,然后按依赖顺序初始化导出绑定,再执行模块主体代码。如果出现循环引用,ESM 通过导出“活绑定”保证即便执行还未完成,引用方也能在将来访问到最终完成的值。这样就避免了 CommonJS 中获取到未定义属性的常见陷阱。
5. 文件扩展名与 package.json 配置
Node.js 通过文件扩展名和 package.json 的 "type" 字段来区分模块类型:
.mjs扩展名始终被当作 ESM 处理。.cjs扩展名始终被当作 CommonJS 处理。.js文件的行为取决于最近的package.json中的"type"字段:"type": "module"→ 当作 ESM。"type": "commonjs"或未设置 → 当作 CommonJS(默认)。
在 ESM 模式下,必须使用完整的文件扩展名进行导入,即 import './utils.js' 而不是 import './utils',因为 ESM 不会自动补全扩展名。而 CommonJS 的 require() 可以省略 .js 或目录下的 index.js。
6. 顶层 this 的值
在 CommonJS 模块中,顶层 this 指向当前模块的 module.exports 对象:
// CommonJS
console.log(this === module.exports); // true
在 ESM 中,顶层 this 是 undefined,这与浏览器中 type="module" 的脚本行为一致。
7. dirname 和 filename 的可用性
CommonJS 默认提供了 dirname 和 filename 这两个全局变量,分别表示当前模块所在目录和文件的绝对路径。它们在开发中经常用来定位静态资源或读取配置。
ESM 中没有这两个全局变量,需要使用 import.meta 对象来模拟:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
8. 互操作性限制
Node.js 在一定程度上允许 ESM 和 CommonJS 互相导入,但有严格的限制:
- ESM 可以导入 CommonJS 模块:
import语句可以将module.exports对象作为默认导入使用。
import pkg from './commonjs-module.cjs';
// 或者解构:import { func } from './commonjs-module.cjs';
但解构只适用于 module.exports 是普通对象且属性在静态分析时可确定的情况。Node.js 会将 CommonJS 的 module.exports 视为默认导出。
- CommonJS 不能同步导入 ESM 模块:
require()无法直接加载 ESM 文件,因为 ESM 需要异步加载。只能通过动态import()来异步加载:
// CommonJS 文件中
import('./esm-module.mjs').then(mod => { ... });
这一单向限制意味着,如果项目正在从 CommonJS 迁移到 ESM,需要注意到某些纯 CommonJS 的工具或配置可能暂时无法引用新的 ESM 模块。
实际选型建议
- 新项目:建议默认使用 ESM(
"type": "module"),享受静态分析、Tree Shaking、活绑定等现代特性。 - 老项目或依赖大量 CommonJS 生态:保持 CommonJS 更稳妥,或者渐进式迁移:将部分模块改为 ESM,确保 CommonJS 使用动态
import()导入它们。 - 库的作者:如果发布 npm 包,可以提供双模块格式(同时输出
.cjs和.mjs),并在package.json中通过exports字段条件导出,让使用者按需加载。
理解 ESM 与 CommonJS 的核心差异,是掌握 Node.js 模块系统全貌的关键一步。它决定了代码的解析规则、加载性能和迁移路径,也是面试中高频的知识点。在下一小节中,我们将深入模块互操作的细节和条件导出的实际应用。