WinddSnow

Frontend-Engineering-Modular-Evolution-CommonJS-to-ESM

字数统计: 4.2k阅读时长: 16 min
2026/07/31

第75课:模块化演进:CommonJS → ESM——规范对比、require vs import、循环依赖处理

在 ES6 之前,JavaScript 没有官方的模块系统。随着前端应用规模的爆炸式增长,社区先后创造了 IIFE(立即执行函数)CommonJSAMDUMD 等模块方案来组织代码。2015 年,ES6 带来了官方标准 ES Modules(ESM),为浏览器和服务器提供了统一的静态模块体系。然而,CommonJS 在 Node.js 生态中根深蒂固,两者长期共存,导致开发者在模块互操作、打包配置和循环依赖处理上遇到无数陷阱。本节课将系统地梳理模块化的演进脉络,深入对比 CommonJS 和 ESM 在加载机制、导出行为、缓存策略上的根本区别,并通过实战代码剖析循环依赖的成因与解决方案。


1. 模块化简史:从 IIFE 到 ESM

1.1 全局脚本时代与 IIFE

在最早的 Web 开发中,JavaScript 代码通过 <script> 标签直接引入,所有变量和函数都挂载在全局作用域下。这导致了命名冲突依赖顺序两大痛点。为解决命名冲突,开发者使用立即执行函数表达式(IIFE) 创建私有作用域,并通过手动挂载到 window 暴露接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// IIFE 模块模式
var MyModule = (function() {
var privateVar = 'secret';

function privateMethod() {
console.log(privateVar);
}

return {
publicMethod: function() {
privateMethod();
}
};
})();

MyModule.publicMethod(); // 'secret'

IIFE 解决了污染问题,但没有解决依赖管理——开发者仍需手动保证 <script> 标签的加载顺序。

1.2 CommonJS:为服务端而生的同步模块规范

2009 年,Node.js 选择了 CommonJS 规范(简称 CJS)作为其模块系统。CommonJS 的设计前提是服务端环境:模块文件存储在本地磁盘,读取速度极快,可以使用同步的方式加载。每个文件就是一个模块,拥有独立作用域。通过 require 函数引入依赖,通过 module.exportsexports 导出接口。

1
2
3
4
5
6
7
8
// math.js (CommonJS)
const PI = 3.14159;

function add(a, b) {
return a + b;
}

module.exports = { PI, add };
1
2
3
// app.js (CommonJS)
const math = require('./math');
console.log(math.add(1, 2)); // 3

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),通过 importexport 关键字提供编译时静态分析的能力。ESM 同时适用于浏览器和 Node.js,是现代 JavaScript 开发的默认模块系统。

1
2
3
4
5
6
// math.js (ESM)
export const PI = 3.14159;

export function add(a, b) {
return a + b;
}
1
2
3
// app.js (ESM)
import { PI, add } from './math.js';
console.log(add(1, 2)); // 3

ESM 的核心特征

  • 静态结构import 必须位于模块顶层,模块路径必须是字符串字面量,依赖关系在代码执行前就已确定。
  • 实时只读绑定import 获取的是对导出变量的实时引用,导出方修改值后导入方会同步看到。
  • 异步加载:浏览器环境下,<script type="module"> 的加载是异步的,不会阻塞 HTML 解析。
  • 严格模式:ESM 自动处于严格模式,无需显式 "use strict"

2. requireimport 的深度对比

2.1 加载时机

特性 require (CommonJS) import (ESM)
执行时机 运行时同步加载。代码执行到 require 时才加载并执行模块。 编译时静态分析,模块在代码执行前已经完成解析和链接。
是否阻塞 同步阻塞。 异步非阻塞(浏览器);Node.js 中为异步。
条件加载 支持(if (condition) { require(...) } 静态 import 不支持;动态 import() 支持条件加载。
性能影响 无法 Tree Shaking。 静态结构支持 Tree Shaking 和死代码消除。

2.2 导出与导入的行为

值的绑定方式

  • CommonJSmodule.exports 是一个对象。require 获取的是该对象的引用。对于基本类型属性(如 module.exports.count = 0),导入方读取的是值的快照——导出方重新赋值 count 不会影响导入方已获取的值。但如果导出的是一个对象,导入方和导出方共享同一引用,修改对象内部属性会相互影响。

  • ESMimport 获取的是实时绑定。导出方对导出变量的任何修改,导入方都能立刻看到。但导入方不能修改导入的绑定(只读),否则会抛出 TypeError

1
2
3
4
5
6
7
8
9
10
11
12
// CommonJS 值拷贝示例
// counter.js
let count = 0;
function increment() { count++; }
module.exports = { count, increment };

// app.js
const counter = require('./counter');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 0 —— 值未被更新!
// counter.count 获取的是原始 count 值的副本
1
2
3
4
5
6
7
8
9
10
// ESM 实时绑定示例
// counter.js
export let count = 0;
export function increment() { count++; }

// app.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 —— 实时绑定,反映最新值

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
2
3
// data.mjs
const data = await fetch('/api/data').then(r => r.json());
export { data };

3. 循环依赖:成因与处理

循环依赖(Circular Dependency)发生在两个或多个模块相互引用时:模块 A 导入模块 B,模块 B 又导入模块 A,形成闭环。虽然应尽量避免,但在大型项目中很难完全消除,理解两种模块系统如何处理循环依赖至关重要。

3.1 CommonJS 中的循环依赖

当 Node.js 执行 require('a') 时:

  1. 检查缓存中是否存在模块 a。若不存在,创建 amodule.exports 空对象并放入缓存。
  2. 执行模块 a 的代码。如果执行过程中遇到 require('b'),暂停 a 的执行,转去加载并执行 b
  3. b 执行中又 require('a') 时,Node.js 发现缓存中已有 amodule.exports(尽管此时 a 可能尚未执行完毕),直接返回该未完成的对象
  4. b 完成后,a 继续执行剩余代码。

风险:如果 ba 完成初始化前就访问了 a 的某个属性,可能会得到 undefined

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// a.js
console.log('a starting');
exports.done = false;
const b = require('./b');
console.log('in a, b.done =', b.done);
exports.done = true;
console.log('a done');

// b.js
console.log('b starting');
exports.done = false;
const a = require('./a');
console.log('in b, a.done =', a.done);
exports.done = true;
console.log('b done');

// main.js
console.log('main starting');
const a = require('./a');
const b = require('./b');
console.log('in main, a.done =', a.done, ', b.done =', b.done);

输出顺序:

1
2
3
4
5
6
7
8
main starting
a starting
b starting
in b, a.done = false // a 尚未执行完毕,done 还是 false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true

3.2 ESM 中的循环依赖

ESM 的静态结构使其对循环依赖的容错性更强。模块的执行分为三步:解析(Parsing)→ 声明实例化(Instantiation)→ 求值(Evaluation)。在实例化阶段,所有模块的导出绑定已经建立好(但尚未赋值)。当模块 A 在求值阶段从模块 B 导入一个变量时,它获取的是对模块 B 中该变量的实时引用。如果 B 尚未完成求值,该变量的值为 undefined,但引用本身已经就位。一旦 B 完成了对该变量的赋值,A 中也能立即看到。

1
2
3
4
5
6
7
8
9
10
11
12
13
// a.mjs
import { b } from './b.mjs';
console.log('a starting');
export const a = 1;
console.log('in a, b =', b);
console.log('a done');

// b.mjs
import { a } from './a.mjs';
console.log('b starting');
export const b = 2;
console.log('in b, a =', a); // 此时 a 尚未完成赋值,输出 undefined
console.log('b done');

在 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.jsontype 字段区分两种模块系统:

  • **.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
2
3
4
5
6
7
8
// 在 ESM 中加载 CJS 模块
import cjsModule from './legacy.cjs'; // 获取默认导出
import { namedExport } from './legacy.cjs'; // Node.js 支持命名导入 CJS 的静态分析

// 在 CJS 中加载 ESM 模块
(async () => {
const esmModule = await import('./modern.mjs');
})();

课后练习

一、概念自测(选择题 / 填空题)

  1. (单选) CommonJS 模块的核心特征是?
    A. 静态结构,编译时确定依赖。
    B. 动态同步加载,require 返回值的拷贝。
    C. 异步加载,支持顶级 await
    D. 自动严格模式。

  2. (单选) ESM 中,import 获取的绑定是什么特性?
    A. 值的快照副本。
    B. 只读实时引用。
    C. 可写的全局变量。
    D. 仅适用于对象的引用。

  3. (填空) Node.js 中,要让 .js 文件被视为 ESM,package.json 中应设置 ______ 字段为 "module"

  4. (多选) 以下哪些是 ESM 相比 CommonJS 的优势?
    A. 静态结构支持 Tree Shaking。
    B. 实时只读绑定。
    C. 可在条件语句中使用 require
    D. 原生支持顶级 await

二、AI 编程任务:编写面向 AI 的提示词

场景:你需要编写一个包含两个模块的小型 Node.js 项目,演示 CommonJS 和 ESM 的导入导出行为以及循环依赖处理。要求如下:

  • 创建 cjs-math.js(CommonJS)和 esm-math.mjs(ESM),分别导出 addPIcounter(带 increment 函数),展示值拷贝 vs 实时绑定。
  • 创建 cjs-cycle-a.jscjs-cycle-b.js 演示 CommonJS 循环依赖的处理过程。
  • 创建 esm-cycle-a.mjsesm-cycle-b.mjs 演示 ESM 循环依赖的行为。
  • 添加 main.cjsmain.mjs 分别运行 CJS 和 ESM 的演示代码,打印结果。

任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成上述所有文件。提示词中需明确指定导出方式、循环依赖的引入方式,以及每种文件的运行预期。

三、Agent 模式下的提示词示例

你是一个资深前端开发 Agent。请创建一个 Node.js 演示项目 module-comparison/,包含以下文件,用于教学 CommonJS 和 ESM 的差异及循环依赖行为:

  1. cjs-math.js:导出 PI = 3.14add(a, b),以及 let counter = 0; function increment() { counter++; }。通过 module.exports 导出。
  2. esm-math.mjs:使用 export 导出相同的功能。
  3. cjs-main.js:使用 require 导入 cjs-math,调用 increment 前后打印 counter,展示值拷贝行为。
  4. esm-main.mjs:使用 import 导入 esm-math,做同样的操作,展示实时绑定。
  5. cjs-cycle-a.jscjs-cycle-b.js:模拟循环依赖,每个文件在顶部 require 另一个,打印中间状态,并在末尾导出状态。
  6. esm-cycle-a.mjsesm-cycle-b.mjs:同样模拟循环依赖。
  7. 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 的导出对象是运行时动态构建的,求值顺序直接影响该对象的属性集合,从而可能导致难以调试的不一致状态。但两者都应尽量避免循环依赖,最佳的实践是重构模块结构以消除环。


课后练习答案

一、概念自测答案

  1. B

    • 解析:CommonJS 是动态同步加载,require 返回值的拷贝。A、C、D 是 ESM 的特征。
  2. B

    • 解析:ESM 的 import 是只读实时引用,不是值拷贝,不能修改绑定。
  3. type

    • 解析:package.json 中的 "type": "module".js 文件视为 ESM。
  4. A、B、D

    • 解析:C 是 CommonJS 的特性(条件 require)。ESM 支持 Tree Shaking(A)、实时绑定(B)和顶级 await(D)。

二、AI 编程任务参考答案(提示词示例)

示例提示词
“请生成一个 Node.js 演示项目,包含以下文件对比 CommonJS 和 ESM:

  • cjs-math.jsesm-math.mjs:各自导出 PIadd、以及一个计数器(let count = 0; increment())。
  • cjs-main.jsesm-main.mjs:分别导入对应数学模块,演示 increment 前后 count 的变化。
  • cjs-cycle-a.jscjs-cycle-b.jsesm-cycle-a.mjsesm-cycle-b.mjs:各自实现循环依赖,并在控制台打印中间状态。
  • 为每个文件的预期输出添加注释。直接输出所有文件内容。”
CATALOG
  1. 1. 第75课:模块化演进:CommonJS → ESM——规范对比、require vs import、循环依赖处理
    1. 1.1. 1. 模块化简史:从 IIFE 到 ESM
      1. 1.1.1. 1.1 全局脚本时代与 IIFE
      2. 1.1.2. 1.2 CommonJS:为服务端而生的同步模块规范
      3. 1.1.3. 1.3 AMD 与 UMD:浏览器的异步模块方案
      4. 1.1.4. 1.4 ES Modules:官方的静态模块标准
    2. 1.2. 2. require 与 import 的深度对比
      1. 1.2.1. 2.1 加载时机
      2. 1.2.2. 2.2 导出与导入的行为
      3. 1.2.3. 2.3 缓存机制
      4. 1.2.4. 2.4 顶级 await
    3. 1.3. 3. 循环依赖:成因与处理
      1. 1.3.1. 3.1 CommonJS 中的循环依赖
      2. 1.3.2. 3.2 ESM 中的循环依赖
      3. 1.3.3. 3.3 避免循环依赖的最佳实践
    4. 1.4. 4. Node.js 中 CJS 与 ESM 的共存
    5. 1.5. 课后练习
      1. 1.5.1. 一、概念自测(选择题 / 填空题)
      2. 1.5.2. 二、AI 编程任务:编写面向 AI 的提示词
      3. 1.5.3. 三、Agent 模式下的提示词示例
      4. 1.5.4. 四、面试真题与参考答案
    6. 1.6. 课后练习答案
      1. 1.6.1. 一、概念自测答案
      2. 1.6.2. 二、AI 编程任务参考答案(提示词示例)