好的,ESM(ECMAScript Modules)和 CJS(CommonJS)是 JavaScript 中两种最主要的模块系统。它们在设计哲学、语法和运行方式上有根本性的区别。
以下是它们之间的核心区别,我用一个对比表格来清晰展示,然后进行详细解释。
核心区别对比表
| 特性 | ESM (ECMAScript Modules) | CJS (CommonJS) |
|---|---|---|
| 来源与标准 | 语言标准 (ES6 / ES2015) | 社区规范 (主要用于 Node.js 环境) |
| 语法 - 导出 | export default myComponent; <br> export const foo = 'bar'; | module.exports = myComponent; <br> exports.foo = 'bar'; |
| 语法 - 导入 | import myComponent from './module.js'; <br> import { foo } from './module.js'; | const myComponent = require('./module'); |
| 加载方式 | 静态 (编译时) | 动态 (运行时) |
| 加载时机 | 异步加载,支持 top-level await | 同步加载 |
| 运行环境 | 现代浏览器、现代 Node.js | Node.js (传统)、打包工具 (如 Webpack) |
| Tree Shaking | 原生支持,打包器可轻松剔除未使用代码 | 不支持,需要依赖打包工具静态分析 |
| 值引用 | 动态映射 (导入的是值的只读引用) | 值拷贝 (导入的是导出值的拷贝) |
| 文件后缀 | 通常为 .js, .mjs | 通常为 .js, .cjs |
顶层的 this | undefined | 指向 module.exports |
详细解释
1. 来源与历史
- CJS (CommonJS):诞生于2009年,旨在为浏览器之外的 JavaScript(主要是 Node.js)提供模块系统。它不是一个语言标准,而是一个社区规范。Node.js 的早期版本完全基于 CJS。
- ESM (ECMAScript Modules):是 JavaScript 语言在 ES2015 (ES6) 标准中正式引入的模块系统。它的目标是为 JavaScript 提供一个统一的、官方的模块方案,无论是在浏览器还是其他环境中。
2. 语法
这是最直观的区别。
CJS 使用
require()和module.exports/exports:javascript// 导出 module.exports = { functionA, variableB }; // 或者 exports.functionA = functionA; // 导入 const myModule = require('./my-module'); const { functionA } = require('./my-module');ESM 使用
import和export:javascript// 导出 export default functionA; export const variableB = 'Hello'; // 导入 import myModule from './my-module.js'; import { variableB } from './my-module.js'; // 或者混合导入 import myDefault, { namedExport } from './my-module.js';
3. 加载方式:静态 vs. 动态
这是最关键的区别,它导致了其他所有差异。
ESM 是静态的:
import命令必须在模块的顶层作用域使用,不能写在条件判断或函数内。- 这意味着模块的依赖关系在代码编译阶段(编译时) 就已经确定下来了。
- 优势:这使得打包工具(如 Webpack、Rollup)可以轻松地进行静态分析,从而实现
Tree Shaking(摇树优化),剔除那些从未被导出和导入的代码,极大减小最终打包体积。
CJS 是动态的:
require()是一个函数,可以在代码的任何地方被调用,包括条件语句和函数内部。- 这意味着模块的依赖关系要到代码执行阶段(运行时) 才能确定。
- 劣势:无法在打包前静态分析出所有依赖,因此难以实现完美的 Tree Shaking。
4. 加载时机:同步 vs. 异步
- CJS 是同步加载的:
require()语句会同步地阻塞代码执行,直到模块被加载并执行完毕,才会继续执行后面的代码。这在服务器端(Node.js)读写本地文件是可行的。 - ESM 是异步加载的:模块的加载过程是异步的。浏览器不会因为加载一个模块而阻塞页面渲染。它还会建立一个模块依赖图,按依赖顺序执行。ESM 还支持
top-level await,允许在模块顶层使用await关键字。
5. 值引用 vs. 值拷贝
这个区别在处理循环依赖时尤为明显。
ESM 是值的动态只读引用:
- 导入和导出两个模块指向的是同一个内存地址。
- 当导出的模块修改了一个值,导入模块中看到的值也会随之改变。
- 导入的值是只读的,你不能直接修改它(除非导出的是一个对象,你可以修改其属性)。
CJS 是值的拷贝:
require()导入的是导出值的一个副本(浅拷贝)。- 即使导出的模块后来改变了这个值,导入模块中看到的仍然是最初导入的副本,不会变化。
示例:
javascript
// counter.js (ESM)
export let count = 0;
export const increment = () => { count++; };
// main.js (ESM)
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 (值被改变了,因为是对同一内存地址的引用)
// counter.js (CJS)
let count = 0;
exports.increment = () => { count++; };
exports.getCount = () => count; // 需要一个getter来获取最新值
// main.js (CJS)
const { increment, getCount } = require('./counter');
let count = getCount();
console.log(count); // 0
increment();
console.log(getCount()); // 1
console.log(count); // 0 (原始的count变量仍是拷贝时的值,未变)6. 运行环境
- CJS:原生支持于 Node.js。在浏览器中直接使用需要打包工具处理。
- ESM:
- 现代浏览器 已原生支持 (
<script type="module">)。 - 现代 Node.js (v12+) 也已原生支持,通常通过
.mjs文件扩展名或package.json中设置"type": "module"来启用。
- 现代浏览器 已原生支持 (
总结与如何选择
- 新项目:毫无疑问应该使用 ESM。它是语言标准,支持 Tree Shaking,并且是未来的方向。
- Node.js 开发:
- 新项目建议在
package.json中设置"type": "module"来使用 ESM。 - 旧的库和项目可能仍然使用 CJS。
- 新项目建议在
- 旧项目/库:如果是一个旧的 CJS 项目,除非有充分理由,否则迁移到 ESM 的成本可能较高。但如果要发布新的库,最好提供 ESM 格式的版本,以便用户能进行 Tree Shaking。
- 在 Node.js 中互操作:
- 在 ESM 模块中可以使用
import()动态导入 CJS 模块。 - 在 CJS 模块中无法使用
require()加载 ESM 模块,因为 ESM 是异步的。
- 在 ESM 模块中可以使用
简单来说,ESM 是现代化开发的标配,而 CJS 是历史遗留和特定环境(如传统 Node.js)下的产物。