CommonJS 与 ES Modules

1. 核心区别总结

特性CommonJS (CJS)ES Modules (ESM)
语法require / module.exportsimport / export
加载时机运行时加载 (Runtime)编译时输出接口 (Compile-time)
输出结果值的拷贝 (浅拷贝)值的引用 (Live Binding)
this 指向当前模块 (module.exports)undefined
Tree Shaking不支持支持 (静态分析)

2. 详细解析

(1) 值的拷贝 vs 值的引用 (高频面试题)

CommonJS: 一旦输出一个值,模块内部的变化就影响不到这个值了(除非是引用类型)。

// counter.js
var count = 1;
function inc() { count++; }
module.exports = { count, inc };

// main.js
var mod = require('./counter');
mod.inc();
console.log(mod.count); // 1 (仍然是 1,因为是拷贝)

ESM: JS 引擎对脚本静态分析的时候,遇到模块加载命令 import,就会生成一个只读引用。等到脚本真正执行时,再根据这个引用,到被加载的那个模块里面去取值。

// counter.js
export let count = 1;
export function inc() { count++; }

// main.js
import { count, inc } from './counter';
inc();
console.log(count); // 2 (实时反映变化)

(2) 运行时 vs 编译时

  • CommonJS: 加载是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成。所以无法在编译时做 Tree Shaking。
  • ESM: import 命令会被 JavaScript 引擎静态分析,优先于模块内的其他内容执行。这使得编译器能够分析出哪些功能没有被使用,从而删除死代码 (Tree Shaking)。

3. 循环依赖

循环依赖是指模块 A 依赖模块 B,同时模块 B 又依赖模块 A:

A -> B -> A

模块加载器会缓存已经开始加载的模块,所以循环依赖通常不会造成无限递归。真正需要关注的是:当前读取的值是否已经初始化

(1) CommonJS:可能拿到“半成品”

CommonJS 在执行模块代码前,会先将该模块放入缓存。如果执行期间再次 require 到该模块,返回的是它当前已经执行完成的导出结果

// a.cjs
const b = require('./b.cjs');
exports.name = 'A';

// b.cjs
const a = require('./a.cjs');
console.log(a.name); // undefined
exports.name = 'B';

// main.cjs
require('./a.cjs');

执行过程:

  1. main.cjs 加载 A,A 执行到 require('./b.cjs') 时暂停,转去执行 B。
  2. B 又加载 A,此时 A 已经在缓存中,因此 B 得到 A 当前的 exports 对象。
  3. A 的 exports.name = 'A' 尚未执行,所以 B 在此时读取 a.name 得到 undefined
  4. B 执行完毕后回到 A,A 才继续初始化 name

CommonJS 循环依赖的关键:require 可能返回正在执行的模块的“半成品”。

(2) ESM:先建立绑定,再执行模块

ESM 处理模块时,可以简化为两个阶段:

  1. 建立连接:在执行模块代码前,静态分析 import / export,构建依赖图,并在导入名称与导出变量之间建立绑定。
  2. 执行模块:从入口模块开始,先求值它的依赖,再执行模块自身的顶层代码。

导入方拿到的不是值的拷贝,而是指向导出变量的只读实时绑定(Live Binding)。但“绑定已经建立”只表示引擎知道该去哪里取值,不代表导出变量的初始化代码已经执行。

// a.mjs
import './b.mjs';
export const name = 'A';

// b.mjs
import { name } from './a.mjs';
console.log(name);

这组模块的依赖关系是相同的,但从不同入口启动时,模块的实际求值顺序不同。

以 A 为入口:报错

node a.mjs

尽管 A 是入口,引擎仍会先求值 A 的依赖 B。B 又依赖正在求值的 A,引擎不会重复进入 A,而是继续执行 B 的顶层代码:

A 作为入口 -> 先求值 B -> B 读取 A.name -> 再执行 A 的模块代码

B 读取 name 时,A 还没有执行 export const name = 'A'。此时绑定虽然存在,但 name 仍未初始化,处于暂时性死区,因此抛出:

ReferenceError: Cannot access 'name' before initialization

以 B 为入口:正常输出

node b.mjs

此时引擎先求值 B 的依赖 A。A 又依赖正在求值的 B,所以引擎直接执行 A 的顶层代码,初始化 name,然后再回到 B:

B 作为入口 -> 先求值 A -> A 初始化 name -> B 读取 A.name

因此 B 可以正常输出 A

更稳妥的写法:延迟读取

不要在模块顶层立即读取循环依赖中的变量,可以将读取延迟到函数真正调用时:

// a.mjs
import { printA } from './b.mjs';

export const name = 'A';
printA();

// b.mjs
import { name } from './a.mjs';

export function printA() {
  console.log(name); // A
}

引擎执行 B 时只创建 printA,没有立即读取 name。等到 A 初始化 name 后再调用 printA(),就能通过实时绑定读取到最新值。

ESM 循环依赖的关键:静态分析先确定“值来自哪里”,实时绑定确定“以后去哪里取最新值”,而实际求值顺序决定“现在能不能读取这个值”。绑定已建立,不代表变量已初始化。

(3) 对比总结

模块规范循环依赖时拿到的内容过早读取的典型结果
CommonJS当前的 module.exports 对象可能得到 undefined
ESM指向导出变量的实时绑定可能触发暂时性死区的 ReferenceError

循环依赖并不一定会报错,但会让初始化顺序变得难以推理。实际开发中应尽量通过抽取共享模块、依赖倒置或延迟调用来消除循环依赖。

4. 兼容性与互操作

在 Node.js 中:

  • .mjs 文件总是被视为 ESM。
  • .cjs 文件总是被视为 CommonJS。
  • .js 文件取决于 package.json 中的 "type": "module" 字段。

混用陷阱

  • ESM 中可以使用 import cjs from 'cjs-module' (默认导出)。
  • CommonJS 中不能直接 require ESM (因为 ESM 是异步的,CJS 是同步的),通常需要使用 await import().