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
require
B,B 又require
A,而此时 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