ESM 和 CJS 的区别
| 特性 | CJS(CommonJS) | ESM(ES 模块 / ES Modules) |
|---|---|---|
| 语法 | const x = require('mod') / module.exports = … / exports.foo = … | import { x } from './mod.js' / export function foo() {} / export default … |
| 加载时机 / 静态分析 | 运行时加载,也就是说 require() 是在代码执行时才会去加载模块。 | 静态 / 编译时阶段可以确定依赖关系,import / export 在加载前就可被分析。 |
| 同步 vs 异步 | require() 是同步的(阻塞式) | import 在底层支持异步(虽然具体执行顺序与环境有关),并且支持 import() 动态加载。 |
| 引用 vs 拷贝 | 导出的是值的 拷贝(当模块一开始执行结束后就把值取出来导出) | 导出的是 引用 / 绑定(所谓 “live binding”),也就是说如果模块内部改变了被导出的变量,外部 import 的也能感知到(在允许的情况下)。 |
| 循环依赖处理 | 在循环依赖中,可能拿到的只是部分执行过的模块内容(未完全初始化的部分) | 因为是静态绑定 / 引用性质,循环依赖时的行为更可预测(但也要小心设计) |
| 文件扩展 / 模块识别 | 默认 .js 扩展名、Node 会有模块解析策略(省略扩展名、目录 index.js 等) | 在 ESM 中通常要求明确完整的文件名(包括扩展名)和路径(在某些环境中) |
| 在 Node 中的支持 / 标识 | Node 默认支持 CJS(除非你特别指定) | 在 Node 中使用 ESM 时,需要配置:要么文件后缀 .mjs,要么在 package.json 中 "type": "module";否则 Node 会把 .js 当作 CJS 来处理。 |
| 兼容性 / 混用 | 在 CJS 中可以随意用 require()、条件加载、动态路径等 | 在 ESM 中不能在顶层随意放 import() 或者条件 import(静态解析限制) |
| 优化 / Tree Shaking | 由于是动态加载,很多工具对 CJS 做 tree shaking 优化较困难 | 静态结构使得打包器可以更容易做“未用代码消除”(tree shaking)等优化 |
顶层 await | 不支持(除非在异步函数内部) | 在 ESM 环境中(支持的运行时/构建环境)可以支持顶层的 await |
1.1 循环引用
参考文章:JavaScript 模块的循环加载
- CJS (CommonJS) 的加载逻辑
同步 + 运行时: require() 是一个函数调用,执行到这行才真正去加载并执行目标模块。
模块缓存: 如果一个模块正在执行(exports 还没准备好),require() 会立刻返回当前的 exports 对象引用。这个对象此时可能是个半成品(部分属性赋值了,部分还没来得及赋值)。
值传递: 由于 exports 是普通对象,引用的是当时的快照,所以其他模块不会随着导出变量的变化而“自动更新”。
👉 可以理解为:CJS 没有“初始化阶段”,加载和执行是混在一起的。
- ESM (ES Modules) 的加载逻辑
编译时(Initialization Phase): JS 引擎在解析模块时,会先建立 模块依赖图,并且为每个 export 建立 binding 槽位,但不赋值。
相当于提前把所有符号关系画出来。
运行时(Evaluation Phase): 才去逐个执行模块,把值写进 binding。
import 进来的变量是一个“引用”(live binding),所以即使之后赋值,导入方也能拿到最新值。
👉 可以理解为:ESM 把“依赖解析(符号表建立)”和“代码执行”严格区分开了。
- 类比
CJS:像是 一边走路一边铺砖,走到 require 才去执行并产出 exports,所以循环时可能踩空(拿到未完成的 exports)。
ESM:像是 先把地基和格子画好(初始化阶段),每个 export 都有“占位符”,然后再逐个往里面填值(执行阶段),所以循环引用时至少有个“壳子”,不会拿不到。
- 总结的话
CJS 确实“没有初始化阶段”,只有“运行时加载 + 缓存半成品对象”。
ESM 有一个显式的“初始化阶段”,在代码执行之前就完成了依赖图和绑定的建立,所以循环引用可以安全成立。
