ES Module (ESM) 和 CommonJS (CJS) 是 JavaScript 中两种主流的模块化方案。它们都旨在解决代码组织、依赖管理和命名空间污染的问题,但在设计理念、语法和行为上存在显著差异。
一、 核心相同点
- 模块化目标:两者都允许将代码分割成独立的模块(文件),每个模块拥有自己的作用域。
- 导出与导入:都提供了明确的语法来导出(
export/module.exports)模块内的变量、函数或类,并在其他模块中导入(import/require)使用。 - 避免全局污染:通过模块化,避免了将所有变量和函数都挂载到全局对象上。
二、 主要差异
| 特性 | ES Module (ESM) | CommonJS (CJS) |
|---|---|---|
| 标准 | ES6 (ECMAScript 2015) 正式引入,是 官方语言标准。 | Node.js 社区早期采用的事实标准,非官方语言标准。 |
| 语法 | 静态声明:import { foo } from './module.js';export const bar = 42; | 动态调用:const foo = require('./module');module.exports = bar; |
| 加载方式 | 静态编译时加载 (Static). 在代码执行前就分析依赖关系。 | 动态运行时加载 (Dynamic). 在代码执行到 require 时才同步加载并执行模块。 |
| 加载时机 | 编译时确定依赖。 | 运行时确定依赖。 |
| 加载性质 | 异步 (在浏览器环境中,通过 <script type="module">)。 | 同步。 |
this 指向 | 在模块顶层,this 为 undefined。 | 在模块顶层,this 指向 module.exports。 |
| 循环依赖处理 | 能更好地处理,通过“活绑定”暴露一个代理。 | 处理较差,可能导致返回 undefined 或不完整的对象。 |
| 导出类型 | 导出的是 绑定 (live bindings)。 导入的值会随原模块更新而更新(对于 const 等原始值,其“值”不变,但引用的对象/数组内容变仍可见)。 | 导出的是 值的拷贝 (value copy)。 一旦导出,后续修改 module.exports 不影响已导入的地方。 |
| 默认导出 | 支持 export default,一个模块只能有一个。 | 通过 module.exports = value 实现,默认导出整个对象。 |
| 命名导出 | 支持多个 export 命名导出。 | 需要给 module.exports 对象添加属性来实现。 |
| 重命名 | import { foo as bar } from './module'; | const { foo: bar } = require('./module'); (需解构) |
| 环境支持 | 浏览器原生支持,Node.js 从 v8.5.0+ 支持 (.mjs 或 "type": "module"). | Node.js 的原生模块系统。 |
三、 关键差异详解
1. 静态 vs 动态 (import vs require)
这是最根本的区别。
- ESM (
import):- 静态:
import语句必须位于模块的顶层,不能在条件语句或函数内部。这使得工具(如打包器、IDE)可以在不执行代码的情况下分析整个应用的依赖树,进行静态分析、死代码消除(Tree Shaking)等优化。
- 静态:
- CJS (
require):- 动态:
require是一个函数调用,可以出现在代码的任何地方,甚至可以根据条件动态加载不同的模块。
- 动态:
2. 活绑定 vs 值拷贝
- ESM – 活绑定 (Live Bindings):
也会反映这个变化(因为导入的是一个“只读视图”或“绑定”)。// --- math.js --- export let counter = 0; export function increment() { counter++; } // --- main.js --- import { counter, increment } from './math.js'; console.log(counter); // 0 increment(); console.log(counter); // 1 (值已更新!) - CJS – 值拷贝:
// --- math.js --- let counter = 0; function increment() { counter++; } module.exports = { counter, increment }; // --- main.js --- const { counter, increment } = require('./math.js'); console.log(counter); // 0 increment(); // 这里改变了 math.js 内部的 counter,但 exports 对象的 counter 属性仍是初始值 0 console.log(counter); // 0 (仍然是 0!)require返回的是module.exports对象的一个快照。即使increment函数内部改变了counter,main.js中解构出来的counter变量仍然是最初的值0。要看到变化,需要直接访问导出的对象:const math = require('./math.js'); console.log(math.counter); // 0 math.increment(); console.log(math.counter); // 1
3. 循环依赖
- ESM:通过创建一个“模块记录”和“活绑定”,能更优雅地处理循环依赖。它会先初始化模块,暴露一个代理,允许对方模块先拿到一个“占位符”,后续再填充实际值。
- CJS:容易出现问题。如果 A
requireB,B 又requireA,而此时 A 还未执行完,module.exports可能还是空对象或部分定义的状态,导致 B 拿到一个不完整或undefined的依赖。
四、 总结
| 方面 | ES Module (ESM) | CommonJS (CJS) |
|---|---|---|
| 定位 | 未来的标准,设计更先进,支持静态分析和 Tree Shaking。 | 历史事实标准,在 Node.js 生态中根深蒂固。 |
| 适用场景 | 现代前端开发 (React, Vue 等),需要打包优化的应用。 | Node.js 后端开发(尤其旧项目),需要动态 require 的场景。 |
| 趋势 | 主流和推荐方向。Node.js 和浏览器都在积极支持。 | 逐渐被 ESM 取代,但在可预见的未来仍会共存。 |
简单来说:
- 你想用
import/export,并且希望有更好的性能优化(Tree Shaking)、更清晰的依赖关系,就用 ESM。 - 你在写 Node.js 代码,且需要动态加载模块,或者依赖大量只支持 CJS 的旧包,目前可能还得用 CJS。
现代项目通常会使用打包工具(如 Webpack, Vite)或 Node.js 的 ESM 支持,优先选择 ES Module。
THE END


