在现代 JavaScript 开发中,ES Module(ESM) 和 CommonJS(CJS) 是两种主流的模块化规范。它们在加载机制、变量作用域、语法结构和执行行为上存在显著差异。本文将从多个维度深入解析这两种模块系统的区别,帮助开发者理解其原理、使用场景以及迁移策略。
一、基本概念
✅ ES Module(ESM)
- 是 ECMAScript 官方原生支持的模块系统。
- 自 ECMAScript 2015(ES6) 引入,逐渐成为现代前端开发的标准。
- 支持浏览器和 Node.js 环境(Node.js v12+ 起支持)。
✅ CommonJS(CJS)
- 是 Node.js 最早采用的模块系统。
- 主要用于服务器端编程,但也可通过打包工具用于浏览器环境。
- 使用
require()
加载模块,module.exports
导出内容。
二、加载机制的区别
✅ ESM:编译时加载(静态导入)
- 模块依赖在代码执行前就已经解析。
import
必须写在文件顶部,不能放在函数或条件语句中。- 便于构建工具进行优化(如 Tree Shaking)。
// ✅ 正确
import { foo } from './module.js';
// ❌ 错误
if (condition) {
import('./module.js'); // SyntaxError
}
✅ CJS:运行时加载(动态导入)
- 模块在运行时按需加载。
require()
可以出现在任意位置(函数内部、条件判断等)。- 不适合静态分析,难以进行 Tree Shaking。
if (condition) {
const module = require('./module');
}
✅ ESM 支持异步加载(Dynamic Import)
虽然默认是同步加载,但 ESM 提供了 import()
函数实现异步加载:
button.addEventListener('click', async () => {
const module = await import('./lazyModule.js');
module.doSomething();
});
import()
返回一个 Promise,适用于懒加载、按需加载、路由级代码分割等场景。
三、变量作用域的区别
✅ ESM 中的变量作用域
- 每个模块都有独立的作用域。
- 顶级作用域不是全局作用域,不会污染
window
或globalThis
。 - 使用
export
显式导出变量。 - 导入的是“绑定”(Live Binding),即引用而非值拷贝。
// a.js
export let count = 0;
setTimeout(() => count++, 1000);
// b.js
import { count } from './a.js';
console.log(count); // 初始为 0
setTimeout(() => console.log(count), 1500); // 输出 1
✅ CJS 中的变量作用域
- 模块也有独立作用域。
- 使用
exports.xxx
或module.exports
导出变量。 - 导入的是值的“快照”,不会随原始模块更新而变化。
// a.js
let count = 0;
setTimeout(() => count++, 1000);
exports.count = count;
// b.js
const { count } = require('./a');
console.log(count); // 0
setTimeout(() => console.log(count), 1500); // 仍然是 0
四、顶层 this
的区别
特性 | ES Module | CommonJS |
---|---|---|
是否运行在严格模式 | ✅ 是(默认) | ❌ 否 |
顶层 this 的值 | undefined | module.exports |
✅ ESM 中的 this
- 所有模块默认运行在严格模式下。
- 在顶层代码中使用
this
,其值为undefined
。
// a.mjs
console.log(this); // undefined
✅ CJS 中的 this
- 顶层
this
等价于module.exports
。 - 可以通过
this.xxx = 'xxx'
来导出变量。
// a.cjs
this.config = { debug: true };
console.log(module.exports); // { config: { debug: true } }
⚠️ 迁移注意:如果项目中有大量使用
this
导出变量的方式,在迁移到 ESM 时会报错。
五、循环依赖的处理
✅ ESM 更安全地处理循环依赖
- 即使两个模块互相导入,也能通过“引用”获取部分完成的导出内容。
- 但仍建议避免复杂的循环依赖结构。
// a.js
import { bar } from './b.js';
export let foo = 'foo';
// b.js
import { foo } from './a.js';
export let bar = 'bar';
✅ CJS 处理循环依赖较脆弱
- 如果模块 A 在初始化过程中
require()
B,而 B 又require()
A,A 将返回未完全初始化的exports
。 - 很容易导致某些变量为
undefined
。
// a.js
const b = require('./b');
exports.foo = 'foo';
// b.js
const a = require('./a');
exports.bar = 'bar';
六、是否支持 Top-level await
特性 | ESM | CJS |
---|---|---|
是否支持顶层 await | ✅ 是 | ❌ 否 |
ESM 支持在模块文件中直接使用 await
,无需包裹在 async
函数中:
// config.js
const response = await fetch('https://api.example.com/config');
export const config = await response.json();
此特性对需要预加载数据或配置的模块非常有用。
七、语法和导出方式
特性 | ESM | CJS |
---|---|---|
导出方式 | export , export default | module.exports , exports.xxx |
导入方式 | import ... from '...' | require('...') |
是否支持默认导出 | ✅ 是 | ⚠️ 支持但需手动设置 |
是否支持命名导出 | ✅ 是 | ✅ 是 |
是否支持混合导出 | ✅ 是 | ✅ 是 |
示例:
// ESM
export const name = 'Alice';
export default class User {}
// CJS
exports.name = 'Alice';
module.exports = class User {};
八、Node.js 环境下的模块类型识别
文件后缀 | 模块类型 |
---|---|
.mjs | ESM |
.cjs | CJS |
默认(无指定) | 由 package.json 中的 "type" 字段决定 |
{
"type": "module"
}
九、ES Module 的优势总结
特性 | 描述 |
---|---|
原生支持 | 浏览器和 Node.js 都原生支持 |
Tree Shaking | 支持静态分析,可剔除未使用代码 |
异步加载 | 支持 import() 实现按需加载 |
实时绑定 | 导入变量是引用,非值拷贝 |
支持 Top-level await | 可在模块中直接使用 await |
模块缓存 | 模块只执行一次,结果被缓存 |
更好的封装性 | 模块间作用域隔离更清晰 |
适合现代开发生态 | React/Vue/Angular 等框架推荐使用 |
代码分割支持 | 构建工具可自动做 Code Splitting |
十、何时选择 ESM 或 CJS?
场景 | 推荐模块类型 |
---|---|
新的前端项目(React/Vue) | ✅ ESM |
新的 Node.js 项目(v14+) | ✅ ESM |
需要懒加载、按需加载 | ✅ ESM(配合 import() ) |
老项目维护(兼容性优先) | ⚠️ CJS |
浏览器端模块化 | ✅ 必须使用 ESM |
动态路径加载模块 | ✅ ESM(import() )或 CJS(require() ) |
需要 Top-level await | ✅ ESM |
需要简单快速调试 | ✅ CJS(Node.js 默认) |
十一、结语
随着 JavaScript 生态的发展,ES Module 已成为主流标准,其设计更符合现代开发需求,尤其在性能优化、模块组织、异步加载等方面具有明显优势。尽管 CommonJS 在旧项目中仍有广泛使用,但在新项目中,尤其是在使用现代构建工具(如 Vite/Webpack/Rollup)的情况下,强烈建议优先使用 ESM。
📚 推荐阅读: