第 30 章

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 + "正在链接"标记防止无限递归:

  1. 开始链接 a.js,标记 a.js 为"正在链接"
  2. a.js 依赖 b.js,开始链接 b.js
  3. b.js 依赖 a.js,发现 a.js "正在链接"(循环依赖)
  4. 停止:a.js 的绑定槽已创建,但尚未填充值
  5. b.js 链接完成
  6. 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 )

  1. If module is not a Source Text Module Record, then: a. Perform ? module.Link(). b. Return index.
  2. If module.[[Status]] is linking, linked, evaluating, or evaluated: a. Return index. ← 已链接或正在链接:跳过(处理循环依赖)
  3. Assert: module.[[Status]] is unlinked.
  4. Set module.[[Status]] to linking.
  5. Set module.[[DFSIndex]] to index.
  6. Set module.[[DFSAncestorIndex]] to index.
  7. Set index to index + 1.
  8. Append module to stack.
  9. 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]]).
  10. Perform ? module.InitializeEnvironment(). ← 创建 Module Environment,链接绑定
  11. Assert: module occurs exactly once in stack.
  12. Assert: module.[[DFSAncestorIndex]] ≤ module.[[DFSIndex]].
  13. 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.
  14. Return index.

步骤13中的逻辑是 Tarjan 强联通分量算法:找到循环依赖的"根"节点,一次性将整个 SCC 标记为 linked。

InnerModuleEvaluation 算法

规范 §16.2.1.5.2 定义 InnerModuleEvaluation:

  1. If module.[[Status]] is evaluating or evaluated: a. If module.[[EvaluationError]] is undefined, return index. ← 已求值或正在求值:跳过 b. Otherwise, throw module.[[EvaluationError]].
  2. ...(类似 linking 的 DFS 设置)
  3. For each required module reqModule: ← 递归求值依赖 a. Set index to ? InnerModuleEvaluation(reqModule, stack, index).
  4. If module.[[HasTLA]] is true: ← 顶层 await 处理 a. Perform ExecuteAsyncModule(module).
  5. Else: ← 普通同步模块 a. Perform ? module.ExecuteModule().
  6. ...(标记 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 或浏览器)提供:

陷阱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 适合初始化操作(加载配置、数据库连接),但应避免在经常被其他模块导入的"工具模块"中使用,以免拖慢整个应用的启动时间。

小结

  1. ESM 模块加载分三个阶段:链接(静态分析依赖、创建绑定槽)→ 实例化(连接 import 到 export 槽)→ 求值(DFS 执行代码、填充槽的值)。
  2. import { x } 导入的是 live binding(只读视口,指向导出模块的内存槽),不是值复制;导出方修改值后,所有导入方立即看到新值。
  3. ESM 循环依赖中,被先求值的模块访问后求值模块的 const/let 导出时会遇到 TDZ;使用函数声明(提升)可以安全地在循环依赖中延迟访问。
  4. CommonJS 循环依赖返回执行到当前位置的不完整 exports 对象,与 ESM 的 live binding 行为完全不同。
  5. 顶层 await 只阻塞依赖该模块的模块,不阻塞整个应用;不依赖它的"兄弟模块"可以并行求值。
本章评分
4.7  / 5  (3 评分)

💬 留言讨论