Skip to content

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 模块的循环加载

  1. CJS (CommonJS) 的加载逻辑

同步 + 运行时: require() 是一个函数调用,执行到这行才真正去加载并执行目标模块。

模块缓存: 如果一个模块正在执行(exports 还没准备好),require() 会立刻返回当前的 exports 对象引用。这个对象此时可能是个半成品(部分属性赋值了,部分还没来得及赋值)。

值传递: 由于 exports 是普通对象,引用的是当时的快照,所以其他模块不会随着导出变量的变化而“自动更新”。

👉 可以理解为:CJS 没有“初始化阶段”,加载和执行是混在一起的。

  1. ESM (ES Modules) 的加载逻辑

编译时(Initialization Phase): JS 引擎在解析模块时,会先建立 模块依赖图,并且为每个 export 建立 binding 槽位,但不赋值。

相当于提前把所有符号关系画出来。

运行时(Evaluation Phase): 才去逐个执行模块,把值写进 binding。

import 进来的变量是一个“引用”(live binding),所以即使之后赋值,导入方也能拿到最新值。

👉 可以理解为:ESM 把“依赖解析(符号表建立)”和“代码执行”严格区分开了。

  1. 类比

CJS:像是 一边走路一边铺砖,走到 require 才去执行并产出 exports,所以循环时可能踩空(拿到未完成的 exports)。

ESM:像是 先把地基和格子画好(初始化阶段),每个 export 都有“占位符”,然后再逐个往里面填值(执行阶段),所以循环引用时至少有个“壳子”,不会拿不到。

  1. 总结的话

CJS 确实“没有初始化阶段”,只有“运行时加载 + 缓存半成品对象”。

ESM 有一个显式的“初始化阶段”,在代码执行之前就完成了依赖图和绑定的建立,所以循环引用可以安全成立。