深入对比JavaScript主流模块化规范CommonJS与ES Module的核心区别。涵盖加载机制、数据传递模型、顶层变量差异、Node.js环境下的互操作实践及工程选型指南。
JavaScript模块化规范对比:CommonJS与ES Module的区别
时间:2026-10-30
浏览:468
文章目录
一、核心机制差异:运行时同步加载 vs 编译时静态解析
理解两种规范差异的首要切入点是代码的解析与加载时机。CommonJS(CJS)诞生于 Node.js 早期,设计初衷是为服务器端提供一套同步的模块载入方案;而 ES Module(ESM)是 ECMAScript 官方于 2015 年(ES6)制定的语言级标准,旨在统一浏览器端与服务端环境。
在 CommonJS 中,模块的加载完全发生在运行时(Runtime)。当执行到 const lib = require('./lib') 时,Node.js 引擎会暂停当前代码的执行,同步读取目标文件内容,将其包装在一个函数闭包中执行,并最终返回 module.exports 的计算结果。这意味着 require 本质上是一个普通的 JavaScript 函数调用,你可以把它写在 if 条件分支、循环体或任何函数的内部,甚至可以动态拼接路径:require('./lang/' + locale)。
ES Module 则完全不同,它基于编译时静态解析(Compile-time Static Analysis)。在代码真正执行之前,JavaScript 引擎会先解析源代码中的 import 与 export 声明,并在内存中建立完整的模块依赖图(Module Graph)。ESM 的载入流程被严格划分为三个独立阶段:
- 构建阶段(Construction):定位、下载并解析所有引用的模块文件为模块记录(Module Records)。
- 实例化阶段(Instantiation):为所有模块导出的变量在内存中分配独立空间,并将导入与导出建立指向同一内存地址的绑定关系(Live Bindings),此时并不执行代码。
- 求值阶段(Evaluation):按照拓扑顺序自底向上执行模块内部的顶层代码,将实际计算结果填充到先前分配的内存区域中。
因为依赖关系在静态分析期就已经确定,import 声明必须置于模块的顶层作用域,不能嵌套在条件分支中。这一特性赋予了现代打包工具(如 Webpack、Vite、Rollup)精准实施 Tree Shaking(无效代码消除)的能力——未经引用的导出项可以在编译阶段被安全移除。
二、数据传递模型:值拷贝 vs 动态只读引用
这是两者在日常编码中最容易引起逻辑偏差的核心区别:CommonJS 模块输出的是值的拷贝,而 ES Module 输出的是值的动态只读引用(Live Bindings)。
1. CommonJS 的值拷贝现象
当通过 CommonJS 导出基础类型变量时,该值在导出瞬间被复制一份存入 module.exports 对象中。如果导出模块在稍后修改了这个变量,导入方持有的值不会发生变化;同样,导入方如果直接修改变量(在允许赋值的情况下),也完全影响不到原始模块内部。
// counter-cjs.js (导出方)
let count = 1;
function increment() {
count++;
}
module.exports = {
count,
increment
};
// app-cjs.js (消费方)
const { count, increment } = require('./counter-cjs.js');
console.log(count); // 输出: 1
increment(); // 模块内部 count 增加至 2
console.log(count); // 依然输出: 1!因为 count 只是解构时的浅拷贝快照
2. ES Module 的动态只读引用
在 ES Module 中,import 进来的变量并不是变量值的副本,而是指向导出模块内部内存地址的“只读指针”。这意味着:只要原始模块内部修改了该变量的值,所有引用它的模块都能立即感知到最新状态;同时,消费方不能对导入的标识符进行重新赋值(会直接抛出 TypeError: Assignment to constant variable)。
// counter-esm.mjs (导出方)
export let count = 1;
export function increment() {
count++;
}
// app-esm.mjs (消费方)
import { count, increment } from './counter-esm.mjs';
console.log(count); // 输出: 1
increment(); // 模块内部修改 count
console.log(count); // 输出: 2!实时获取最新内存值
// 尝试直接修改将报错:
// count = 10; // SyntaxError / TypeError: Assignment to constant variable.
三、语法特征与顶层环境变量对比
从代码书写和执行上下文的角度来看,CommonJS 与 ES Module 提供了截然不同的全局/顶层变量集合与执行环境约束。
1. 顶层环境变量差异
在编写 CommonJS 模块时,开发者可以信手拈来使用 __dirname 和 __filename 获取路径信息,这得益于 Node.js 在加载前执行的函数包裹:
// Node.js 在底层对 CommonJS 脚本进行的包装
(function (exports, require, module, __filename, __dirname) {
// 你编写的 CommonJS 模块代码实际运行在此闭包内
});
而在 ES Module 中,由于它是标准化的语言级规范,脱离了 Node.js 特有的包装器模式,因此 __dirname、__filename、require、module 和 exports 在 ESM 中均未定义。如果在 ESM 中直接访问 __dirname,会直接抛出 ReferenceError: __dirname is not defined。
在现代 ESM 中,获取当前文件和目录的标准替代做法是通过 import.meta.url 配合 Node.js 原生的 url 模块实现:
// 在 ES Module (.mjs 或 package.json "type": "module" 下)
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log('当前文件路径:', __filename);
console.log('当前目录路径:', __dirname);
2. 顶层 this 的指向差异
模块顶层代码执行时的 this 绑定规则也是两者的显著差异之一:
- CommonJS:顶层作用域中的
this严格指向module.exports对象。 - ES Module:顶层作用域中的
this默认是undefined,避免了隐式修改全局对象导致的不可预测问题。
3. 顶层 await(Top-Level Await)支持
ES Module 原生支持在模块顶层直接使用 await,无需包裹在 async function 之中。这在需要进行异步初始化配置、数据库连接建立或动态载入数据字典的场景中极为优雅:
// config.mjs (ES Module 顶层 await 示例)
const response = await fetch('https://api.example.com/runtime-config');
export const runtimeConfig = await response.json();
而在 CommonJS 规范中,顶层代码不能直接编写 await(除非包装在立即执行异步函数 IIFE 中,但这样会导致后续代码无法同步取得导出结果)。
四、Node.js 中的混用与互操作策略
在实际工程中,大量现存的 NPM 包仍以 CommonJS 格式发布,而现代前端工具链与后端开发正快速转向 ES Module。掌握两种规范在 Node.js 环境下的互操作规则至关重要。
1. Node.js 如何判断当前文件属于何种规范
Node.js 依据两个核心维度决定模块的解析格式:文件后缀名和最近的 package.json 配置:
- 显式后缀优先级最高:无论
package.json如何配置,.mjs文件始终作为 ES Module 解析;.cjs文件始终作为 CommonJS 解析。 - 常规
.js文件判断:Node.js 会向上递归查找最近的package.json,若其中声明了"type": "module",则该目录树下的.js文件均按 ESM 解析;若未声明或为"type": "commonjs",则按 CommonJS 解析。
2. ES Module 中引入 CommonJS 模块
在 ESM 中引入 CJS 模块是官方良好支持的模式,但有以下关键细节:
- 推荐使用默认导入:CJS 的
module.exports对象会被映射为 ESM 中的默认导出(default export)。 - 命名导出陷阱:Node.js 会尝试通过词法分析(CJS-module-lexer)解析 CJS 中的属性导出,但在许多动态导出(如计算属性、第三方代理)情况下,命名导出无法被识别,此时使用具名导入会失败。
// 推荐:在 ESM 中引入 CommonJS 模块的标准安全写法
import lodash from 'lodash';
const { debounce } = lodash;
// 不稳定:部分 CJS 库的具名导入可能失败
// import { debounce } from 'lodash'; // 可能触发 SyntaxError
3. CommonJS 中引入 ES Module 模块
这是引发本文开头 ERR_REQUIRE_ESM 报错的根源:CommonJS 无法通过同步的 require() 加载 ES Module。因为 ESM 的加载与依赖构建可能是异步的,而 require() 是强制同步的,若允许同步加载可能破坏主线程调度并导致死锁。
若必须在 CJS 项目中消费纯 ESM 依赖,唯一的标准解决方式是使用动态 import() 表达式,它返回一个 Promise:
// 在 CommonJS 模块中载入纯 ESM 依赖的有效方法
async function handleMarkdown(content) {
// 许多现代 markdown 解析库(如 remark, chalk v5)已转为纯 ESM
const { remark } = await import('remark');
const result = await remark().process(content);
return String(result);
}
五、全维度对比与工程选型建议
为了让差异一目了然,我们从语言特性、运行环境和工程构建三个维度总结两者的核心区别:
| 对比维度 | CommonJS (CJS) | ES Module (ESM) |
|---|---|---|
| 规范制定者 | Node.js 社区规范,早期用于服务器端 | ECMA 国际标准化组织,语言级官方规范 |
| 导入/导出语法 | require() / module.exports |
import / export、动态 import() |
| 解析与加载时机 | 运行时加载、同步阻塞、顺序执行 | 编译时静态分析、三阶段处理、支持异步加载 |
| 数据传递机制 | 输出值的浅拷贝(独立快照) | 输出值的动态只读引用(Live Bindings) |
| Tree Shaking 支持 | 通常不支持(难以静态检测动态修改) | 原生完美支持(基于静态 Module Graph) |
| 模块顶层 this | 指向 module.exports |
undefined |
| 内置环境变量 | 自带 __dirname, __filename |
无内置变量,使用 import.meta.url 转换 |
| 顶层 Await | 原生不支持(必须包裹在 async 函数内) | 原生完全支持(Top-Level Await) |
| 运行环境覆盖 | 主要在 Node.js 中运行,浏览器端需打包 | 现代化浏览器原生支持(<script type="module">),Node.js 全面支持 |
工程选型与实践建议
根据团队面对的实际场景,可以遵循以下确定性的决策逻辑:
- 现代前端项目(React / Vue / Vite / Next.js):无悬念全面采用 ES Module。现代构建工具均基于 ESM 静态解析构建依赖图,能够最大限度缩减发布产物体积并提供极致的本地热更新(HMR)体验。
- 全新 Node.js 后端项目(Node.js 18+):建议在
package.json中显式声明"type": "module",全面拥抱 ESM。这不仅消除了与现代第三方 NPM 库的集成摩擦,还能享受顶层await和规范统一带来的代码维护红利。 - 已稳定运行的大型遗留 Node.js 服务:保持 CommonJS 不动,避免盲目重构导致的大量路径调整和潜在的隐式依赖破坏。如遇纯 ESM 第三方库,通过动态
import()局部桥接即可。 - 开发需要发布的 NPM 公共工具库:推荐采用双模块支持(Dual Package)方案。利用现代构建工具(如
tsup、unbuild或rollup)同时打包输出 CJS 和 ESM 产物,并在package.json的exports字段中进行条件映射:
{
"name": "my-utility-lib",
"version": "1.0.0",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
六、总结
CommonJS 与 ES Module 并非单纯的语法表象差异(require 与 import),而是代表了两种完全不同的模块设计哲学:
- CommonJS 以运行时动态性和同步简单性为核心,适合脚本即时执行、环境高度受控的服务端早期场景,输出的是数据的副本。
- ES Module 则以编译时静态分析和标准化互通为核心,支持异步加载、跨宿主环境运行、输出动态只读引用,并天然提供了 Tree Shaking 的优化基石。
随着 Node.js 现代版本的普及和前端工具链的成熟,ES Module 已经成为 JavaScript 生态无可争议的标准演进方向。在日常开发中,只要清晰把握“运行时 vs 静态分析”、“值拷贝 vs 动态绑定”以及两者的互操作语法规则,就能游刃有余地排查模块报错,并构建出高效稳健的现代工程体系。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。