CommonJS 与 ES Modules
1. 核心区别总结
2. 详细解析
(1) 值的拷贝 vs 值的引用 (高频面试题)
CommonJS: 一旦输出一个值,模块内部的变化就影响不到这个值了(除非是引用类型)。
ESM:
JS 引擎对脚本静态分析的时候,遇到模块加载命令 import,就会生成一个只读引用。等到脚本真正执行时,再根据这个引用,到被加载的那个模块里面去取值。
(2) 运行时 vs 编译时
- CommonJS: 加载是一个对象(即
module.exports属性),该对象只有在脚本运行完才会生成。所以无法在编译时做 Tree Shaking。 - ESM:
import命令会被 JavaScript 引擎静态分析,优先于模块内的其他内容执行。这使得编译器能够分析出哪些功能没有被使用,从而删除死代码 (Tree Shaking)。
3. 循环依赖
循环依赖是指模块 A 依赖模块 B,同时模块 B 又依赖模块 A:
模块加载器会缓存已经开始加载的模块,所以循环依赖通常不会造成无限递归。真正需要关注的是:当前读取的值是否已经初始化。
(1) CommonJS:可能拿到“半成品”
CommonJS 在执行模块代码前,会先将该模块放入缓存。如果执行期间再次 require 到该模块,返回的是它当前已经执行完成的导出结果。
执行过程:
main.cjs加载 A,A 执行到require('./b.cjs')时暂停,转去执行 B。- B 又加载 A,此时 A 已经在缓存中,因此 B 得到 A 当前的
exports对象。 - A 的
exports.name = 'A'尚未执行,所以 B 在此时读取a.name得到undefined。 - B 执行完毕后回到 A,A 才继续初始化
name。
CommonJS 循环依赖的关键:
require可能返回正在执行的模块的“半成品”。
(2) ESM:先建立绑定,再执行模块
ESM 处理模块时,可以简化为两个阶段:
- 建立连接:在执行模块代码前,静态分析
import/export,构建依赖图,并在导入名称与导出变量之间建立绑定。 - 执行模块:从入口模块开始,先求值它的依赖,再执行模块自身的顶层代码。
导入方拿到的不是值的拷贝,而是指向导出变量的只读实时绑定(Live Binding)。但“绑定已经建立”只表示引擎知道该去哪里取值,不代表导出变量的初始化代码已经执行。
这组模块的依赖关系是相同的,但从不同入口启动时,模块的实际求值顺序不同。
以 A 为入口:报错
尽管 A 是入口,引擎仍会先求值 A 的依赖 B。B 又依赖正在求值的 A,引擎不会重复进入 A,而是继续执行 B 的顶层代码:
B 读取 name 时,A 还没有执行 export const name = 'A'。此时绑定虽然存在,但 name 仍未初始化,处于暂时性死区,因此抛出:
以 B 为入口:正常输出
此时引擎先求值 B 的依赖 A。A 又依赖正在求值的 B,所以引擎直接执行 A 的顶层代码,初始化 name,然后再回到 B:
因此 B 可以正常输出 A。
更稳妥的写法:延迟读取
不要在模块顶层立即读取循环依赖中的变量,可以将读取延迟到函数真正调用时:
引擎执行 B 时只创建 printA,没有立即读取 name。等到 A 初始化 name 后再调用 printA(),就能通过实时绑定读取到最新值。
ESM 循环依赖的关键:静态分析先确定“值来自哪里”,实时绑定确定“以后去哪里取最新值”,而实际求值顺序决定“现在能不能读取这个值”。绑定已建立,不代表变量已初始化。
(3) 对比总结
循环依赖并不一定会报错,但会让初始化顺序变得难以推理。实际开发中应尽量通过抽取共享模块、依赖倒置或延迟调用来消除循环依赖。
4. 兼容性与互操作
在 Node.js 中:
.mjs文件总是被视为 ESM。.cjs文件总是被视为 CommonJS。.js文件取决于package.json中的"type": "module"字段。
混用陷阱:
- ESM 中可以使用
import cjs from 'cjs-module'(默认导出)。 - CommonJS 中不能直接
requireESM (因为 ESM 是异步的,CJS 是同步的),通常需要使用await import().

