深入解析JavaScript两大主流模块规范CommonJS与ES Module的核心差异,涵盖语法规范、加载时机、导出机制、循环引用处理及现代工程选择策略。
JavaScript模块系统详解:CommonJS与ES Module的区别
时间:2026-10-30
浏览:258
文章目录
一、为什么 JavaScript 会存在两套主流模块标准
早期的 JavaScript 并没有语言内置的模块系统,代码往往通过全局作用域或立即执行函数(IIFE)组织,极易引发命名冲突与依赖维护灾难。随着 Node.js 在 2009 年诞生,社区制定了面向服务端的 CommonJS(简称 CJS)规范,允许通过 require() 和 module.exports 同步加载本地文件模块。
随后,ECMAScript 在 2015 年(ES6)推出了官方模块标准 ECMAScript Modules(简称 ESM),使用 import 与 export 关键字,确立了统一浏览器与服务端模块体系的目标。现代 JavaScript 生态正逐步从 CommonJS 向 ES Module 迁移,但两者在设计目标与执行模型上有着根本性的分歧。
二、核心差异一:语法形态与加载时机
CommonJS 模块加载是动态且同步的。require() 是一个内置函数,可以在代码的任意位置调用,例如放置在 if 分支内部或函数执行体中。这也意味着其导入路径可以动态拼接:
// CommonJS 动态加载示例 (运行时解析)
const configName = process.env.NODE_ENV === 'production' ? 'prod' : 'dev';
const config = require(`./config.${configName}.js`);
与此相对,ES Module 的导入导出是静态声明式的。标准 import 声明必须置于模块的顶层作用域,导入路径必须是固定的字符串字面量,不能包含变量。JavaScript 引擎在解析代码阶段(即执行代码之前)就会扫描所有的 import 和 export 语句,提前构建模块依赖拓扑图。
// ES Module 静态导入示例 (语法分析阶段确定)
import { queryUser, updateUser } from './userService.js';
export const API_VERSION = 'v1';
静态导入的特性赋予了现代打包工具(如 Webpack、Rollup、Vite)进行 Tree-shaking(死代码消除) 的能力。因为模块在未执行前就已经确定了导出与导入的引用关系,打包器可以安全地剔除未被使用的导出函数;而 CommonJS 导出的对象具有动态修改的可能,无法可靠进行静态剔除。
import() 动态函数。import() 返回一个 Promise 对象,用于需要懒加载或条件加载的场景,与静态 import ... from ... 语法并行不悖。
三、核心差异二:导出机制与内存值传递
很多开发者在日常使用中最容易踩坑的地方,在于两者导出数据时对变量内存的处理方式。
- CommonJS 导出的是“值的浅拷贝”:当一个模块执行
module.exports = { count }时,输出的是该变量在当时时间点的浅拷贝副本。即使源模块内部后续修改了count,导入方获取到的依然是最初拷贝的值。 - ES Module 导出的是“只读的动态引用(Live Binding)”:
export并不会复制数据,而是在内存中建立一个指针绑定。当源模块修改其内部变量时,外部所有引用该模块的地方都能即时读取到最新的值;但导入方不能对该绑定进行直接赋值修改。
下面通过具体代码进行验证:
CommonJS 值拷贝验证代码
// counter-cjs.js
let count = 1;
function increment() {
count++;
}
module.exports = { count, increment };
// main-cjs.js
const { count, increment } = require('./counter-cjs.js');
console.log(count); // 输出: 1
increment();
console.log(count); // 依然输出: 1 (外部只保留了最初解构出来的基础类型拷贝)
ES Module 实时绑定验证代码
// counter-esm.js
export let count = 1;
export function increment() {
count++;
}
// main-esm.js
import { count, increment } from './counter-esm.js';
console.log(count); // 输出: 1
increment();
console.log(count); // 输出: 2 (实时读取到模块内部的最新状态)
// count = 10; // 报错: TypeError: Assignment to constant variable.
四、核心差异三:循环引用(Circular Dependency)的处理
当模块 A 引用模块 B,同时模块 B 又引用模块 A 时,两种规范对这种循环依赖采取了截然不同的解决方案。
CommonJS 基于已执行部分缓存导出:当遇到循环 require() 时,不会产生死循环,而是立即返回目标模块当前已经执行完毕的 exports 属性。如果某些属性在该时间点尚未执行到,则会得到 undefined。
ES Module 基于引用指针延迟取值:由于 ESM 在静态编译阶段就已经完成了模块连接(Linking),符号插槽已在内存中创建。代码在求值阶段自下而上执行时,只要在具体访问该变量的时刻对应代码已经执行完毕,就可以安全获取到值。
五、其他关键差异维度
除了加载方式和数据流向,在 Node.js 运行时中混用这两种规范时,还会遇到以下具体差异:
| 对比维度 | CommonJS (CJS) | ES Module (ESM) |
|---|---|---|
| 官方规范制定方 | Node.js 社区 / CommonJS 规范组织 | TC39(ECMAScript 国际语言标准) |
| 顶层 this 指向 | 指向当前模块的 exports 对象 |
在顶层严格指向 undefined |
| 环境预置变量 | 自带 __filename, __dirname |
无预置变量,需用 import.meta.url 转换 |
| 顶层 Await 支持 | 不支持(必须包裹在 async 函数内) | 原生支持 Top-level Await |
| 跨模块混用 | 无法使用 require() 同步加载纯 ESM 模块 |
可直接使用 import 或 createRequire 加载 CJS |
六、现代工程选型与迁移建议
随着 Node.js 12+ 对 ES Module 的原生支持与工具链的完善,在实际工程中选择模块系统时可以遵循以下实践准则:
- 前端工程与现代应用开发:全面优先采用 ES Module。现代浏览器全部原生支持 ESM,且主流打包构建工具(Vite、Webpack 5)依赖 ESM 静态结构实现急速热更新(HMR)与 Tree-shaking 优化。
- Node.js 新建后端服务:建议在
package.json中配置"type": "module",直接以 ESM 模式编写服务端逻辑,享受标准语法与顶层await的便利。 - 公共 NPM 基础库开发:推荐采用双模式导出(Dual Package)。使用现代打包工具输出 ESM 与 CJS 两套构建产物,并在
package.json的exports字段中进行精确条件分发,确保老旧 CJS 项目与现代 ESM 项目均可无缝引入。
七、总结
回顾两者,CommonJS 以简单直观的运行时同步加载解决了服务端初期模块化的问题,但其动态特性限制了现代前端构建与死代码剔除;ES Module 则凭借其严谨的静态结构、实时符号绑定以及对浏览器运行时的原生适配,成为了现代 JavaScript 生态的基石。
理解两者的运行生命周期、值引用模型及循环依赖机制,能够帮助开发者在配置工程工具、排查生产环境怪异导入 Bug 以及设计通用 SDK 时更加得心应手。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。