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