ESM 解析与加载:模块图与后序执行
本节目标
- 理解 ESM 的「解析 → 实例化 → 求值」三阶段
- 理解「模块图(Module Graph)」与「后序执行」
- 知道为什么「导入的变量在模块顶层可用」
ESM 加载分三步:解析(建模块图、确定依赖)、实例化(分配内存、建立 live binding)、求值(自底向上执行模块体)。
入口 main.mjs
├─ import a.mjs ─┐
├─ import b.mjs ─┤ 这些依赖先解析
依赖 a 又 import c.mjs ┘
执行顺序(后序):c → a → b → main
(被依赖的先执行完,依赖方最后执行)可运行示意:用 console.log 看执行顺序。
// 运行环境:Node.js 16+(.mjs),观察打印顺序
// c.mjs
console.log('c 执行')
export const c = 1
// a.mjs
import { c } from './c.mjs'
console.log('a 执行, 读到 c=', c)
export const a = c + 1
// main.mjs
import { a } from './a.mjs'
console.log('main 执行, 读到 a=', a)
// 输出:c 执行 → a 执行,读到 c=1 → main 执行,读到 a=2要点:ESM 先「把整张依赖图解析并实例化」再「自底向上求值」,所以 main 执行时,它依赖的 a、c 早跑完了,顶层就能用到它们的值。
名词解释
模块图(Module Graph):所有模块按「谁 import 谁」连成的有向图。入口是根,被依赖的是子节点。打包器和引擎都先构建这张图,才知道加载/执行顺序。它像一张「家谱」,清楚显示模块间的血缘。
后序执行(Post-order):「被依赖的先执行,依赖方后执行」。因为依赖方顶层要用到被依赖方导出的值,所以必须等底层就绪。ESM 用「先实例化建 binding、再自底向上求值」保证这点,这也是它循环依赖比 CJS 稳的原因(见下节)。
课后练习
练习 1:为什么 main.mjs 顶层就能用 a,而不会像 CJS 拿到半成品?
答案:ESM 在「求值」前先完成「实例化」:把所有模块的导出/导入槽位连成 live binding,再自底向上执行。等
main开始执行时,a所在模块早已跑完并填好 binding,所以main顶层读到的a是完整值。
练习 2:模块图如果有环(A↔B),执行顺序怎么定?
答案:引擎用「后序 + 已访问标记」避免无限循环:每个模块只实例化/求值一次,遇到环里已访问的节点就跳过。于是环里每个模块至少能拿到「已实例化好的 binding」(虽未求值完),靠 live binding 补齐,比 CJS 半成品友好(见 jsm-l9)。
总结
ESM 三阶段(解析→实例化→求值)看着抽象,实则是「为了稳定」精心设计的。我的观点:理解「后序执行」能解释前端 90% 的「为什么我的 import 能用」——因为引擎保证了「你依赖的一定先跑完」。对比 CJS 的「运行时碰到才加载」,ESM 的「先建图再执行」更可预测、更安全。这也是为什么现代打包器能放心做静态优化:整张图在编译期就透明了。结论:ESM 不是「把 require 换个写法」,而是一套「先规划后执行」的更稳模型,循环依赖的坑也由此变小。