全面解析 Node.js 中 CommonJS 与 ES Modules 两大模块规范的本质差异、底层机制、语法定义、内置变量区别、互操作性规则及工程落地使用方案。
Node.js 中 CommonJS 与 ES Modules 模块规范的区别及使用
时间:2026-11-03
浏览:75
文章目录
在 Node.js 生态中,模块系统是组织业务逻辑、复用代码以及管理第三方依赖的基石。在很长一段时间里,Node.js 默认采用社区制定的 CommonJS(简称 CJS)规范;而随着 ECMAScript 2015(ES6)将 ES Modules(简称 ESM)确立为 JavaScript 官方标准,Node.js 自 v12 起全面引入了对 ESM 的原生支持。两者的底层设计、加载模型与运行时语义存在本质差别。本文将深入讲解这两大规范的核心区别、在 Node.js 中的配置启用方法以及二者互相调用的边界条件与实用技巧。
一、核心机制与设计理念的本质区别
很多开发者初学时常以为 CommonJS 与 ES Modules 仅是 require 与 import 的语法糖差异,但实际上,两者的编译机制、加载时机与数据传递方式完全不同。
1. 加载时机:运行时动态同步 vs 编译期静态分析
CommonJS 采用运行时加载:当代码执行到 require('./module') 时,Node.js 会立即暂停当前脚本的执行,同步通过文件系统读取对应文件,将其包裹在一个闭包函数中执行,并将执行结果对象缓存到 require.cache。由于它是在代码执行期间动态运行的,因此 require() 可以在条件判断语句、函数内部调用,甚至可以使用动态拼接的变量路径:
// CommonJS 支持动态路径和条件加载
if (condition) {
const dynamicModule = require(`./plugins/${pluginName}.js`);
}
ES Modules 采用编译期静态分析:ESM 规范的设计目标是让模块依赖关系在代码实际执行之前就能完全确定。整个加载过程被严格划分为三个阶段:构建(Construction)、实例化(Instantiation)与求值(Evaluation)。在代码执行前,JavaScript 引擎会静态遍历所有的 import 和 export 声明,构建出一张完整的依赖关系图。因为依赖关系在编译阶段就已经固化,打包工具(如 Webpack、Rollup、Vite)能轻松分析出未被使用的导出变量,实现精准的代码剪枝(Tree-Shaking)。
2. 导出机制:值的浅拷贝 vs 实时只读引用(Live Binding)
这是两者在代码行为上最容易让人困惑的一处差异。CommonJS 模块导出的是值的浅拷贝(快照),而 ES Modules 导出的是变量内存地址的实时只读引用(Live Binding)。
在 CommonJS 中,模块内部在导出后即使修改了该变量的值,调用方获取到的也是当初拷贝过来的快照,变量不会随之变化:
// counter.cjs (CommonJS)
let count = 1;
function increment() {
count++;
}
module.exports = { count, increment };
// main.cjs
const { count, increment } = require('./counter.cjs');
console.log(count); // 输出: 1
increment();
console.log(count); // 依然输出: 1(外部持有的 count 仅是浅拷贝)
而在 ES Modules 中,import 引入的不是值的拷贝,而是一个指向源模块内存地址的只读指针。当模块内部修改了变量,外部引入处能够立即感知到最新值:
// counter.mjs (ES Modules)
export let count = 1;
export function increment() {
count++;
}
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 输出: 1
increment();
console.log(count); // 输出: 2(实时绑定,直接反映源模块内部变化)
// count = 10; // 抛出 TypeError: Assignment to constant variable,外部为只读视图
二、内置环境与上下文变量的差异
由于加载模型的不同,Node.js 为 CommonJS 注入的一系列全局变量在 ES Modules 中不再可用,取而代之的是新的标准 API。
1. __dirname 与 __filename 的替代方案
在 CommonJS 中,每个模块在执行前都会被 Node.js 隐式包裹进一个包装函数:(function(exports, require, module, __filename, __dirname) { ... }),因此我们可以直接在模块内访问表示当前文件和目录绝对路径的变量。
而在 ES Modules 中,模块遵循统一的 ECMAScript 标准,并不存在该包装函数,直接访问 __dirname 或 __filename 会抛出 ReferenceError。标准做法是使用 import.meta.url 配合 Node.js 内置的 node:url 和 node:path 模块构建:
// ES Modules 中获取 __filename 与 __dirname 的标准方案
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);
在较新的 Node.js 版本(v20.11.0 及以上)中,官方为 import.meta 实验性/稳定补充了 import.meta.filename 和 import.meta.dirname,可直接读取路径,无需手动转换。但在通用生产环境或对外开源库中,依然推荐使用上述基于 fileURLToPath 的兼容写法。
2. 顶层 this 指向
在 CommonJS 中,模块文件顶层的 this 指向当前模块的导出对象 exports(即 this === exports)。而在 ES Modules 中,所有模块默认在严格模式(use strict)下执行,顶层的 this 值为 undefined。
3. 原生顶层 await(Top-Level Await)
在 CommonJS 中,await 关键字必须包裹在 async 函数内部,模块顶层无法直接使用 await。而在 ES Modules 中,顶层支持直接使用 await,模块系统会等待 Promise 执行完毕再完成求值,极大简化了数据库连接、配置文件预读取等异步初始化逻辑:
// db.mjs (ES Modules 原生支持顶层 await)
import { connectDB } from './database.js';
// 直接在顶层执行异步等待,模块载入时自动 resolve
export const dbConnection = await connectDB();
三、在 Node.js 中配置与使用 ES Modules 的具体步骤
Node.js 默认将 .js 文件视为 CommonJS 规范。要让 Node.js 将代码按 ES Modules 运行,有清晰的操作步骤与文件命名规范。
步骤 1:在 package.json 中配置 "type": "module"
如果希望项目内所有普通的 .js 文件均以 ES Modules 规范运行,可在项目根目录的 package.json 中增加 "type": "module" 配置:
// package.json
{
"name": "my-esm-project",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"scripts": {
"start": "node index.js"
}
}
此时,该 package.json 所在目录及其所有子目录下,未特别指定扩展名的 .js 文件都会被 Node.js 作为 ESM 运行。
步骤 2:利用扩展名明确指定模块格式
无论 package.json 中的 type 设置为何,Node.js 都允许通过特定扩展名对单一文件进行强行覆盖:
.mjs:无论package.json中是否设置"type": "module",Node.js 一律以 ES Modules 规范解析。.cjs:无论package.json中是否设置"type": "module",Node.js 一律以 CommonJS 规范解析。.js:当最近的package.json包含"type": "module"时作为 ESM;反之(未设置或设为"commonjs")作为 CJS。
步骤 3:补齐相对导入的完整扩展名
在 CommonJS 中,我们习惯于书写 require('./utils'),Node.js 会自动尝试寻找 utils.js 或 utils/index.js。但在原生 ES Modules 中,Node.js 严格遵循类似浏览器的 URL 解析规则:相对路径导入必须写出完整的文件扩展名:
// 正确做法(包含扩展名)
import { calculate } from './utils/math.js';
// 错误做法(运行时会抛出 ERR_MODULE_NOT_FOUND 异常)
import { calculate } from './utils/math';
四、CommonJS 与 ES Modules 的互操作性与避坑指南
在实际项目迁移或引入 npm 第三方库时,经常遇到两种规范混用的场景。Node.js 针对跨规范调用制定了严格的单向互操作规则。
1. 在 ES Modules 中引入 CommonJS 模块
在 ESM 文件中引入 CommonJS 模块是被官方良好支持的。Node.js 会自动将 CommonJS 的 module.exports 整体映射为 ESM 的默认导出(default export):
// 在 ESM 文件中导入 CommonJS 模块
import lodash from 'lodash'; // 默认导入:lodash 代表整个 module.exports
const { cloneDeep } = lodash;
虽然 Node.js 内置了静态分析解析器尝试将 CommonJS 的属性转换为 ESM 具名导出(Named Export),但如果该 CJS 模块采用了动态属性挂载(例如 Object.assign(module.exports, ...) 或循环赋值),静态分析就会失效,从而抛出 SyntaxError: Named export not found。因此,在 ESM 中消费 CJS 模块最稳妥的方式是始终使用默认导入,再在下一行按需解构。
2. 在 CommonJS 中引入 ES Modules 模块
这是 Node.js 中最常见的报错场景。由于 CommonJS 的 require() 是纯同步执行的,而 ES Modules 包含异步构建与顶层 await 支持,因此 无法使用同步 require() 直接引入 ESM 模块。执行该操作会直接抛出以下致命错误:
// app.cjs (CommonJS)
const chalk = require('chalk'); // chalk v5 纯 ESM 包
// 报错:Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported.
在 CommonJS 环境中消费 ESM 模块的唯一官方途径是使用动态 import() 表达式。动态 import() 是一个全局异步函数,返回一个 Promise,无论在 CJS 还是 ESM 环境中均可使用:
// 在 CommonJS 中安全加载 ESM 模块的正确方式
async function run() {
// 动态 import() 加载纯 ESM 模块
const { default: chalk } = await import('chalk');
console.log(chalk.green('成功在 CommonJS 中动态调用了 ESM 模块!'));
}
run();
五、综合特性对比与技术选型建议
为了帮助大家在日常工程落地与模块选型时做出科学决策,我们将两者的关键指标与应用场景整理如下。
| 对比维度 | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| 标准化地位 | Node.js 社区早期规范,非浏览器原生标准 | ECMAScript 官方标准,浏览器与 Node.js 通用 |
| 语法标识 | require() / module.exports |
import / export |
| 加载时机 | 代码执行时同步读取并执行(运行时) | 构建、实例化、求值三阶段(编译期静态分析) |
| 导出值类型 | 值的浅拷贝(导出后内部修改外部无感知) | 实时只读引用(Live Binding,源变量变化即时同步) |
| 顶层 await | 不支持,必须包裹在 async 函数内 |
原生支持顶层顶级 await |
| Tree-Shaking | 难以静态推导死代码 | 天然适配构建工具进行死代码剔除 |
| 模块内上下文 | 有 __dirname, __filename, this === exports |
无内置路径变量,需 import.meta.url,this === undefined |
技术选型与工程化建议:
- 全新研发的现代 Node.js 服务:建议直接在
package.json中启用"type": "module",全面拥抱 ES Modules。这样不仅能够无缝对接主流前端打包工具(Vite、Rollup、esbuild),还可以原生享受顶层 await 以及未来 JavaScript 标准演进带来的所有新特性。 - 大型历史遗留工程或运维脚本:若现有项目深度依赖旧版 npm 包、大量动态
require()逻辑或传统构建脚本,建议继续维持 CommonJS 规范,切忌盲目全量升级。如个别业务需要引入纯 ESM 的依赖,采用await import()局部隔离桥接即可。 - 发布跨生态的开源 NPM 类库:如果作为库作者发布开源包,最佳实践是采用 Dual Package(双模支持)。通过构建工具(如 Rollup 或 tsup)分别打包出
dist/index.cjs与dist/index.mjs,并在package.json的exports字段中进行条件导出映射,让使用 CJS 的旧项目与使用 ESM 的新项目均能开箱即用。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。