深入剖析 JavaScript 中 CommonJS 与 ES Module 的底层加载机制与设计哲学,解析运行时同步解析与静态编译分阶段构建、值拷贝与只读引用的差异及循环引用的处理方案。
JavaScript模块系统原理:CommonJS与ES Module加载机制区别
时间:2026-10-30
浏览:631
文章目录
在现代化前端工程或 Node.js 服务端研发中,你或许遇到过类似场景:将某个项目的构建工具从 Webpack 迁移到 Vite 时,原先工作良好的业务逻辑突然抛出 ReferenceError: exports is not defined;或者在同一个模块里解构引入变量后,发现源模块内部修改了状态,当前文件读取到的依然是旧值。这些表象并非打包器配置瑕疵,其本质在于 CommonJS(CJS)与 ECMAScript Modules(ESM)两套模块规范在加载时机、依赖解析树构建以及内存绑定机制上存在根本性的底层差异。本文将拆解两者的加载机制底层原理,厘清值复制与实时引用、同步阻塞与分阶段异步求值的执行路径。
一、根本分水岭:动态执行与静态解析
理解两套模块系统差异的切入点,在于把握它们被设计时的运行环境与网络模型:
- CommonJS:诞生于 2009 年的 ServerJS 社区,主要服务于服务器端(Node.js)。服务器环境的模块代码存放在本地磁盘,读取耗时极低(通常为微秒级)。因此 CJS 采用了同步阻塞式的运行时加载设计——执行到
require()时立即暂停当前脚本,读取磁盘文件并执行完毕后,返回导出的值。 - ES Module:作为 ECMAScript 2015 (ES6) 纳入语言标准的模块规范,首要考量了浏览器网络请求场景。在浏览器中,模块需要通过 HTTP 下载,网络往返延迟往往达数百毫秒。如果采用同步阻塞加载,页面将彻底冻结。因此 ESM 采用了静态声明式、异步分阶段的加载机制,在代码真正执行前构建模块依赖图谱。
静态语法的核心意义在于:在 JavaScript 引擎执行第一行代码之前,模块的导出(export)和导入(import)关系已经确定。编译器能够直接执行死代码消除(Tree Shaking)和作用域扁平化,这是动态计算路径的 require() 无法原生做到的。
二、CommonJS 运行时同步加载机制全流程
Node.js 中的 CommonJS 模块并不存在独立的“编译解析期”,每个文件都是在调用 require() 的瞬间被当场读取、包装并执行的。整个运行流程可以归纳为五个严格顺序执行的步骤。
图 1:CommonJS 运行时包装函数注入与同步执行流程
1. 路径解析与缓存机制
当执行 require('./math') 时,Node.js 内部会先调用 Module._resolveFilename(),把相对路径计算为操作系统的绝对路径(例如 /app/math.js)。随后,Node.js 会在全局对象 Module._cache 中检索是否存在以该绝对路径为键的值。如果存在,直接返回其 module.exports,不再重复读取磁盘。
2. 闭包函数包装
在没有缓存的前提下,Node.js 会读取该文件文本,并通过 Node.js 内置的包装器将其包裹在一个立即执行函数中:
// Node.js 源码实现逻辑示意
(function(exports, require, module, __filename, __dirname) {
// 你的文件源代码在此处执行
const privateVar = 100;
module.exports = { value: privateVar };
});
这解释了为何 CommonJS 文件中可以自由使用 __dirname 与 module 等变量,同时文件顶层声明的变量不会污染全局对象 global。
在函数初始入参中,exports 是形参,指向与 module.exports 相同的内存对象。如果直接赋值 exports = { a: 1 },只会打断形参变量与 module.exports 的引用关联,导致外界最终无法读取到新的导出对象。
三、ES Module 加载机制:三阶段处理模型
根据 ECMAScript 规范,ES Module 的加载与执行并非一蹴而就,而是严格划分为三个异步且互相独立的阶段:构建(Construction)、实例化(Instantiation)、求值(Evaluation)。
图 2:ECMAScript 模块三阶段加载机制(构建 -> 实例化 -> 求值)
1. 构建阶段 (Construction)
引擎根据入口文件的 import 声明,定位并抓取目标文件内容。引擎将文本解析为 AST,并提取出所有的依赖标识符。此时会生成一个 Module Record(模块记录)。如果子模块尚未下载,引擎会递归执行抓取,直至整棵依赖树的所有 Module Record 构建完毕。所有已解析的 Record 会保存在模块映射表(Module Map)中,避免重复请求。
2. 实例化阶段 (Instantiation)
当整棵树的所有文件解析完成后,引擎将遍历 Module Records。在此阶段,引擎为每一个导出的变量在内存中分配地址(Module Environment Record)。关键在于:此时不仅导出的变量分配了地址,导入方的引用也直接指向这个内存地址。这个过程被称为符号连接(Linking)。整个过程完全不运行任何实际的业务代码。
3. 求值阶段 (Evaluation)
最后,JS 引擎采用深度优先后序遍历(Depth-First Post-Order Traversal),从依赖树的叶子模块开始自底向上执行代码。此时顶层的表达式被计算,真实的值被写入实例化阶段所开辟的内存地址中。执行完毕后,调用方即可即时读取到最新值。
四、深度对比:值拷贝与实时引用的底层本质
模块导出到底返回了什么?这是 CJS 与 ESM 最易引起混淆的核心差异。
图 3:值拷贝(浅复制副本)与实时只读绑定(Live Bindings)内存模型对比
1. CommonJS 的值拷贝
在 CommonJS 中,导出的核心是 module.exports 这个对象。当你导出一个原始类型(如数字、字符串)时,它被作为该对象的属性复制出去:
// counter.js
let count = 1;
function increment() { count++; }
module.exports = { count, increment };
// main.js
const { count, increment } = require('./counter');
increment();
console.log(count); // 输出仍为 1
// 解释:main.js 解构得到的 count 是 number 值的浅拷贝,与 counter.js 内的 count 内存脱钩
2. ES Module 的实时只读引用 (Live Bindings)
与 CJS 不同,ESM 导入的并非一个包装对象,而是对模块环境记录中特定标识符的直接内存引用:
// counter.mjs
export let count = 1;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 输出 2!
// 解释:count 变量直接指向 counter.mjs 内部的内存槽,counter 改变,main 即刻同步
此外,规范明确规定导入的绑定是只读(Read-only)的。如果在 main.mjs 中尝试 count = 100,JavaScript 引擎会在编译/运行时抛出 TypeError: Assignment to constant variable,严格保障了模块导出单向数据流的纯洁性。
五、循环依赖处理机制差异
当模块 A 依赖模块 B,而模块 B 又反向依赖模块 A 时,两套系统展现出截然不同的处理行为与潜在陷阱。
图 4:循环依赖场景下两种模块系统的容错策略与执行结果对比
1. CommonJS 的未完成导出副本
假设 a.js 内部执行到一半调用 require('./b'),Node.js 会转而加载 b.js。在 b.js 中又调用 require('./a')。为了防止死循环,Node.js 并不重新执行 a.js,而是直接返回 Module._cache['a.js'] 中当前的 exports 暂存对象。这意味着 b.js 拿到的是 a.js 的不完全导出快照。如果在此时立即访问后续才赋值的导出变量,将得到 undefined。
2. ES Module 的引用指针与 TDZ
ESM 在进入求值前就已经打通了所有的符号引用(Live Bindings)。只要模块求值结束后再调用对应方法,哪怕存在循环依赖,所有方法都能正常访问到对方作用域下的最新值。但如果在代码顶层、变量尚未执行初始化时就急于读取 let/const 导出的变量,会触发暂存死区(TDZ),直接抛出 ReferenceError: Cannot access 'x' before initialization。
六、核心维度横向比对与工程选型建议
为便于在工程重构与技术架构选型时快速排查问题,下表梳理了两种机制在运行维度的完整对照:
| 对比维度 | CommonJS (CJS) | ES Module (ESM) |
|---|---|---|
| 规范来源 | 社区事实标准(Node.js / Browserify) | ECMAScript 语言官方标准(TC39) |
| 解析时机 | 运行时动态求值 | 构建期静态编译 + 运行时求值 |
| 加载方式 | 同步阻塞式(适合磁盘 I/O) | 异步/分阶段并行加载(适合网络与多端) |
| 模块导出 | 导出值的浅拷贝对象(快照) | 导出内存位置的只读实时绑定(Live Binding) |
| Tree Shaking 支持 | 较难静态优化(因 require 路径支持动态拼接) | 天然支持静态分析与无用代码消除 |
| 顶层 this 指向 | 指向当前模块的 exports 对象 |
严格为 undefined |
| 异步流程控制 | 不支持顶层 await(需包在 async IIFE) |
原生支持 Top-level await |
| 循环依赖表现 | 可能读取到部分导出的 undefined 快照 |
保持内存链接,过早读取可能触发 TDZ 报错 |
Node.js 现代工程实践与互操作规范
在当前 Node.js 生产环境中,官方推荐全面向 ES Module 过渡。在处理历史遗留代码与互操作时,应遵守以下工程准则:
- 明确包类型声明:在
package.json中声明"type": "module"后,项目内所有.js文件均被解析为 ESM;若声明为"type": "commonjs"或省略,则按 CJS 解析。若需在同一工程混用,可通过.mjs与.cjs显式指定模块格式。 - ESM 引入 CommonJS:ES Module 脚本可直接通过
import pkg from './cjs-module.cjs'导入 CJS 模块,默认导出为整个module.exports对象;但不能随意使用命名解构导入,除非该 CJS 模块导出的命名格式能被 Node.js 的 CJS-ESM 词法分析器(cjs-module-lexer)静态识别。 - CommonJS 引入 ESM:同步的
require()无法加载异步分阶段初始化的 ESM 模块(否则会破坏同步调用栈约定)。如需在 CJS 中调用 ESM,必须使用动态导入表达式import('./esm-module.mjs'),并以 Promise 方式处理其异步结果。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。