ESM:链接、实例化、求值三阶段与循环依赖处理
import 不是 require 的 ES6 版本——它们是两套完全不同的模块系统。ESM 在代码执行前完成依赖图的静态分析,所有绑定在"链接"阶段就已存在,只是还没有值。这个三阶段设计解释了为什么 live binding 能工作,也解释了循环依赖下某些变量为 undefined 的根本原因。
🔹 Level 1 · 你需要知道的
import vs require:根本差异
| 维度 | ESM (import) |
CommonJS (require) |
|---|---|---|
| 解析时机 | 静态(代码执行前) | 动态(代码执行时) |
| 导出内容 | live binding(实时绑定,导出变量变化可见) | 值复制(导入时的快照) |
| 顶层 await | 支持(ES2022) | 不支持 |
| 循环依赖 | 支持(有限制) | 支持(返回不完整对象) |
| 异步加载 | 内置(import()) |
需要手动处理 |
| 规范来源 | ECMAScript + HTML | Node.js CommonJS 规范 |
ESM 基本语法
// 具名导出
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export class Vector { /* ... */ }
// 默认导出
export default function main() { /* ... */ }
// 重新导出
export { add, PI } from './math.js';
export * from './utils.js';
export * as utils from './utils.js'; // 命名空间重导出
// 导入
import defaultExport from './module.js';
import { named1, named2 } from './module.js';
import * as namespace from './module.js';
import defaultExport, { named } from './module.js';
// 动态导入(返回 Promise)
const module = await import('./lazy.js');
Live Binding:与 CommonJS 的关键区别
// counter.js(ESM)
export let count = 0;
export function increment() { count++; }
// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1(不是0!live binding,看到最新值)
// ────────────────────────────────
// counter.js(CommonJS)
let count = 0;
function increment() { count++; }
module.exports = { count, increment };
// main.js
const { count, increment } = require('./counter.js');
console.log(count); // 0
increment();
console.log(count); // 0!(值复制,count 是拷贝,不随原始值变化)
顶层 await
ESM 支持在模块顶层使用 await(无需包在 async 函数中):
// config.js(ESM)
const config = await fetch('/api/config').then(r => r.json());
export const API_KEY = config.key;
// 使用 config.js 的模块必须等待 config.js 的顶层 await 完成
// 才能继续自己的 Evaluation 阶段
🔸 Level 2 · 它是怎么运行的
三个阶段的完整描述
┌─────────────────────────────────────────────────────────────────┐
│ ESM 模块加载的三个阶段 │
│ │
│ 阶段1:链接(Linking) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • 静态解析所有 import/export 声明 │ │
│ │ • 构建完整的模块依赖图 │ │
│ │ • 为所有 export 创建绑定槽(内存位置) │ │
│ │ • 检测循环依赖 │ │
│ │ • 此时绑定存在但没有值(变量在 TDZ 中) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 阶段2:实例化(Instantiation) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • 为每个模块创建 Module Environment Record │ │
│ │ • 将 export 绑定到 Environment Record 的槽 │ │
│ │ • import 的名称被链接到对应模块的 export 槽 │ │
│ │ • let/const 进入 TDZ(Temporal Dead Zone) │ │
│ │ • 函数声明被提升(hoisted)到绑定中 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 阶段3:求值(Evaluation) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • 按深度优先顺序执行模块代码 │ │
│ │ • 每个模块只执行一次(后续 import 使用缓存) │ │
│ │ • 执行填充 export 槽的值 │ │
│ │ • 顶层 await 会暂停求值(等待 Promise 完成) │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Live Binding 的实现原理
Live binding 的关键在于:ESM 的 import 导入的不是值,而是导出变量的内存地址引用(绑定槽)。
模块 A 的内存布局(链接阶段之后):
A 的 Module Environment Record:
┌──────────────────────────────────────┐
│ Binding: count → [内存槽 0x1234] │
│ [0x1234] = 0 (初始值) │
└──────────────────────────────────────┘
B 导入 A 时:
B 的 import { count } from 'A'
┌──────────────────────────────────────┐
│ B 的 count → 指向 A 的 [0x1234] │
│ (不是复制,是同一个槽) │
└──────────────────────────────────────┘
A 执行 count++ 后:
[0x1234] = 1
B 读取 count:
B.count → A 的 [0x1234] → 1(最新值)
循环依赖:ESM 的处理方式
模块 A 导入模块 B,模块 B 导入模块 A:
// a.js
import { b } from './b.js';
export const a = 'value-a';
export function useB() { return b; }
// b.js
import { a } from './a.js';
export const b = 'value-b';
export function useA() { return a; }
链接阶段(构建依赖图):
依赖图:
入口
│
▼
a.js ──→ b.js ──→ a.js(循环!)
↑已存在,停止递归
链接算法使用 DFS + "正在链接"标记防止无限递归:
- 开始链接 a.js,标记 a.js 为"正在链接"
- a.js 依赖 b.js,开始链接 b.js
- b.js 依赖 a.js,发现 a.js "正在链接"(循环依赖)
- 停止:a.js 的绑定槽已创建,但尚未填充值
- b.js 链接完成
- a.js 链接完成
求值阶段(DFS 执行模块代码):
求值顺序(深度优先,后序):
1. 求值 b.js(a.js 尚未求值,所以 b.js 导入的 a 是 TDZ 或 undefined)
2. 求值 a.js
// b.js 求值时:
import { a } from './a.js'; // a 此时还没有值(a.js 未求值)
export const b = 'value-b';
export function useA() { return a; } // 函数捕获 live binding,延迟访问 a
// b.js 执行完毕。b 的值是 'value-b'
// a.js 求值时:
import { b } from './b.js'; // b 已经有值了('value-b')
export const a = 'value-a';
export function useB() { return b; }
// a.js 执行完毕
访问时机决定结果:
// 在模块顶层访问(静态初始化时):
// b.js 顶层:
console.log(a); // undefined!(a.js 未求值,TDZ 或 undefined)
// 在函数内访问(延迟访问,调用时 a.js 已求值):
// 调用 useA() 时:
useA(); // 'value-a'(函数调用时 a.js 已经执行完毕)
函数声明 vs 变量声明的差异:
// a.js
import { funcB } from './b.js';
export function funcA() { return 'from A'; } // 函数声明(提升)
export const varA = 'varA'; // const(不提升)
// b.js
import { funcA, varA } from './a.js';
// 模块顶层立即执行:
console.log(funcA()); // 'from A' ✓(函数声明提升,链接阶段已绑定)
console.log(varA); // ReferenceError(TDZ)或 undefined(取决于规范版本)
模块求值的 DFS 顺序示意
模块依赖图:
main.js
├── utils.js
│ └── helpers.js
└── api.js
└── utils.js(已访问)
DFS 求值顺序(后序遍历):
1. helpers.js(叶子节点)
2. utils.js(helpers.js 已求值)
3. api.js(utils.js 已求值)
4. main.js(所有依赖已求值)
每个模块只求值一次,后续 import 使用模块实例的缓存。
顶层 await 对模块加载顺序的影响
// data.js
export const data = await fetch('/api/data').then(r => r.json());
// 顶层 await:data.js 的求值暂停,等待 Promise
// main.js
import { data } from './data.js';
// main.js 的求值必须等待 data.js 完成(因为 data.js 是其依赖)
console.log(data); // 等待后,data 已有值
// sibling.js(与 main.js 同层但不依赖 data.js)
import { other } from './other.js';
// sibling.js 不依赖 data.js,可以并行求值
顶层 await 的影响:
- 阻塞:依赖此模块的所有模块都等待
- 并行:此模块的"兄弟"模块(不依赖它的模块)可以并行加载
- 整体行为类似:使整个模块成为一个异步模块
🔺 Level 3 · 规范怎么定义的
规范 §16.2:Modules
ECMAScript 规范第16章定义了模块系统。Module Record 的关键内部槽:
Abstract Module Record(所有模块类型的基类):
[[Realm]] — 关联的 Realm
[[Environment]] — Module Environment Record(求值后存在)
[[Namespace]] — 模块命名空间对象(首次访问时创建)
[[HostDefined]] — 宿主自定义数据
Source Text Module Record(ESM 模块):
继承 Abstract Module Record,额外包含:
[[ECMAScriptCode]] — 解析后的模块 AST
[[Context]] — 执行上下文
[[ImportMeta]] — import.meta 对象
[[RequestedModules]]— 静态依赖(从 import 语句提取)
[[ImportEntries]] — 导入记录列表
[[LocalExportEntries]] — 本地导出记录列表
[[IndirectExportEntries]] — 间接导出(re-export)
[[StarExportEntries]] — * 导出
[[Status]] — unlinked | linking | linked | evaluating | evaluated
[[EvaluationError]] — 求值失败时的错误
[[DFSIndex]] — DFS 遍历中的索引(循环依赖检测)
[[DFSAncestorIndex]]— DFS 祖先索引(Tarjan 算法)
[[CycleRoot]] — 循环依赖的根模块
[[HasTLA]] — 是否有顶层 await
[[AsyncEvaluation]] — 是否在异步求值中
[[TopLevelCapability]] — 顶层 await 的 PromiseCapability
[[AsyncParentModules]] — 等待此模块完成的父模块列表
[[PendingAsyncDependencies]] — 待完成的异步依赖计数
InnerModuleLinking 算法
规范 §16.2.1.5.1 定义 InnerModuleLinking(DFS 遍历):
InnerModuleLinking ( module, stack, index )
- If module is not a Source Text Module Record, then: a. Perform ? module.Link(). b. Return index.
- If module.[[Status]] is linking, linked, evaluating, or evaluated: a. Return index. ← 已链接或正在链接:跳过(处理循环依赖)
- Assert: module.[[Status]] is unlinked.
- Set module.[[Status]] to linking.
- Set module.[[DFSIndex]] to index.
- Set module.[[DFSAncestorIndex]] to index.
- Set index to index + 1.
- Append module to stack.
- For each required module reqModule in module.[[RequestedModules]]: a. Let requiredModule be GetImportedModule(module, reqModule.[[Specifier]]). b. Set index to ? InnerModuleLinking(requiredModule, stack, index). c. If requiredModule is a Source Text Module Record: i. Set module.[[DFSAncestorIndex]] to min(module.[[DFSAncestorIndex]], requiredModule.[[DFSAncestorIndex]]).
- Perform ? module.InitializeEnvironment(). ← 创建 Module Environment,链接绑定
- Assert: module occurs exactly once in stack.
- Assert: module.[[DFSAncestorIndex]] ≤ module.[[DFSIndex]].
- If module.[[DFSAncestorIndex]] = module.[[DFSIndex]]: a. Let done be false. b. Repeat while not done: i. Let requiredModule be the last element of stack. ii. Remove the last element of stack. iii. Set requiredModule.[[Status]] to linked. iv. If requiredModule and module are the same Module Record, set done to true.
- Return index.
步骤13中的逻辑是 Tarjan 强联通分量算法:找到循环依赖的"根"节点,一次性将整个 SCC 标记为 linked。
InnerModuleEvaluation 算法
规范 §16.2.1.5.2 定义 InnerModuleEvaluation:
- If module.[[Status]] is evaluating or evaluated: a. If module.[[EvaluationError]] is undefined, return index. ← 已求值或正在求值:跳过 b. Otherwise, throw module.[[EvaluationError]].
- ...(类似 linking 的 DFS 设置)
- For each required module reqModule: ← 递归求值依赖 a. Set index to ? InnerModuleEvaluation(reqModule, stack, index).
- If module.[[HasTLA]] is true: ← 顶层 await 处理 a. Perform ExecuteAsyncModule(module).
- Else: ← 普通同步模块 a. Perform ? module.ExecuteModule().
- ...(标记 evaluated,处理 DFS 收尾)
ResolveExport 算法
import { foo } from './a.js' 在链接阶段调用 ResolveExport,追踪 re-export 链直到找到本地定义:
ResolveExport(exportName, resolveSet):
1. 检查 resolveSet 中是否已有 (this, exportName),有则返回 null(循环)
2. 将 (this, exportName) 加入 resolveSet
3. 在 [[LocalExportEntries]] 中查找 exportName
→ 找到:返回 { [[Module]]: this, [[BindingName]]: localName }
4. 在 [[IndirectExportEntries]] 中查找(re-export)
→ 追溯到源模块,递归调用 ResolveExport
5. 在 [[StarExportEntries]] 中查找(export *)
→ 遍历所有 star 来源,收集所有匹配
6. 若有多个匹配:抛出 SyntaxError(导出名冲突)
7. 返回结果或 null(未找到)
💎 Level 4 · 边界与陷阱
陷阱1:import 的 live binding 不可重新赋值
// math.js
export let counter = 0;
export const increment = () => counter++;
// main.js
import { counter, increment } from './math.js';
console.log(counter); // 0
increment();
console.log(counter); // 1(live binding,自动更新)
// 错误:不能对导入的 live binding 赋值
counter = 5; // TypeError: Assignment to constant variable.
// import 的绑定是只读的!只有导出模块自己才能修改
// 正确:通过导出的函数修改
increment(); // 让 math.js 内部修改 counter
深层原因: ESM 的 import { counter } 创建了一个只读的"视口"(live binding),指向 math.js 的内存槽。你能看到值的变化,但无法向该槽写入——写操作只能来自拥有该槽的模块(math.js)。
陷阱2:CommonJS 循环依赖返回不完整对象
// CommonJS 的循环依赖(与 ESM 行为对比)
// a.cjs
const b = require('./b.cjs'); // 此时 b.js 开始执行
console.log('a: b.value =', b.value); // undefined!
exports.value = 'a-value';
// b.cjs
const a = require('./a.cjs'); // a.cjs 正在执行,返回当前(不完整的)exports
console.log('b: a.value =', a.value); // undefined!
exports.value = 'b-value';
// 执行顺序:
// 1. 开始执行 a.cjs,标记 a.cjs 正在加载
// 2. require('./b.cjs'):开始执行 b.cjs
// 3. require('./a.cjs'):a.cjs 正在加载,返回 a 的当前 exports(空对象 {})
// 4. b.cjs 执行完,exports.value = 'b-value'
// 5. 回到 a.cjs,b 的 exports 已完整({value: 'b-value'})
// 6. a.cjs 执行完,exports.value = 'a-value'
// 输出:b: a.value = undefined → a: b.value = b-value
CommonJS 的循环依赖返回当前执行进度的导出对象(半成品),ESM 返回 live binding(始终反映最终值,但求值顺序决定了初始可见性)。
陷阱3:ESM 循环依赖中 TDZ 的实际触发
// a.mjs
import { b } from './b.mjs';
console.log('in a, b =', b); // 此时 b.mjs 已求值,b = 'value-b'
export const a = 'value-a';
// b.mjs
import { a } from './a.mjs';
// 这里 a.mjs 尚未求值!
console.log('in b, a =', a);
// 若 a 是 const/let:可能触发 TDZ(ReferenceError)
// 实际行为取决于引擎:
// V8:访问 TDZ 变量 → ReferenceError: Cannot access 'a' before initialization
// 某些引擎:undefined
export const b = 'value-b';
安全的循环依赖模式: 只用函数(函数声明会被提升),不在顶层直接访问对方的导出变量。
// a.mjs(安全写法)
import { getB } from './b.mjs';
export function getA() { return 'value-a'; }
// 在函数调用时访问 getB(),此时两个模块都已求值
export function useB() { return getB(); } // 延迟访问
陷阱4:import.meta.url 用于相对路径计算
// 模块内获取当前文件的目录(Node.js ESM 中 __dirname 不存在)
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 相对路径构建
const configPath = join(__dirname, '../../config.json');
// 浏览器中
const imageUrl = new URL('./assets/image.png', import.meta.url);
import.meta 是模块私有的对象,由宿主(Node.js 或浏览器)提供:
- Node.js:
import.meta.url(文件URL)、import.meta.resolve(specifier) - 浏览器:
import.meta.url(模块 URL) - Vite/webpack 等构建工具会扩展
import.meta.env等自定义属性
陷阱5:顶层 await 对模块加载的阻塞与并行影响
模块依赖图:
main.js
├── slow.js(顶层 await,需要2秒)
└── fast.js(无顶层 await)
加载时序:
0ms: 链接阶段完成(所有模块)
│
├── 开始求值 fast.js(立即完成)
│
└── 开始求值 slow.js
│ await fetch('/slow-api')...
│ 等待中...(fast.js 可以并行)
│
2000ms: slow.js 的 Promise 完成
│ slow.js 求值完成
│
2000ms: main.js 开始求值(所有依赖就绪)
关键: 顶层 await 不阻塞整个页面,只阻塞依赖该模块的模块。不依赖 slow.js 的其他模块(如 fast.js)可以并行加载和求值。这与 CommonJS 的 require 完全不同(require 总是同步的)。
实际建议: 顶层 await 适合初始化操作(加载配置、数据库连接),但应避免在经常被其他模块导入的"工具模块"中使用,以免拖慢整个应用的启动时间。
小结
- ESM 模块加载分三个阶段:链接(静态分析依赖、创建绑定槽)→ 实例化(连接 import 到 export 槽)→ 求值(DFS 执行代码、填充槽的值)。
import { x }导入的是 live binding(只读视口,指向导出模块的内存槽),不是值复制;导出方修改值后,所有导入方立即看到新值。- ESM 循环依赖中,被先求值的模块访问后求值模块的
const/let导出时会遇到 TDZ;使用函数声明(提升)可以安全地在循环依赖中延迟访问。 - CommonJS 循环依赖返回执行到当前位置的不完整 exports 对象,与 ESM 的 live binding 行为完全不同。
- 顶层
await只阻塞依赖该模块的模块,不阻塞整个应用;不依赖它的"兄弟模块"可以并行求值。