CommonJS和ES的模块化标准分析
前言
CJS和ESM是两种常见的模块化标准,本文首先从性质,语法差异,加载机制,对动态导入的支持,导出值的类型,循环依赖的处理,以及兼容性7个方面分析了CJS和ESM的异同,然后得出了CJS和ESM互操作的最佳实践的建议,最后补充了ESM中静态import的异步和同步的理解.
一、 性质
| 特性 | CommonJS |
ES Modules |
|---|---|---|
| 性质 | 社区规范 | 官方规范 |
CJS由 Mozilla 工程师 Kevin Dangoor 于 2009 年发起,最初为解决浏览器外(如服务端)的 JS 模块化问题而提出的社区标准.
ESM是由ECMAScript 2015 (ES6) 正式纳入语言标准(ECMA-262).
二、语法差异
1. 导出语法
| 操作 | CJS | ESM |
|---|---|---|
| 默认导出 | module.exports = value |
export default value |
| 命名导出 | exports.name = value |
export const name = value |
| 混合导出 | module.exports = { name, default } |
export { name }; export default ... |
| 默认严格模式 | 否 | 是 |
CJS 示例:
1 | // math.js |
ESM 示例:
1 | // math.mjs |
2. 导入语法
| 操作 | CJS | ESM |
|---|---|---|
| 默认导入 | const mod = require('module') |
import mod from 'module' |
| 命名导入 | const { name } = require(...) |
import { name } from 'module' |
| 混合导入 | const mod = require('module')(默认导出作为mod中的default属性的值) |
import defaultname, { name } from 'module' |
| 省略扩展名 | 能 | 否 |
CJS 动态导入:
1 | if (condition) { |
ESM 动态导入:
1 | if (condition) { |
注意在导入时,
CJS可以省略拓展名,ESM必须加上拓展名.
三、加载机制
| 特性 | CommonJS |
ES Modules |
|---|---|---|
| 加载时机 | 运行时动态解析+同步加载 | 编译时静态解析 + 异步预加载 |
| 依赖分析 | 执行到 require() 时才加载 |
预处理阶段解析所有 import 语句,也就是说无论import语句在代码中的什么位置,都会在执行代码之前先按顺序解析 |
| 执行顺序 | 父模块先执行,遇到 require 时暂停,转而去加载子模块 |
子模块优先完全执行,再执行父模块剩余代码 |
tree-shaking |
不支持 | 支持 |
示例:
1 | // CJS (main.js) |
四、对动态导入的支持
| 特性 | CJS | ESM |
|---|---|---|
| 动态导入 | require()函数原生支持动态导入,可以写在if条件判断中 |
import关键字只能静态导入,必须处于模块的顶层.要实现动态导入需要使用import()函数来基于Promise进行操作. |
CJS
1 | // a.js |

ESM

ESM中无法的import关键字只能在模块顶层代码使用,否则报错.
要想在ESM中实现动态导入,必须使用import()函数来返回Promise进行操作.
1 | // a.mjs |

当然,由于import()函数是基于Promise的,所以也可以使用async/await语法糖.
1 | // main.mjs |
注意:在
CommonJS模块和非模块的普通脚本(浏览器中的<script>标签,无type=module)中不能再顶层代码中直接使用await关键字,必须在async函数中才能使用.
在ESM中,可以在顶层代码直接使用await(ES2022正式规范).
五、导出值的类型
| 特性 | CJS | ESM |
|---|---|---|
| 基本类型导出 | 导出值的拷贝 | 导出值的只读引用(实时绑定) |
| 对象类型导出 | 导出对象的引用 | 导出的对象的只读引用(实时绑定) |
CJS
基本类型
1 | // a.js |

输出的两个都是3,说明CJS导出基本类型是,导出的是值拷贝,当原始值变化,导入部分获取的值不会更新.
对象类型
1 | // a.js |

当原始对象中的属性发生变化,导入部分获取到的对象也发生变化,说明在CJS导出对象类型时,导出的是对象的引用.而且在导入模块可以对导入的对象进行属性的修改,修改后原对象也会变化.
ESM
基本类型
1 | // a.mjs |

可以看到,当我们在导出模块中修改了基本类型的值后,导入模块也实时更新,说明导出的是值的引用.
注意,在ESM中导出值都是只读的,哪怕使用
let或var声明.在导入模块中修改导入的值会报错.

对象类型
1 | // a.mjs |

同样的,对于对象类型,ESM导出的也是对象的引用,会自动更新,因为其实它们指向同一块内存地址.
同样的,
ESM导入模块中,即使是对象类型,也不能重新赋值.但是可以修改对象中的属性.(即不能改变该对象的引用)

六、循环依赖处理
| 场景 | CJS | ESM |
|---|---|---|
| 循环加载 | 可能读取到未初始化的值 | 利用let和const的TDZ暂时死区的报错来提醒. |
| 执行顺序 | 父模块执行到一半时加载子模块 | 子模块优先完全初始化 |
CJS 循环依赖问题:
1 | // a.js |
1 | // 输出结果 |
- 未完成的导出:当
b.js加载a.js时,a.js仅执行到exports.done = false,因此b.js看到的是未更新的值。- 最终一致性:当
a.js执行完毕后,其导出的done变为true,但b.js中已经持有了旧值的拷贝。
ESM 解决方案:
1 | // a.mjs |
如果直接运行main.mjs会报错如下:

因为变量
aDone用let声明,存在暂时性死区(TDZ),所以无法根据后序遍历首先运行b.mjs文件,输出第一行’b开始执行’,然后导入aDone,但此时aDnoe还没有initialize,即没有初始化,所以直接报错.
可以改用var声明来明确一下过程.
1 | // 输出如下 |
七、兼容性支持
| 维度 | CommonJS | ES Modules |
|---|---|---|
| 浏览器支持 | 需 Webpack 等工具转换 | 现代浏览器原生支持 (type="module") |
| Node.js 使用 | 默认模块系统 | 需 .mjs 扩展名或 在package.json中设置"type": "module" |
八、最佳实践建议
新项目优先选择 ESM:
1
2
3
4json// package.json
{
"type": "module" // 启用 ESM
}混合使用时的互操作:
1
2
3
4
5// 在 ESM 中引入 CJS
import cjsModule from './legacy.cjs'; // 默认导入整个 module.exports
// 在 CJS 中引入 ESM(必须异步)
const esmModule = await import('./modern.mjs');避免副作用:ESM 模块的顶层代码会在加载时执行,建议将逻辑封装到函数中。
通过理解这些差异,可以更好地处理模块化开发中的问题,并选择适合项目的模块系统。
[补充]深入理解ESM中静态import的异步和同步
ESM 代码执行的核心规则
- 加载和解析优先
所有静态import语句会在模块代码执行前完成依赖的加载、解析和初始化(包括依赖的依赖,递归处理)。 - 执行顺序确定性
模块的顶层代码(如console.log)按照从依赖树的叶子节点到根节点的顺序执行。这意味着:- 最底层的依赖模块(没有其他依赖的模块)最先执行。
- 父模块总是在其所有依赖模块执行完成后,才开始执行自己的顶层代码。
示例验证
假设有以下依赖关系:
1 | main.mjs → a.mjs → b.mjs |
执行顺序为:b.mjs → c.mjs → a.mjs → main.mjs
代码示例
1 | // main.mjs |
输出结果
1 | b 执行 |
底层机制的三阶段
ESM 的处理过程分为三个阶段,且完全串行化:
| 阶段 | 行为 | 是否阻塞主线程 |
|---|---|---|
| 解析 | 静态分析所有 import,构建完整的依赖树 |
异步(可并行加载资源) |
| 实例化 | 为所有模块分配内存,绑定 import/export 的引用关系 |
同步 |
| 求值 | 按后序遍历顺序执行模块的顶层代码 | 同步 |
关键特性
阻塞性执行
父模块的代码执行会被阻塞,直到所有依赖模块的代码执行完成。1
2
3
4
5
6// main.mjs
console.log("main"); // 最后执行
import './a.mjs';
// a.mjs
console.log("a"); // 先执行网络加载的透明性
在浏览器中,即使依赖模块需要从网络下载,引擎也会等待所有文件就绪后才开始执行代码。1
2// 假设 a.mjs 需要 2 秒下载
// main.mjs 的控制台输出仍会严格等待 a.mjs 完全加载并执行后才会触发循环依赖的安全性
通过预先绑定导出引用(“活绑定”),即使存在循环依赖,执行顺序仍能保证正确性。
与 CommonJS 的对比
| 特性 | ESM | CommonJS (require()) |
|---|---|---|
| 依赖分析时机 | 编译时静态分析 | 运行时动态解析 |
| 执行顺序 | 子模块优先执行 | 父模块执行到 require() 时才加载子模块 |
| 输出顺序确定性 | 完全确定(依赖树后序遍历) | 依赖代码执行路径(可能不确定) |
| 顶层代码执行 | 所有依赖完成后同步执行 | 同步阻塞式逐行执行 |
总结
- ESM 中所有静态
import的模块会先完成加载和解析,然后严格按照从叶子到根的顺序同步执行代码。 - 🌐 浏览器中的表现:即使模块需要网络下载,执行顺序依然严格遵循此规则,开发者无需关心底层加载的异步性。
- ⚙️ 设计优势:这种机制保证了模块间状态的确定性,避免了 CommonJS 中可能出现的未初始化导出问题。



