现代 JavaScript 模块化深度解析:CommonJS vs ES Modules
本文基于 Node.js 22 LTS / 20.19+(2026 年现状) 校对。涉及模块互调的结论与老教程(2022 年前)差异较大,请注意甄别存量资料。
在 JavaScript 的演进历程中,模块化规范的制定解决了代码隔离、依赖管理和工程复用的核心痛点。作为前端架构师,深刻理解 CommonJS (CJS) 与 ES Modules (ESM) 的底层机制、边界差异及混合工程实践,是构建高性能、高可维护性现代应用的基础。
一、 核心概念与硬核对比
下表从底层运行机制到工程化落地,对两套规范进行全方位对比:
| 维度 | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| 标准属性 | 社区标准(2009年由 ServerJS 演进而来) | 语言级官方标准(ES6 / ES2015 正式提出) |
| 核心语法 | require() / module.exports |
import / export |
| 实现载体 | 纯宿主函数实现(利用闭包与环境注入) | JS 引擎底层实现(V8/SpiderMonkey 原生内核) |
| 加载机制 | 运行时同步阻塞加载 | 静态解析(先于执行) + 异步加载流 |
| 值传递方式 | 共享的 exports 对象引用,但属性在导出时快照赋值(基本类型不联动,对象属性联动) | 值的动态绑定(Live Bindings)(只读引用,始终联动) |
| 动态依赖 | 天然支持(支持路径拼接、条件分支内加载) | 静态 import 不支持,通过 import() 异步函数实现 |
| Tree-Shaking | ⚠️ 基本无效(打包器仅对极简字面量模式做有限优化,不可靠) | 天然支持,打包工具可精准剔除未引用代码 |
| 循环依赖 | 返回当前已执行部分的 exports 快照,可能出现部分导出为 undefined |
建立 Live Binding 引用图,在使用前完成初始化即可(否则触发 TDZ 报错) |
| 严格模式 | 默认非严格模式 | 永远处于严格模式 |
| 顶层 this | this === module.exports(仅模块顶层成立,函数内不成立) |
this === undefined |
| 路径工具 | 注入 __filename / __dirname |
使用 import.meta.dirname / import.meta.filename(Node 20.11+)或 import.meta.url |
| 环境支持 | 仅 Node.js 等服务端环境原生支持(浏览器需打包) | 现代浏览器及现代 Node.js (v14+) 原生支持 |
二、 底层原理深度解剖
1. CommonJS 的函数式包装与环境限制
CommonJS 并非 JavaScript 官方规范,它的实现完全不需要 JS 引擎做底层修改。在 Node.js 环境中,每个 CommonJS 模块在执行前,底层的 Module 模块会利用一个闭包函数外壳将其包裹起来:
// Node.js 底层的模块包装器伪代码
(function(exports, require, module, __filename, __dirname) {
// 你的 CJS 模块代码实际运行在这里
const math = require('./math');
module.exports = { ... };
});
- 原生的环境限制:因为
require、module等本质上只是该外层函数的形参,且依赖 Node.js 底层特有的磁盘 I/O 能力,因此现代浏览器无法原生支持 CommonJS(直接运行会抛出require is not defined)。前端工程中能使用 CJS,全靠 Webpack 等打包工具在编译期模拟了这套函数包装机制。
2. exports / module.exports / this 的三角关系
CJS 中最经典的"导出丢失"事故来源。包装器被调用时,exports 形参和 this 值都被绑定为 module.exports:
// 包装器的实际调用方式(伪代码)
wrapper.call(module.exports, module.exports, require, module, __filename, __dirname);
// ↑ this 值 ↑ exports 形参
- 初始状态下,三者指向同一个对象:
// 模块顶层(非严格模式)
console.log(this === module.exports); // true
console.log(exports === module.exports); // true
- 但"同一对象"靠共享引用维持,重新赋值即断裂——
exports和this只是初始对象的别名快捷方式,require()真正返回的只有module.exports:
// ✅ 挂载属性:三者同生共死
exports.foo = 1;
console.log(module.exports.foo); // 1
// ❌ 对 exports 重新赋值:别名被"剪断"
exports = { foo: 1 }; // exports 指向了新对象
console.log(module.exports); // {} —— 消费方拿到空对象,foo 无声丢失
// ❌ 反过来对 module.exports 重新赋值后,exports / this 仍指向旧的空对象
module.exports = { foo: 1 };
console.log(exports.foo); // undefined
this === module.exports只在模块顶层成立:模块内部的普通函数里,this是globalThis(非严格模式)或undefined(严格模式);顶层的箭头函数捕获顶层this,所以箭头函数内仍是module.exports。- 工程结论:只用
module.exports.xxx = ...(或统一用exports.xxx = ...但绝不对其整体重赋值),不要混用。
3. CJS 的动态依赖与同步阻塞机制
因为 require() 本质上是一个普通的 JavaScript 函数调用,所以它具备完全的动态性与同步性:
- 动态依赖:依赖关系不需要提前声明,必须等到代码实际运行到那一行时才能确定。路径可以动态拼接,也可以放入
if-else分支。 - 同步执行:当主线程运行到
require时,会停下来阻塞后续代码,直到本地磁盘将该模块文件读取、解析并执行完毕,返回module.exports后,主线程才会继续往下走。在服务器端由于是本地磁盘读取,性能损耗可控;但在网络环境下的浏览器端,这种同步阻塞会导致页面彻底卡死。
4. 静态解析 (ESM) vs 动态执行 (CJS)
- CJS (运行时):如上所述,在运行时动态同步读取、包裹并执行,最后将结果写入
require缓存(同一模块多次require返回同一个对象引用)。 - ESM (编译前静态阶段):JS 引擎在真正执行代码前,会先对代码进行静态分析(Module Record)。它会先找出所有的
import,下载并解析模块,构建出一棵模块依赖图(Module Graph),然后再按照深度优先遍历的顺序执行代码。
5. 导出快照 (CJS) vs 引用绑定 (ESM)
这是工程中最容易引发 Bug 的地方。注意:CJS 并非"值的拷贝"——require 返回的是带缓存的、全局共享的 module.exports 对象引用。真正准确的机制是:
- CJS:模块执行时,把"当时的值"赋值给
exports对象的属性。基本类型 = 快照(之后不联动);对象/函数 = 属性引用共享(修改可见)。 - ESM:导出的是指向模块内部变量的只读 Live Binding,任何时刻读取的都是模块内的最新值。
💡 CommonJS 示例(基本类型快照,不联动):
// counter.js (CJS)
let count = 1;
function increment() { count++; }
module.exports = { count, increment }; // 基本类型 count 在此刻被"快照"进 exports
// main.js
const counter = require('./counter');
console.log(counter.count); // 1
counter.increment();
console.log(counter.count); // 1 <-- increment 改的是模块内部变量,exports.count 永远是快照值
💡 CommonJS 反例(对象属性是共享引用,联动!):
// counter.js (CJS)
const state = { count: 1 };
module.exports = {
state, // 导出的是对象的引用
increment() { state.count++; } // 修改的是同一个对象的属性
};
// main.js
const counter = require('./counter');
counter.increment();
console.log(counter.state.count); // 2 <-- 外部完全感知到了变化
💡 ES Modules 示例(Live Binding,永远联动):
// counter.js (ESM)
export let count = 1;
export function increment() { count++; }
// main.js
import { count, increment } from './counter.js';
console.log(count); // 1
increment();
console.log(count); // 2 <-- 动态绑定,实时映射模块内部的最新值
// count = 3; // ❌ 报错!导入的变量是只读的(Read-only binding)
6. ESM 原理速览:语法级标准 + 双依赖模型 + 符号绑定
用一句话概括 ESM:由 JS 引擎实现的语法级官方标准,与 CJS 的"宿主函数注入"形成根本对照。可以从四个维度完整收束:
- 官方标准 + 新语法实现:
import/export是 ES2015 引入的语言关键字,由引擎在语法分析阶段直接识别——而不是 CJS 那样靠宿主注入require/module形参。这是 ESM 一切"静态能力"的根源。 - 现代环境均原生支持:现代浏览器(
<script type="module">)、Node.js(v14+,.mjs或"type": "module")、Deno / Bun / Edge Runtime。注意边界:老浏览器(如 IE11)与老 Node 不支持,需要打包工具降级转译。 - 同时支持静态依赖与动态依赖:
- 静态依赖(顶层
import声明):路径必须是字符串字面量,声明会被提升(hoisting),依赖关系在代码运行前(解析阶段)就已确定——这是构建 Module Graph 和 Tree-Shaking 的前提。 - 动态依赖(
import()):返回 Promise,异步加载,路径可动态拼接、可写在条件分支或函数体内,依赖关系到运行时才确定。⚠️ 注意:import()只是长得像普通函数,实际是引擎实现的特殊语法形式——不能赋值给变量、不能作为回调传递、无法被普通函数 polyfill。
- 静态依赖(顶层
- 符号绑定(Live Binding):
import得到的是对导出模块内部变量的只读符号绑定,而非值快照——导出方修改,导入方实时可见(机制与示例见第 5 节)。
三、 现代架构中的混合工程实践(Dual-Package)
在当前的过渡期(Node.js 全面转向 ESM,但 npm 生态仍有大量 CJS),架构师必须处理"双模"共存的问题。
1. 避坑指南:Node.js 中的模块互调规则(现代版)
- ESM 引入 CJS:
- 支持。
import pkg from 'cjs'(default 引入)永远可用,拿到的是整个module.exports。 - 命名解构导入同样支持:Node 内置 cjs-module-lexer 静态分析器,只要 CJS 导出是静态可分析的(
exports.foo = ...、module.exports = { a, b }等字面量模式),import { add } from './math.cjs'完全合法。 - 唯一的限制:当导出是动态生成的(如
Object.keys(obj).forEach(k => exports[k] = ...)),词法分析失败,此时只能 default 整体引入后再手动解构。
- 支持。
// math.cjs
module.exports = { add, sub };
// main.mjs —— 现代 Node 中正常运行
import { add } from './math.cjs';
import pkg from './math.cjs'; // pkg === module.exports,同样可用
- CJS 引入 ESM:
- 现代 Node(≥ 22.12 / ≥ 20.19):
require(esm)已默认启用,可以直接同步加载 ESM,ERR_REQUIRE_ESM已成历史。返回的是 module namespace 对象(default导出挂在.default上)。 - 硬性限制:目标模块图内不能包含 top-level await,否则抛
ERR_REQUIRE_ASYNC_MODULE;文件必须是.mjs或"type": "module"下的.js。 - 老版本 Node(< 20.19):只能使用动态导入
import()(异步)来加载 ESM 模块。
- 现代 Node(≥ 22.12 / ≥ 20.19):
// 现代 Node 中,CJS 直接 require ESM(无 top-level await 时)
const esm = require('./module.mjs'); // ✅ 返回 namespace 对象
console.log(esm.default);
// 老版本 Node 的兼容写法(异步,任何版本都可用)
const esm2 = await import('./module.mjs');
2. 基础架构落地:库开发者的双包兼容(Dual Package)
如果你正在设计一个企业级的基础 UI 库或工具库,为了同时兼容新老项目,需要在 package.json 中配置 Conditional Exports:
{
"name": "corporate-core-library",
"version": "1.0.0",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
- 扩展名必须用
.mjs/.cjs:不要依赖"type"字段去猜测.js的模块格式,.mjs/.cjs与包配置解耦,任何环境下行为都确定。 "types"条件放最前:TypeScript 按条件顺序匹配,放最后存在解析不到的边角情况。- Vite/Webpack/现代 Node 会识别
import字段并加载 ESM,享受 Tree-shaking。 - 老旧 Node.js/CJS 项目 会识别
require字段并加载打包好的 CJS 产物。
⚠️ 双包最大的坑:Dual Package Hazard
同一份库被 ESM 和 CJS 渠道各加载一次,进程内产生两份模块实例,导致:
instanceof跨实例判断失效(foo instanceof Foo返回false);- 单例状态、全局缓存分裂成两份,产生难以排查的灵异 Bug。
缓解方案:
- Wrapper 模式:把所有状态/类定义放在 CJS 侧,
.mjs入口只做export * from './index.cjs'式的 re-export,保证进程内永远只有一份真实实例; - 只发 ESM(2026 年越来越可行):现代 Node 已支持
require(esm),纯 ESM 库的 CJS 消费障碍基本消除,新库可以优先考虑 ESM-only。
四、 架构师的技术选型建议
- 新立项的应用(前端/后端):
- 全面拥抱 ESM。前端项目配合 Vite / Webpack 5,Node.js 服务端项目在
package.json中明确声明"type": "module"。
- 全面拥抱 ESM。前端项目配合 Vite / Webpack 5,Node.js 服务端项目在
- 存量老项目(BFF / Node.js 历史服务):
- 如果项目庞大且依赖了大量未维护的 CJS 传统三方库,保持 CJS 架构。盲目重构为 ESM 会带来巨大的历史包袱和运行风险。
- 好消息是:现代 Node 已支持
require(esm),老 CJS 项目消费新的纯 ESM 三方库不再是硬障碍,渐进式混用的空间比过去大得多。
- 团队规范演进:
- 在 CI/CD 阶段和代码规范(ESLint)中,逐步废弃
__dirname的使用。Node 20.11+ 直接使用import.meta.dirname/import.meta.filename,不再需要fileURLToPath(import.meta.url)的仪式代码,为未来全面 ESM 化扫清语法障碍。
- 在 CI/CD 阶段和代码规范(ESLint)中,逐步废弃
- ESM 落地细节备忘:
- Node 中 ESM 相对导入必须写全扩展名(
import './util.js'),没有 CJS 式的扩展名猜测; - ESM 永远严格模式,顶层
this === undefined,不要再写依赖this指向模块导出的老代码; - 导入 JSON 使用 Import Attributes:
import data from './x.json' with { type: 'json' }。
- Node 中 ESM 相对导入必须写全扩展名(

浙公网安备 33010602011771号