人人都会AI编程

ESM 与 CommonJS 的核心差异

更新时间:2026-07-10

Node.js 的模块系统经历了一次重大转折:从早期完全基于 CommonJS 规范,到如今全面支持 ES Modules(ESM)。两种模块系统可以并存,但它们的底层机制、使用方式和设计哲学存在本质区别。理解这些差异,不仅能避免实际开发中的坑,也有助于在项目中选择合适的模块方案。

1. 语法层面:require vs import

CommonJS 使用 require() 函数加载模块,使用 module.exportsexports 导出成员:

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

// app.js
const math = require('./math');
console.log(math.add(1, 2));

ESM 则使用 importexport 关键字,属于语言层面的静态语法:

// 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 中,顶层 thisundefined,这与浏览器中 type="module" 的脚本行为一致。

7. dirnamefilename 的可用性

CommonJS 默认提供了 dirnamefilename 这两个全局变量,分别表示当前模块所在目录和文件的绝对路径。它们在开发中经常用来定位静态资源或读取配置。

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 模块系统全貌的关键一步。它决定了代码的解析规则、加载性能和迁移路径,也是面试中高频的知识点。在下一小节中,我们将深入模块互操作的细节和条件导出的实际应用。