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 中可能出现的未初始化导出问题。