人人都会AI编程

与 CommonJS 的核心差异

更新时间:2026-07-11

ES Modules(ESM)是 ECMAScript 官方推出的模块化标准,而 CommonJS 是 Node.js 在早期自行实现的模块规范。两者虽然都用于拆分代码、组织模块,但在设计理念和底层机制上存在着根本性的差异。理解这些差异,不仅能避免日常开发中的诡异错误,也有助于做出正确的模块化方案选型。

语法层面的直观差异

导出方式不同
CommonJS 使用 module.exportsexports 导出:

// CommonJS 导出
module.exports = { foo: 1 };
// 或者
exports.bar = 2;

ESM 使用 export 关键字,支持命名导出和默认导出:

// ESM 导出:命名导出
export const foo = 1;
export function bar() {}

// ESM 导出:默认导出
export default function() {}

导入方式不同
CommonJS 使用 require() 函数:

const moduleA = require('./moduleA');

ESM 使用 import 语句:

// 命名导入
import { foo, bar } from './moduleA';
// 默认导入
import myFunc from './moduleA';
// 整体导入
import * as moduleA from './moduleA';

关键设计差异:静态 vs 动态

这是最核心的区别。CommonJS 是动态的、运行时的require() 就是一个普通的函数,可以在任何位置调用,甚至根据条件决定加载什么模块:

// CommonJS 动态加载:完全合法
if (condition) {
  const module = require('./dynamicModule');
  module.doSomething();
}

ESM 是静态的、编译时的import 语句必须出现在模块顶层,不能被包裹在条件、循环或函数中。这使得 JavaScript 引擎在解析阶段就能确定模块间的依赖关系,从而实现 tree shaking(死代码消除)、静态分析和更高效的加载优化。

// ESM:以下写法会直接报错
if (condition) {
  import { something } from './dynamicModule'; // SyntaxError
}

ESM 也提供了动态导入的补充方案——import() 函数,它返回一个 Promise,可以在运行时按需加载模块,但这属于动态导入,不能完全替代静态 import 的顶层声明。

值拷贝与动态绑定

CommonJS 导出的是值的拷贝。一旦模块被 require() 加载,导出的值就被复制了一份。后续原始模块内部修改这个值,导入方拿到的仍然是当初的拷贝,不会跟着变化。

// CommonJS 模块 counter.js
let count = 0;
module.exports = { count, increment: () => ++count };

// 使用方
const counter = require('./counter');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 仍然是 0

ESM 导出的是动态绑定(live binding),导入方拿到的实际上是一个指向原始模块内部变量的“引用”。当原始模块内部的值发生变化时,导入方会实时反映该变化。

// ESM 模块 counter.mjs
export let count = 0;
export function increment() { count++; }

// 使用方
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1,动态更新

这一差异在实际开发中意义重大。CommonJS 的拷贝机制容易造成数据不一致,而 ESM 的动态绑定则保证了模块间的状态同步,特别适合需要单一数据源(如状态管理)的场景。

对循环引用的处理方式不同

两个模块互相引用(循环依赖)时,两者的行为不同。

CommonJS 对循环引用的处理是已执行部分可见。当模块 A 在加载过程中去 require 模块 B,而模块 B 又 require 模块 A 时,Node.js 会返回模块 A 当前已经执行过的导出对象(即便模块 A 还未执行完),不会陷入死锁。这往往导致导入方拿到的可能是“未完工”的对象,出现 undefined 属性,是一个经典的坑点。

ESM 得益于静态结构,引擎能够在解析阶段就检测出循环依赖,并确保动态绑定正常工作。即使存在循环引用,只要处理得当,导出的绑定最终都能正确解析到最终值,不会出现“未完工”对象。

this 值的区别

CommonJS 模块顶层 this 指向当前模块的 exports 对象(即 module.exports)。

// CommonJS
this === module.exports; // true

ESM 模块顶层 thisundefined。这强制了模块代码不能依赖隐式的全局上下文,增强了代码的确定性。

加载阶段与顺序

CommonJS 模块是运行时同步加载require() 执行时,会立即加载并执行整个模块,然后返回导出对象。这个过程是串行的,阻塞后续代码。

ESM 模块的加载分为三个阶段:

  1. 解析(Parsing):分析所有 importexport,构建模块依赖图。
  2. 实例化(Instantiation):为每个模块分配内存空间,绑定导出/导入的引用关系。
  3. 求值(Evaluation):按照依赖顺序执行模块顶层代码。

这意味着 ESM 加载是异步的,浏览器可以并行下载多个模块,并且只有在依赖链全部解析完成后才开始执行模块体。这种机制天然支持异步加载,更适合浏览器端的网络环境。

互操作性与现实考量

Node.js 起初只支持 CommonJS,但后来逐步引入了对 ESM 的支持(通过 .mjs 扩展名或在 package.json 中设置 "type": "module")。然而,两者混用仍有很多坑:CommonJS 不能直接 require ESM 模块,ESM 可以使用 import 加载 CommonJS 模块,但只能整体导入,拿到的对象是 module.exports 的默认导出。

目前的开发生态正在向 ESM 迁移,许多新项目和工具链(如 Vite)都已默认采用 ESM。学习并适应 ESM,不仅是为了跟上标准演进,也是享受静态分析与 tree shaking 等工程化红利的前提。

总结对比表

| 特性 | CommonJS | ES Modules |
|------|----------|------------|
| 语法 | require() / module.exports | import / export |
| 加载时机 | 运行时动态加载 | 编译时静态分析(引擎支持) |
| 导出内容 | 值的拷贝 | 动态绑定(引用) |
| 加载方式 | 同步阻塞 | 异步加载(浏览器) |
| 顶层 this | 指向 module.exports | undefined |
| 循环依赖 | 返回已执行部分,易出错 | 支持动态绑定,行为更可预测 |
| 静态分析 | 不支持 | 支持,利于 Tree Shaking |
| 使用场景 | Node.js 传统项目 | 现代 Web 开发,新标准推荐 |

理解这些差异,你就能在项目中根据实际需求选择正确的模块方案,同时在遇到跨规范的问题时迅速定位原因。