第75课:模块化演进:CommonJS → ESM——规范对比、require vs import、循环依赖处理
在 ES6 之前,JavaScript 没有官方的模块系统。随着前端应用规模的爆炸式增长,社区先后创造了 IIFE(立即执行函数)、CommonJS、AMD 和 UMD 等模块方案来组织代码。2015 年,ES6 带来了官方标准 ES Modules(ESM),为浏览器和服务器提供了统一的静态模块体系。然而,CommonJS 在 Node.js 生态中根深蒂固,两者长期共存,导致开发者在模块互操作、打包配置和循环依赖处理上遇到无数陷阱。本节课将系统地梳理模块化的演进脉络,深入对比 CommonJS 和 ESM 在加载机制、导出行为、缓存策略上的根本区别,并通过实战代码剖析循环依赖的成因与解决方案。
1. 模块化简史:从 IIFE 到 ESM
1.1 全局脚本时代与 IIFE
在最早的 Web 开发中,JavaScript 代码通过 <script> 标签直接引入,所有变量和函数都挂载在全局作用域下。这导致了命名冲突和依赖顺序两大痛点。为解决命名冲突,开发者使用立即执行函数表达式(IIFE) 创建私有作用域,并通过手动挂载到 window 暴露接口。
1 | // IIFE 模块模式 |
IIFE 解决了污染问题,但没有解决依赖管理——开发者仍需手动保证 <script> 标签的加载顺序。
1.2 CommonJS:为服务端而生的同步模块规范
2009 年,Node.js 选择了 CommonJS 规范(简称 CJS)作为其模块系统。CommonJS 的设计前提是服务端环境:模块文件存储在本地磁盘,读取速度极快,可以使用同步的方式加载。每个文件就是一个模块,拥有独立作用域。通过 require 函数引入依赖,通过 module.exports 或 exports 导出接口。
1 | // math.js (CommonJS) |
1 | // app.js (CommonJS) |
CommonJS 的核心特征:
- 同步加载:
require会阻塞后续代码执行,直到模块加载完成并返回module.exports。 - 值的拷贝:
require返回的是module.exports对象的浅拷贝引用。对于基本类型属性,导入方获得的是值的副本,导出方的后续修改不会影响导入方。 - 动态加载:
require可以在任何位置调用,参数可以是变量或表达式,模块路径在运行时确定。 - 单例缓存:模块在第一次加载后会被缓存,后续
require直接返回缓存对象,不会重新执行模块代码。
1.3 AMD 与 UMD:浏览器的异步模块方案
CommonJS 的同步特性不适合浏览器——通过网络加载远程模块会阻塞页面渲染。AMD(Asynchronous Module Definition) 规范应运而生,使用 define 声明模块,支持异步加载依赖。RequireJS 是其最流行的实现。
UMD(Universal Module Definition) 则是一种兼容方案,通过检测环境(typeof define === 'function' 等)判断当前是 AMD、CommonJS 还是全局脚本,从而同时适配多种环境。
1.4 ES Modules:官方的静态模块标准
ES6 引入了 ES Modules(ESM),通过 import 和 export 关键字提供编译时静态分析的能力。ESM 同时适用于浏览器和 Node.js,是现代 JavaScript 开发的默认模块系统。
1 | // math.js (ESM) |
1 | // app.js (ESM) |
ESM 的核心特征:
- 静态结构:
import必须位于模块顶层,模块路径必须是字符串字面量,依赖关系在代码执行前就已确定。 - 实时只读绑定:
import获取的是对导出变量的实时引用,导出方修改值后导入方会同步看到。 - 异步加载:浏览器环境下,
<script type="module">的加载是异步的,不会阻塞 HTML 解析。 - 严格模式:ESM 自动处于严格模式,无需显式
"use strict"。
2. require 与 import 的深度对比
2.1 加载时机
| 特性 | require (CommonJS) |
import (ESM) |
|---|---|---|
| 执行时机 | 运行时同步加载。代码执行到 require 时才加载并执行模块。 |
编译时静态分析,模块在代码执行前已经完成解析和链接。 |
| 是否阻塞 | 同步阻塞。 | 异步非阻塞(浏览器);Node.js 中为异步。 |
| 条件加载 | 支持(if (condition) { require(...) }) |
静态 import 不支持;动态 import() 支持条件加载。 |
| 性能影响 | 无法 Tree Shaking。 | 静态结构支持 Tree Shaking 和死代码消除。 |
2.2 导出与导入的行为
值的绑定方式:
CommonJS:
module.exports是一个对象。require获取的是该对象的引用。对于基本类型属性(如module.exports.count = 0),导入方读取的是值的快照——导出方重新赋值count不会影响导入方已获取的值。但如果导出的是一个对象,导入方和导出方共享同一引用,修改对象内部属性会相互影响。ESM:
import获取的是实时绑定。导出方对导出变量的任何修改,导入方都能立刻看到。但导入方不能修改导入的绑定(只读),否则会抛出TypeError。
1 | // CommonJS 值拷贝示例 |
1 | // ESM 实时绑定示例 |
2.3 缓存机制
两者都实现了模块缓存,但缓存的对象不同:
- CommonJS:缓存的是
module.exports对象。第二次require同一个模块时,直接返回该对象的引用。 - ESM:缓存的是模块记录(Module Record),包含所有导出绑定的状态。第二次
import同一模块时,返回指向相同绑定的引用。
2.4 顶级 await
- CommonJS:不支持顶级
await(除非使用 Node.js 的实验性 flag 或在.mjs中)。CJS 文件的顶级代码是同步执行的,无法直接await。 - ESM:天然支持顶级
await。在模块顶层可以直接使用await表达式,模块的后续代码和依赖该模块的其他模块会等待 Promise 解析后再执行。
1 | // data.mjs |
3. 循环依赖:成因与处理
循环依赖(Circular Dependency)发生在两个或多个模块相互引用时:模块 A 导入模块 B,模块 B 又导入模块 A,形成闭环。虽然应尽量避免,但在大型项目中很难完全消除,理解两种模块系统如何处理循环依赖至关重要。
3.1 CommonJS 中的循环依赖
当 Node.js 执行 require('a') 时:
- 检查缓存中是否存在模块
a。若不存在,创建a的module.exports空对象并放入缓存。 - 执行模块
a的代码。如果执行过程中遇到require('b'),暂停a的执行,转去加载并执行b。 - 当
b执行中又require('a')时,Node.js 发现缓存中已有a的module.exports(尽管此时a可能尚未执行完毕),直接返回该未完成的对象。 b完成后,a继续执行剩余代码。
风险:如果 b 在 a 完成初始化前就访问了 a 的某个属性,可能会得到 undefined。
1 | // a.js |
输出顺序:
1 | main starting |
3.2 ESM 中的循环依赖
ESM 的静态结构使其对循环依赖的容错性更强。模块的执行分为三步:解析(Parsing)→ 声明实例化(Instantiation)→ 求值(Evaluation)。在实例化阶段,所有模块的导出绑定已经建立好(但尚未赋值)。当模块 A 在求值阶段从模块 B 导入一个变量时,它获取的是对模块 B 中该变量的实时引用。如果 B 尚未完成求值,该变量的值为 undefined,但引用本身已经就位。一旦 B 完成了对该变量的赋值,A 中也能立即看到。
1 | // a.mjs |
在 ESM 中,如果访问尚未赋值的 const 变量,不会抛出错误(因为引用已存在),但会得到 undefined。如果变量是用 let 声明的,且在 TDZ 期间被访问,则抛出 ReferenceError。
3.3 避免循环依赖的最佳实践
- 提取共享类型:将循环依赖中的公共接口或类型定义提取到独立的
types.ts模块中,打破依赖环。 - 依赖反转:通过引入中间层或事件总线,让模块 A 和 B 都依赖同一个抽象,而非互相依赖。
- 延迟加载:将循环引用放在函数内部进行动态
import(),延迟到运行时才建立依赖。 - 工具检测:使用 ESLint 插件(如
import/no-cycle)或打包工具(如 Webpack 的circular-dependency-plugin)检测和报警循环依赖。
4. Node.js 中 CJS 与 ESM 的共存
Node.js 通过文件扩展名和 package.json 的 type 字段区分两种模块系统:
- **
.mjs**:始终被视为 ESM。 - **
.cjs**:始终被视为 CommonJS。 - **
.js**:由最近的package.json中的"type"字段决定。"type": "module"时视为 ESM,否则视为 CJS(默认)。
互操作性:
- ESM 可以通过
import加载 CJS 模块(Node.js 将 CJS 的module.exports作为默认导出提供)。 - CJS 不能使用
require加载 ESM 模块(会抛出ERR_REQUIRE_ESM),必须使用动态import()。
1 | // 在 ESM 中加载 CJS 模块 |
课后练习
一、概念自测(选择题 / 填空题)
(单选) CommonJS 模块的核心特征是?
A. 静态结构,编译时确定依赖。
B. 动态同步加载,require返回值的拷贝。
C. 异步加载,支持顶级await。
D. 自动严格模式。(单选) ESM 中,
import获取的绑定是什么特性?
A. 值的快照副本。
B. 只读实时引用。
C. 可写的全局变量。
D. 仅适用于对象的引用。(填空) Node.js 中,要让
.js文件被视为 ESM,package.json中应设置______字段为"module"。(多选) 以下哪些是 ESM 相比 CommonJS 的优势?
A. 静态结构支持 Tree Shaking。
B. 实时只读绑定。
C. 可在条件语句中使用require。
D. 原生支持顶级await。
二、AI 编程任务:编写面向 AI 的提示词
场景:你需要编写一个包含两个模块的小型 Node.js 项目,演示 CommonJS 和 ESM 的导入导出行为以及循环依赖处理。要求如下:
- 创建
cjs-math.js(CommonJS)和esm-math.mjs(ESM),分别导出add、PI和counter(带increment函数),展示值拷贝 vs 实时绑定。 - 创建
cjs-cycle-a.js和cjs-cycle-b.js演示 CommonJS 循环依赖的处理过程。 - 创建
esm-cycle-a.mjs和esm-cycle-b.mjs演示 ESM 循环依赖的行为。 - 添加
main.cjs和main.mjs分别运行 CJS 和 ESM 的演示代码,打印结果。
任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成上述所有文件。提示词中需明确指定导出方式、循环依赖的引入方式,以及每种文件的运行预期。
三、Agent 模式下的提示词示例
你是一个资深前端开发 Agent。请创建一个 Node.js 演示项目
module-comparison/,包含以下文件,用于教学 CommonJS 和 ESM 的差异及循环依赖行为:
cjs-math.js:导出PI = 3.14,add(a, b),以及let counter = 0; function increment() { counter++; }。通过module.exports导出。esm-math.mjs:使用export导出相同的功能。cjs-main.js:使用require导入cjs-math,调用increment前后打印counter,展示值拷贝行为。esm-main.mjs:使用import导入esm-math,做同样的操作,展示实时绑定。cjs-cycle-a.js和cjs-cycle-b.js:模拟循环依赖,每个文件在顶部require另一个,打印中间状态,并在末尾导出状态。esm-cycle-a.mjs和esm-cycle-b.mjs:同样模拟循环依赖。README.md:解释如何运行和观察输出,总结 CJS 与 ESM 的核心差异。
所有文件添加注释说明输出原因。完成后列出所有文件内容。
四、面试真题与参考答案
题目(腾讯前端面试题):
请详细解释 CommonJS 和 ES Modules 在处理循环依赖时的不同行为。为什么 CommonJS 可能得到不完整的模块对象,而 ESM 相对更安全?请结合模块加载过程说明。
参考答案:
CommonJS 的处理:当 Node.js 加载 CommonJS 模块时,它采用“先缓存后执行”的策略。在代码执行之前,require 会先在缓存中为该模块创建一个空的 exports 对象。如果此时发生循环依赖(A 加载 B,B 又加载 A),B 中 require('A') 将立即返回缓存中的未完成的 A 对象(可能缺少尚未执行的导出属性)。这就导致了“不完整的模块对象”问题——B 访问到的 A 可能没有某些属性或方法,因为它们还未被执行到。
ESM 的处理:ESM 采用“先链接,后求值”的三阶段模型(解析、实例化、求值)。在实例化阶段,所有模块的导出绑定已经建立(内存空间已分配,但尚未赋值)。当求值阶段出现循环依赖时,模块 B 通过 import 获取的是对模块 A 中导出变量的实时引用。如果 A 的变量尚未完成赋值,导入方读取到的是 undefined,但不会导致整个模块对象缺失。一旦 A 完成了赋值,B 中的引用能立即看到最新值。这种“先建立引用空间,再逐步填充值”的机制避免了 CommonJS 中缓存不完整对象的问题。
为什么 ESM 相对更安全:因为 ESM 的绑定在实例化阶段就已建立,模块间的导入接口(变量槽位)固定,求值顺序不影响接口的完整性。而 CommonJS 的导出对象是运行时动态构建的,求值顺序直接影响该对象的属性集合,从而可能导致难以调试的不一致状态。但两者都应尽量避免循环依赖,最佳的实践是重构模块结构以消除环。
课后练习答案
一、概念自测答案
B
- 解析:CommonJS 是动态同步加载,
require返回值的拷贝。A、C、D 是 ESM 的特征。
- 解析:CommonJS 是动态同步加载,
B
- 解析:ESM 的
import是只读实时引用,不是值拷贝,不能修改绑定。
- 解析:ESM 的
type- 解析:
package.json中的"type": "module"将.js文件视为 ESM。
- 解析:
A、B、D
- 解析:C 是 CommonJS 的特性(条件
require)。ESM 支持 Tree Shaking(A)、实时绑定(B)和顶级await(D)。
- 解析:C 是 CommonJS 的特性(条件
二、AI 编程任务参考答案(提示词示例)
示例提示词:
“请生成一个 Node.js 演示项目,包含以下文件对比 CommonJS 和 ESM:
cjs-math.js和esm-math.mjs:各自导出PI、add、以及一个计数器(let count = 0; increment())。cjs-main.js和esm-main.mjs:分别导入对应数学模块,演示increment前后count的变化。cjs-cycle-a.js、cjs-cycle-b.js和esm-cycle-a.mjs、esm-cycle-b.mjs:各自实现循环依赖,并在控制台打印中间状态。- 为每个文件的预期输出添加注释。直接输出所有文件内容。”