一、模块化的意义与演进背景
在JavaScript发展历程中,模块化解决了脚本依赖混乱、全局污染等问题。早期通过IIFE模拟封装,后出现AMD/CMD规范,而Node.js推出的CommonJS(CJS)成为服务端主流。2015年ES6正式推出ECMAScript Modules(ESM),开启了浏览器原生模块化时代。
二、运行机制解析
1. CommonJS:动态的同步加载
- 执行过程:每个模块被包裹为函数,通过
require
触发执行。首次加载时缓存模块结果,后续调用直接返回缓存。
// 模块包装器
(function(exports, require, module, __filename, __dirname) {// 模块代码
});
- 值传递:导出基本类型时为值拷贝,导出对象时共享引用。动态修改需通过函数返回最新值。
// counter.cjs
let count = 0;
module.exports = { count, add: () => count++ };// main.cjs
const { count, add } = require('./counter');
add();
console.log(count); // 输出0(值拷贝未更新)
2. ESM:静态的引用绑定
- 编译阶段解析:引擎预处理
import/export
构建模块图谱,形成动态只读引用(live binding)。
// counter.mjs
export let count = 0;
export const add = () => count++;// main.mjs
import { count, add } from './counter.mjs';
add();
console.log(count); // 输出1(实时引用)
- 异步加载:浏览器通过
<script type="module">
按需获取,支持顶层await
。
三、核心差异对比
特性 | CommonJS | ESM |
---|---|---|
加载时机 | 运行时同步加载 | 编译时静态分析,异步加载 |
环境 | Node.js传统环境 | 浏览器/现代Node.js |
顶层this | 指向module.exports | 指向undefined |
循环依赖 | 可能读取未完成导出 | 通过引用绑定自动更新 |
动态导入 | require() 任意位置 | import() 返回Promise |
代码检测 | 不支持Tree Shaking | 静态结构利于优化 |
互操作性 | Node中可引入ESM(需配置) | ESM可引入CJS(默认只默认导出) |
四、循环依赖处理实例
CJS潜在问题:
// a.cjs
exports.done = false;
const b = require('./b.cjs');
console.log('在a中,b.done =', b.done);
exports.done = true;// b.cjs
exports.done = false;
const a = require('./a.cjs');
console.log('在b中,a.done =', a.done); // 输出false
exports.done = true;
- b中获取到a的不完整状态。
- Node.js 在遇到循环依赖时,会将尚未完全加载的模块的导出对象(module.exports)直接传递给另一个模块。
这就是为什么在 b.js 中访问 a.done 时,得到的是 false(因为 a.js 的执行尚未完成)。
ESM解决方案:
// a.mjs
import { bDone } from './b.mjs';
export var aDone = false;
console.log('在a中,bDone =', bDone); // 输出true
aDone = true;// b.mjs
import { aDone } from './a.mjs';
export var bDone = false;
console.log('在b中,aDone =', aDone); // 输出undefined
bDone = true;
- 为什么 aDone 是 undefined?
在 ES 模块中,当一个模块尚未完全初始化时,它的导出值可能是 undefined。
在 b.mjs 中访问 aDone 时,a.mjs 的执行尚未完成,因此 aDone 的值仍然是 undefined。 - 为什么 bDone 是 true?
在 a.mjs 中访问 bDone 时,b.mjs 已经完成了初始化,并且将 bDone 设置为了 true。
因此,console.log(‘在a中,bDone =’, bDone); 输出的是 true。
五、现代开发实践
- 浏览器应用:ESM原生支持,结合
importmap
管理依赖路径。 - Node.js兼容:在
package.json
设置"type": "module"
,CJS文件使用.cjs
后缀。 - 构建工具:Webpack/Rollup等将混合模块转换为目标环境格式,支持CJS/ESM互转。
六、未来趋势与选择建议
ESM凭借静态分析、异步能力和浏览器原生支持,已成为前端开发首选。Node.js生态正逐步迁移,但CJS在存量项目和工具库中仍有价值。理解二者差异,有助于在以下场景合理选择:
- 新项目首选ESM:利用Tree Shaking优化体积。
- 库开发双格式发布:通过
exports
字段适配不同环境。 - 旧系统渐进升级:使用动态
import()
按需加载CJS。
通过深入理解模块系统原理,开发者能够优化代码结构,提升应用性能,从容应对不同平台的模块化需求。