ES Modules 的“异步加载”并非指代码执行顺序异步,而是指模块图构建、链接与求值全过程由 Promise 驱动;静态 import 语句看似同步阻塞,实则是顶层模块执行被延迟至整个依赖图完成链接与求值后——这是 ESM 与 CommonJS 根本性差异的核心。
es modules 的“异步加载”并非指代码执行顺序异步,而是指模块图构建、链接与求值全过程由 promise 驱动;静态 `import` 语句看似同步阻塞,实则是顶层模块执行被延迟至整个依赖图完成链接与求值后——这是 esm 与 commonjs 根本性差异的核心。
在 JavaScript 模块化演进中,ES Modules(ESM)与 CommonJS(CJS)最常被简述为“ESM 异步、CJS 同步”,但这一说法极易引发误解。正如你在 Bun 中观察到的现象:import "exporter.mjs" 后 console.log("importer") 仍按序输出,且 Bun 甚至允许 require("./exporter.mjs") 成功运行——这恰恰说明:“异步”不是指执行时序不可预测,而是指模块生命周期的底层调度模型发生了根本转变。
根据 ECMAScript 规范,ESM 加载并非单一线性操作,而是一个严格分阶段、以 Promise 编排的异步流水线:
| 阶段 | 关键行为 | 是否异步 | 说明 |
|---|---|---|---|
| Load(加载) | 递归获取所有依赖模块源码(如 HTTP 请求或文件读取) | ✅ 异步 | 返回 Promise<ModuleRecord>,可被中断、缓存、并行发起 |
| Link(链接) | 构建模块环境记录(Module Environment Record),建立 import ↔ export 的内存绑定关系 | ✅ 异步(微任务级) | 所有依赖必须 Load 完成后才启动;此时导出变量已“声明就绪”,但尚未执行初始化代码 |
| Evaluate(求值/执行) | 逐层执行模块体代码(即 export const x = ... 中的右侧表达式) | ✅ 异步(微任务级) | 严格遵循拓扑序:依赖模块先 Evaluate,当前模块后执行;import 语句本身不执行代码,仅触发该流程 |
? 关键洞察:import 语句不立即执行模块代码,它只是向引擎提交一个“待处理的模块图构建请求”。真正执行 console.log("exporter") 是在 Evaluate 阶段——而该阶段被设计为在所有依赖完成 Link 后,统一按依赖顺序在微任务队列中同步执行。因此你看到“exporter 先于 importer 输出”,是规范强制保障的确定性执行顺序,而非 CJS 式的同步阻塞。
CommonJS 的 require() 是一个运行时函数调用:
// a.cjsconsole.log('a start');const { b } = require('./b.cjs'); // ? 此刻:同步解析路径 → 同步读文件 → 同步包装执行 → 同步返回 exportsconsole.log('a end', b);
Node.js(严格遵循规范):
require() 是同步函数,而 ESM 的 Load/Link/Evaluate 全程需 Promise 协调。若允许 require("./mod.mjs"),则必须将异步流程强行“降级”为同步——这会破坏模块图一致性(如循环依赖处理、顶层 await 支持等)。因此 Node.js 明确禁止,并要求使用 await import() 动态导入。
Bun(扩展兼容性):
Bun 在底层实现了对 require() 调用 ESM 的桥接:当检测到 .mjs 时,自动将其包裹为 await import(...) 并等待 Promise resolve 后返回命名空间对象。这属于运行时魔法,非标准行为,不可跨平台迁移。
✅ 正确跨环境写法(推荐):
// 在 CJS 环境中安全加载 ESM(Node.js / Bun / Deno 通用)async function loadESM() { const { number } = await import('./exporter.mjs'); console.log(number); // ✅ 始终可靠}
CommonJS 是“执行驱动加载”(run-then-load):代码走到 require 才开始加载;
ESM 是“加载驱动执行”(load-then-run):所有依赖提前加载链接,再统一执行——整个过程由 Promise 编排,故称“异步”,但执行顺序高度确定。
这种设计使 ESM 天然支持静态分析(Tree Shaking)、动态导入(import())、顶层 await、循环依赖安全绑定等现代工程能力;而 CJS 的同步性虽利于调试,却成为大型应用性能与架构演进的隐性瓶颈。选择模块系统,本质是在开发体验、运行时确定性与工程可扩展性之间做出权衡。