JavaScript模块化规范对比:CommonJS与ES Module的区别

作者:墨池

时间:2026-10-30

浏览:468

深入对比JavaScript主流模块化规范CommonJS与ES Module的核心区别。涵盖加载机制、数据传递模型、顶层变量差异、Node.js环境下的互操作实践及工程选型指南。

文章目录

一、核心机制差异:运行时同步加载 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 的载入流程被严格划分为三个独立阶段:

  1. 构建阶段(Construction):定位、下载并解析所有引用的模块文件为模块记录(Module Records)。
  2. 实例化阶段(Instantiation):为所有模块导出的变量在内存中分配独立空间,并将导入与导出建立指向同一内存地址的绑定关系(Live Bindings),此时并不执行代码。
  3. 求值阶段(Evaluation):按照拓扑顺序自底向上执行模块内部的顶层代码,将实际计算结果填充到先前分配的内存区域中。

因为依赖关系在静态分析期就已经确定,import 声明必须置于模块的顶层作用域,不能嵌套在条件分支中。这一特性赋予了现代打包工具(如 Webpack、Vite、Rollup)精准实施 Tree Shaking(无效代码消除)的能力——未经引用的导出项可以在编译阶段被安全移除。

JavaScript模块化规范对比:CommonJS与ES Module的区别
图 1:CommonJS 运行时同步加载与 ES Module 编译时静态三阶段解析流程对比

二、数据传递模型:值拷贝 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.
JavaScript模块化规范对比:CommonJS与ES Module的区别
图 2:CommonJS 的值拷贝模型与 ES Module 的动态只读引用(Live Bindings)内存映射对比

三、语法特征与顶层环境变量对比

从代码书写和执行上下文的角度来看,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);
}
JavaScript模块化规范对比:CommonJS与ES Module的区别
图 3:Node.js 项目中跨规范调用时的互操作与语法调用选择路径

五、全维度对比与工程选型建议

为了让差异一目了然,我们从语言特性、运行环境和工程构建三个维度总结两者的核心区别:

对比维度 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"
  }
}
JavaScript模块化规范对比:CommonJS与ES Module的区别
图 4:现代 JavaScript 项目在不同开发场景下的模块化规范选型与集成准则总结

六、总结

CommonJS 与 ES Module 并非单纯的语法表象差异(require 与 import),而是代表了两种完全不同的模块设计哲学:

  • CommonJS 以运行时动态性和同步简单性为核心,适合脚本即时执行、环境高度受控的服务端早期场景,输出的是数据的副本。
  • ES Module 则以编译时静态分析和标准化互通为核心,支持异步加载、跨宿主环境运行、输出动态只读引用,并天然提供了 Tree Shaking 的优化基石。

随着 Node.js 现代版本的普及和前端工具链的成熟,ES Module 已经成为 JavaScript 生态无可争议的标准演进方向。在日常开发中,只要清晰把握“运行时 vs 静态分析”、“值拷贝 vs 动态绑定”以及两者的互操作语法规则,就能游刃有余地排查模块报错,并构建出高效稳健的现代工程体系。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章