全面解析JavaScript模块化开发的核心概念,系统对比CommonJS与ES Module在语法规范、加载时机、数据传递语义、运行上下文及工程优化等维度的关键区别,并提供具体的互操作指南与选型建议。
JavaScript模块化开发入门:CommonJS与ES Module的区别
时间:2026-11-03
浏览:506
文章目录
一、规范背景与语法形式对比
在模块化方案成熟之前,JavaScript 开发者通常使用立即执行函数表达式(IIFE)或 AMD/CMD 方案来封装私有作用域。随着 Node.js 在 2009 年发布,CommonJS 规范作为服务端文件组织的核心标准确立下来;而 ES Module 则是 ECMAScript 官方在 2015 年推出的现代化标准,旨在为浏览器和服务器端提供一致的原生模块方案。
1. CommonJS 的语法规范
CommonJS 模块将每一个文件视为一个独立的模块实例。模块内部包含预置的 module、exports 以及 require 全局对象:
// math.js - CommonJS 导出方式
const PI = 3.1415926;
function add(a, b) {
return a + b;
}
// 导出对象或属性
module.exports = {
PI,
add
};
// main.js - CommonJS 导入方式
const { PI, add } = require('./math.js');
console.log(add(2, 3)); // 输出: 5
在 CommonJS 中,require() 是一个可以在任意逻辑位置调用的普通 JavaScript 函数,这意味着你可以把它放在 if 分支判断或循环内部,实现动态路径加载。
2. ES Module 的语法规范
ES Module 采用静态保留关键字 import 和 export,将模块规范直接提升为语法层面的机制:
// math.js - ES Module 导出方式
export const PI = 3.1415926;
export function add(a, b) {
return a + b;
}
// 默认导出
export default function multiply(a, b) {
return a * b;
}
// main.js - ES Module 导入方式
import multiply, { PI, add } from './math.js';
console.log(add(2, 3)); // 输出: 5
console.log(multiply(2, 3)); // 输出: 6
在 ES Module 中,静态的 import 声明必须出现在模块文件的最顶层,不能嵌套在任何 if 语句、函数或循环体中,这为编译阶段的静态依赖分析打下了基础。
二、加载机制差异:运行时同步 vs 编译时三阶段
在理解模块化时,最核心的技术分水岭在于:模块何时被解析?何时开始执行? 这一根本机制决定了两者的行为差异与优化上限。
1. CommonJS 的运行时即时加载流程
CommonJS 最初专为服务端设计。由于本地文件系统的读取时间通常以微秒计算,CommonJS 采用了同步阻塞模式:
- 当代码执行到
const a = require('./a.js')时,引擎立即暂停后续执行。 - 首先查询内置的
require.cache缓存对象;若已存在缓存,则直接返回其缓存的导出对象。 - 若未命中缓存,Node.js 会读取目标文件内容,使用函数包装器将其包裹起来:
(function(exports, require, module, __filename, __dirname) { ... }),然后同步执行该脚本。 - 脚本执行完毕后,将
module.exports的值注入缓存,并返回给调用者,最后才继续执行require下方的代码。
2. ES Module 的三阶段处理流程
ES Module 必须同时服务于浏览器网络请求与服务器环境。网络延迟可能高达数百毫秒,若采用同步阻塞将会造成页面渲染严重冻结。因此,ESM 规范将模块处理划分为三个清晰解耦的独立阶段:
- 构建阶段(Loading & Parsing): 引擎自顶向下解析入口文件的
import声明,递归定位并异步下载所有依赖脚本。在不执行代码的前提下,通过纯词法分析将文件转化为抽象语法树(AST),并生成“模块记录”(Module Record)。 - 实例化阶段(Instantiation): 引擎在内存中为所有导出的变量开辟内存插槽(Memory Slots),并将导入方的
import引用精准指向导出方的内存地址。此时仅仅建立引用指针通道,仍然不执行任何函数或计算逻辑。 - 求值阶段(Evaluation): 引擎按照拓扑排序,从最深层的依赖模块开始执行顶层代码,将计算出的真实数值填充到此前开辟的内存插槽中。
三、核心底层语义:值的浅拷贝 vs 动态只读引用
在日常开发中,许多初学者经常对“模块导出的值在外部为什么没有随之改变”产生困惑。这是由于 CommonJS 与 ES Module 在内存模型层面的本质差异决定的。
1. CommonJS:值的浅拷贝(值快照)
当通过 module.exports = { count, inc } 导出时,实际上是创建了一个 JavaScript 对象,并将 count 当前持有的基本类型值直接复制到该对象的属性上:
// counter-cjs.js
let count = 1;
function inc() {
count++;
}
module.exports = {
count,
inc
};
// main-cjs.js
const { count, inc } = require('./counter-cjs');
console.log(count); // 输出: 1
inc(); // 模块内部的私有变量 count 累加变成了 2
console.log(count); // 依然输出: 1 !外部持有的是旧值的拷贝快照
由于 CommonJS 导出的是对象的浅拷贝,在模块内部执行 inc() 修改的是该模块闭包中的内部变量 count,而外部解构得到的 count 依然停留在初次执行加载时复制的数值上,两者在内存中已经脱钩。
2. ES Module:动态只读引用(Live Bindings)
与 CommonJS 完全不同,ES Module 的 export 导出的不是值的快照,而是对内存插槽的实时绑定通道(Live Binding):
// counter-esm.js
export let count = 1;
export function inc() {
count++;
}
// main-esm.js
import { count, inc } from './counter-esm.js';
console.log(count); // 输出: 1
inc(); // 模块内部修改了 count
console.log(count); // 输出: 2 !动态引用实时感知内部变化
// 注意:导入的绑定是只读的
count = 10; // 抛出 TypeError: Assignment to constant variable.
在 ES Module 中,外部文件的 count 变量像一个只读指针,始终直通导出模块所在的内存插槽。当模块内部修改 count 时,外部访问会立即看到最新的值;同时,ESM 规范强制规定导入的绑定具有只读性,外部文件绝不能直接重写导入的变量,保证了模块状态的单向可控。
四、运行环境与上下文差异
除了导入导出的执行机制,两者在运行上下文、顶层变量和异步特性上也存在显著差异:
1. 顶层 this 的指向
- CommonJS: 模块顶层的
this指向当前模块的导出对象,即this === exports === module.exports。 - ES Module: 严格遵循标准 JavaScript 的严格模式(Strict Mode),模块顶层的
this永远是undefined,防止意外污染全局对象。
2. 文件路径变量:__dirname 与 import.meta.url
在 Node.js CommonJS 环境下,每个模块在包裹函数中被直接注入了 __dirname(当前文件所在文件夹绝对路径)和 __filename(当前文件完整绝对路径)。而在 ES Module 中,这两个变量不复存在,需要通过 import.meta.url 结合 Node.js 原生模块进行转换:
// CommonJS 获取路径
const path = require('path');
const configPath = path.resolve(__dirname, 'config.json');
// ES Module 获取路径等价写法
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const configPath = resolve(__dirname, 'config.json');
3. 顶层 await(Top-level Await)
在现代 ES Module 中,原生允许在模块的最顶层直接编写 await 关键字,而无需将其包裹在 async function 自执行函数内。执行引擎会在求值阶段等待该 Promise 决议,再继续执行依赖该模块的其他模块。CommonJS 则无法在顶层使用 await,必须使用异步函数进行封装。
五、工程实践:互操作性与配置选型
在现阶段的 Node.js 与前端项目中,CommonJS 与 ES Module 常常共存。掌握两者之间的相互调用规则与配置策略,是避免诸如 ERR_REQUIRE_ESM 报错的关键。
1. Node.js 中的模块类型识别规则
Node.js 依据两个核心依据来判定一个 .js 文件到底是 CommonJS 还是 ES Module:
package.json中的"type"字段:- 若声明
"type": "module",则当前项目目录下所有.js文件默认均作为 ES Module 处理。 - 若未声明或声明
"type": "commonjs",则所有.js文件默认作为 CommonJS 处理。
- 若声明
- 文件显式扩展名:
.mjs文件无论package.json如何设置,均强制作为 ES Module 解析。.cjs文件无论package.json如何设置,均强制作为 CommonJS 解析。
2. 双向互操作规则(调用边界)
1. ESM 可以调用 CommonJS: 使用默认导入 import pkg from './cjs.cjs' 或通过 createRequire 创建 require 实例均可安全引入。
2. CommonJS 无法同步引入 ESM: 绝不能使用 require('./esm.mjs'),否则会直接抛出 ERR_REQUIRE_ESM 错误。若必须在 CJS 中调用 ESM,必须使用动态函数 import('./esm.mjs') 异步加载并处理 Promise。
// 在 CommonJS 文件中调用 ES Module 必须采用动态 import()
async function loadData() {
// 动态 import() 返回一个 Promise
const { processData } = await import('./esm-utils.mjs');
processData();
}
六、核心区别总结表
为了便于速查与复习,以下将 CommonJS 与 ES Module 在日常开发所涉及的核心维度进行系统汇总:
| 对比维度 | CommonJS (CJS) | ES Module (ESM) |
|---|---|---|
| 核心语法 | require() / module.exports |
import / export / export default |
| 规范来源 | 社区规范,由 Node.js 推广流行 (2009) | ECMAScript 官方标准 (ES6 / 2015) |
| 加载时机 | 运行时同步加载执行,可置于条件与循环内 | 编译阶段静态构建依赖树,三阶段异步解耦 |
| 导出值传递 | 值的浅拷贝(生成当前快照,后续与原值脱钩) | 动态只读引用(Live Bindings)(实时保持同步) |
| Tree-shaking | 难以进行静态死代码剔除 | 原生强力支持,编译期精准剔除未引用代码 |
| 顶层 this | 指向当前模块导出对象 module.exports |
严格模式下为 undefined |
| 路径全局变量 | 原生注入 __dirname 与 __filename |
通过 import.meta.url 组合转换获取 |
| 顶层 await | 不支持,必须包裹在 async 函数内 | 原生语法级支持顶层 await |
| 互操作边界 | 无法直接同步 require ESM;必须用动态 import() |
可直接 import CJS 模块并获取其 default 导出 |
七、写在最后
理解 CommonJS 与 ES Module 的区别,不仅是熟悉两套语法差异,更是理解现代 JavaScript 工具链演进的基础。在今天的新项目构建中,优先选用 ES Module 已成为前端与 Node.js 社区的共识,它能够为现代构建工具(如 Vite、Rollup、Webpack)提供完整的静态分析能力,最大化减小产物体积;而在维护存量 Node.js 基础设施或使用尚未完成 ESM 改造的第三方库时,熟练运用 .cjs、动态 import() 和条件导出,也能确保整个开发体系平稳高效地运行。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。