JavaScript模块化规范:CommonJS与ES Module区别及使用
深入解析 JavaScript 两大主流模块化规范 CommonJS 与 ES Module 的核心概念、使用语法、底层加载机制与动态引用差异,并提供 Node.js 环境下的互操作实践指南。
在 JavaScript 语言发展的早期,语言规范自身并没有提供模块化机制,社区为了解决全局变量污染与依赖混乱问题,诞生了以 Node.js 为代表的 CommonJS (CJS) 规范;随后,ECMAScript 2015 (ES6) 正式推出了语言层面的官方模块系统 ES Module (ESM)。两者的核心差异在于:CommonJS 属于运行时同步加载,导出的是值的浅拷贝;而 ES Module 采用编译时静态结构解析与异步加载,导出的是值的动态只读引用(Live Bindings)。本文将针对两者的语法规范、执行生命周期、内存引用模型以及在现代工程中的互操作方案进行系统梳理。
一、CommonJS 规范的语法与运作方式
CommonJS 最早在服务端由 Node.js 普及并作为默认模块系统。在 CommonJS 规范中,每一个 JavaScript 文件都被视为一个独立的模块实例。模块内部声明的所有变量与函数都处于该模块的私有作用域中,不会泄漏到全局作用域。
1. 模块导出:module.exports 与 exports
CommonJS 通过 module.exports 对象对外暴露内容。在每个模块执行前,Node.js 会在顶层注入 exports 变量,该变量初始时是指向 module.exports 的一个内存引用。
// utils/math.js
const PI = 3.1415926;
function add(a, b) {
return a + b;
}
// 方式一:直接在 exports 上挂载属性
exports.PI = PI;
exports.add = add;
// 方式二:直接覆盖 module.exports(常用于导出单例函数或类)
module.exports = {
PI,
add,
multiply(a, b) {
return a * b;
}
};
由于模块最终对外返回的是 module.exports 指向的对象,如果执行 exports = { add },只是切断了 exports 与 module.exports 的关联引用,并不会改变 module.exports,导致导出失效。正确的做法是操作 exports.xxx = ... 或直接赋值 module.exports = ...。
2. 模块导入:require()
使用内置全局函数 require(modulePath) 来同步导入模块。调用 require 时,运行时会执行目标模块的脚本并返回其 module.exports 对象的拷贝引用。
// app.js
const { add, multiply, PI } = require('./utils/math.js');
console.log(add(2, 3)); // 输出: 5
console.log(multiply(4, PI)); // 输出: 12.5663704
// CommonJS 允许在条件分支中动态调用 require
if (process.env.NODE_ENV === 'development') {
const devHelper = require('./devHelper.js');
devHelper.init();
}
CommonJS 采用同步磁盘 I/O 读取并具有模块缓存机制。当某个模块第一次被 require 时,该模块会被完整执行一次,其导出对象被存入 require.cache。后续再次引入相同路径的模块时,直接从缓存读取,不会重复执行模块内部代码。
二、ES Module 规范的语法与运作方式
ES Module 是 ECMAScript 官方在 ES2015 规范中确立的标准。它被设计为面向浏览器和服务器的通用模块系统,最大的特点在于其依赖关系的静态化:在代码执行前的编译/语法解析阶段,模块的导入与导出依赖关系就已经确定。
1. 模块导出:命名导出与默认导出
ES Module 提供了两种导出方式:命名导出(Named Exports)与默认导出(Default Export)。
// utils/calculator.mjs
// 命名导出:声明时直接导出
export const VERSION = '2.0.0';
export function calculateTax(amount, rate) {
return amount * rate;
}
// 统一集中命名导出
const DEFAULT_RATE = 0.08;
export { DEFAULT_RATE };
// 默认导出:每个模块只能有一个 default 导出
export default class Calculator {
constructor(base = 0) {
this.base = base;
}
}
2. 模块导入:静态 import 与动态 import()
静态 import 语句必须出现在模块的顶层作用域,不能包裹在 if、循环或函数内,这保证了编译器能够无障碍地分析依赖图(Dependency Graph)。
// main.mjs
// 1. 混合导入默认与命名导出
import Calculator, { VERSION, calculateTax } from './utils/calculator.mjs';
// 2. 重命名导入
import { DEFAULT_RATE as taxRate } from './utils/calculator.mjs';
// 3. 命名空间整体导入
import * as Calc from './utils/calculator.mjs';
// 4. 动态按需导入:返回 Promise
if (condition) {
const module = await import('./analytics.mjs');
module.sendReport();
}
3. 在不同环境中启用 ES Module
- Node.js 环境:
- 在
package.json中声明"type": "module",此时项目内所有.js文件均被解析为 ESM。 - 或者将文件扩展名显式命名为
.mjs(即便package.json未配置,Node.js 也会以 ESM 解析)。
- 在
- 现代浏览器环境:
在 HTML 脚本标签上显式声明
type="module",浏览器将自动以异步跨域安全的模块模式下载并执行:<script type="module" src="./main.js"></script>
三、CommonJS 与 ES Module 的关键区别深度解析
很多初学者仅把两者的区别看作语法上 require 与 import 的形式不同。但在实际工程与性能优化中,底层运作机制的差异直接决定了构建产物体积、热更新效率以及程序的执行正确性。
1. 加载时机与执行机制:运行时加载 vs 编译时静态解析
CommonJS 属于运行时求值(Runtime Evaluation)。当你调用 require('./module') 时,JavaScript 引擎会立刻暂停当前脚本,同步读取文件、解析执行,并把 module.exports 的值注入到当前变量中。因为 require() 是一个常规的函数调用,它可以写在任何表达式、if 分支或循环中:
// CommonJS 支持完全动态的路径计算与条件加载
const moduleName = condition ? './pluginA' : './pluginB';
const plugin = require(moduleName);
ES Module 采用静态分析(Static Analysis)与分阶段处理。ECMAScript 规范将 ESM 的加载与执行严格划分为三个阶段:
- 构建阶段(Construction):查找、下载并解析所有涉及的
.mjs文件,解析import与export声明生成 Module Record(模块记录)。如果存在语法错误或找不到导入符号,在此阶段就会报错,尚未执行任何一行用户代码。 - 实例化阶段(Instantiation):在内存中分配模块环境记录(Module Environment Record),将所有的
export与对应的import建立符号指针链接(链接内存槽位),此时内存槽中尚未填充实际的值。 - 求值阶段(Evaluation):按照依赖拓扑图从叶子节点到根节点依次执行模块内部代码,计算表达式并将求得的值填入实例化时开辟的内存槽中。
2. 值的绑定机制:值的拷贝 vs 动态只读引用(Live Bindings)
这是 CommonJS 和 ES Module 最根本的行为区别,直接影响数据同步与状态维护:
- CommonJS 导出的是值的浅拷贝(值缓存):在执行完模块导出语句后,导出的原始类型(数字、字符串、布尔值)即成为一份独立的副本。模块内部后续对该变量进行修改,外部引入的值不会自动更新。
- ES Module 导出的是动态只读引用(Live Bindings):导入方并不拥有一份独立的值拷贝,而是拥有一个指向导出模块内部环境记录的符号链接。当模块内部更新该变量时,导入方读取到的值会实时发生变化;同时,导入方不能直接修改导入的值,赋值操作会抛出
TypeError。
以下示例清晰地展现了这一差异:
/* ----- CommonJS 表现 ----- */
// counter-cjs.js
let count = 1;
function increment() {
count++;
}
module.exports = { count, increment };
// test-cjs.js
const { count, increment } = require('./counter-cjs.js');
console.log(count); // 输出: 1
increment();
console.log(count); // 依然输出: 1!(因为导入的是解构时的数字浅拷贝)
/* ----- ES Module 表现 ----- */
// counter-esm.mjs
export let count = 1;
export function increment() {
count++;
}
// test-esm.mjs
import { count, increment } from './counter-esm.mjs';
console.log(count); // 输出: 1
increment();
console.log(count); // 实时输出: 2!(动态指针绑定,实时同步)
count = 10; // 报错: TypeError: Assignment to constant variable! (外部只读)
3. 运行上下文与全局内置对象的差异
从 CommonJS 切换到 ES Module 时,很多开发者会遭遇“变量不存在”的报错,这是由于顶层运行环境上下文不同:
- 顶层
this的指向:在 CommonJS 模块中,顶层的this指向exports(即module.exports);而在 ES Module 中,顶层this严格定义为undefined。 - Node.js 特有内置全局变量:CommonJS 模块被包裹在一个函数闭包内,因此拥有预设的
__dirname(当前文件所在目录)和__filename(当前文件绝对路径)。而在 ES Module 中,这些变量均不存在,必须使用import.meta.url转换:// 在 ESM (.mjs 或 "type": "module") 中获取当前路径的标准解法 import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);
四、Node.js 环境下的互操作与最佳实践
在实际项目演进中,往往无法在瞬间将所有代码库完全迁移为 ESM,CommonJS 和 ES Module 在同一项目中共存是长期的常态。正确处理两者的互操作是保障系统稳定运行的关键。
1. ES Module 引入 CommonJS 模块
在 ES Module 中引入 CommonJS 模块,Node.js 提供了良好的向下兼容。可以通过默认导入方式获取 CommonJS 的 module.exports:
// 在 ESM (main.mjs) 中引入 CommonJS
import cjsPackage from './legacy-cjs.cjs';
cjsPackage.doSomething();
// 如果需要引入特定纯 CJS 专属工具,还可以借助 createRequire 构建同步 require 函数:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const packageJson = require('./package.json');
在 ESM 中对 CommonJS 模块使用解构命名导入(例如 import { add } from './math.cjs')依赖 Node.js 内部的 cjs-module-lexer 静态分析。对于高度动态赋值的 module.exports,静态分析可能无法提前识别导出的属性,导致报错。推荐始终使用默认导入,然后从默认对象中解构。
2. CommonJS 引入 ES Module 模块
核心限制:CommonJS 模块无法使用同步的 require() 引入 ES Module。如果在 CommonJS 文件中执行 require('./esm.mjs'),Node.js 会抛出不可捕获的运行时异常:ERR_REQUIRE_ESM。
唯一正确的引入方式是使用动态 import()。由于动态 import() 本身返回 Promise,因此必须采用异步方式消费:
// 在 CommonJS (app.cjs) 中引入 ES Module
async function loadESM() {
// 必须使用动态 import(),不能使用 require()
const { default: Calculator, VERSION } = await import('./calculator.mjs');
const calc = new Calculator();
console.log('ESM Version:', VERSION);
}
loadESM();
3. 核心差异快速速查表
| 对比项目 | CommonJS (CJS) | ES Module (ESM) |
|---|---|---|
| 规范提出方 | Node.js 社区规范 | ECMAScript 官方标准 (ES2015+) |
| 关键字语法 | require() / module.exports |
import / export / import() |
| 加载与解析阶段 | 运行时动态同步加载 | 编译时静态解析 + 运行时异步执行 |
| 导出数据本质 | 值的浅拷贝(值缓存副本) | 符号的动态只读引用(Live Bindings) |
| Tree Shaking 支持 | 极难(动态赋值无法预测) | 原生无缝支持(基于静态拓扑结构) |
| 作用域顶级 this | 指向 module.exports |
undefined |
| 路径元数据获取 | __dirname, __filename |
import.meta.url 结合 fileURLToPath |
| 浏览器支持 | 原生不支持(必须借助 Webpack/Vite 等打包) | 现代浏览器原生支持(<script type="module">) |
| 互相调用姿势 | 引入 ESM:必须使用异步 import() |
引入 CJS:推荐默认导入 import pkg from '...' |
五、总结与工程选型建议
CommonJS 作为伴随 Node.js 成长十余年的经典模块方案,以其简单直观的运行时同步模型支撑起了丰富的 npm 生态;而 ES Module 作为现代 ECMAScript 的统一标准,凭借静态结构分析、原生异步拓扑、支持 Tree Shaking 与动态 Live Bindings,代表着 JavaScript 工程化演进的未来方向。
在实际项目工程中:
- 对于新建的前端应用与现代 Node.js 服务,应优先使用
"type": "module"全面采用 ES Module 规范,以便享受现代构建工具的极致树摇优化与跨端复用能力。 - 对于开发公共开源类库(npm package),建议在
package.json中通过exports字段配置 Dual Package(双模块导出),分别提供import(ESM)和require(CJS)入口,最大化保障下游不同架构项目的消费兼容性。 - 在维护老旧 CommonJS 项目且需消费纯 ESM 依赖时,应当熟练掌握动态
import()或采用.mjs/.cjs显式扩展名隔离技术,避免出现ERR_REQUIRE_ESM阻塞系统运行。
Node.js 安装配置与第一个 HTTP 服务器编写
2026-11-05 11:41:40
怎么使用 nvm 管理并平滑升级 Node.js 版本
2026-11-04 23:15:07
Node.js 怎么使用 zlib 模块解压 gzip 和 deflate
2026-11-03 23:40:50
直接覆盖安装还是用版本管理器?Node.js 升级方式对比
2026-11-03 22:59:07
Express.js 是什么:Node.js Web 框架核心概念与适用场景
2026-11-03 20:00:12








