
ES 模块(ESM)的“异步性”并非指执行顺序不可预测,而是其加载、链接与求值三阶段严格按异步流程调度;静态 import 语句看似同步,实为顶层模块图构建与微任务驱动的确定性执行,与 CommonJS 的阻塞式同步 require 有根本差异。
es 模块(esm)的“异步性”并非指执行顺序不可预测,而是其加载、链接与求值三阶段严格按异步流程调度;静态 `import` 语句看似同步,实为顶层模块图构建与微任务驱动的确定性执行,与 commonjs 的阻塞式同步 `require` 有根本差异。
在前端和 Node.js 开发中,常听到一句高度简化的论断:“CommonJS 是同步的,ES Modules 是异步的”。这句话虽便于传播,却极易引发误解——正如你在 Bun 中成功用 require('./exporter.mjs') 加载 ESM 文件所观察到的那样:行为不等于机制,运行结果不等于执行模型。要真正厘清这个问题,我们必须回归 ECMAScript 规范定义的模块生命周期,并对比两种系统的设计哲学。
✅ ESM 的三阶段异步模型(规范级事实)
根据 ECMA-262 §16.2 Modules,ES 模块的加载不是单一步骤,而是一个由宿主(如 Node.js、Bun、浏览器)协调的、基于 Promise 的三阶段异步流程:
| 阶段 | 关键动作 | 是否异步 | 说明 |
|---|---|---|---|
| 1. Load(加载) | 递归获取所有依赖模块源码(网络请求或文件读取) | ✅ 异步(返回 Promise) | 宿主决定如何获取资源(如 fetch() 或 fs.readFile()),此阶段可并行、可缓存、可中断。 |
| 2. Link(链接) | 建立模块环境记录(Module Environment Record),绑定 import 与 export 的内存引用 | ⚠️ 同步但不可重入 | 必须等所有依赖 Load 完成后才能开始;它不执行代码,只建立符号映射(例如 import { a } from './a' → 指向 a 的实时绑定)。 |
| 3. Evaluate(求值) | 执行模块体代码(即 console.log("exporter") 等语句) | ✅ 异步(返回 Promise) | 所有依赖必须已完成 Link;执行顺序由模块图拓扑决定(深度优先),且整个链路通过微任务队列调度,保证导出值在求值完成前已就绪。 |
? 关键洞见:import 'exporter.mjs' 在顶层出现时,引擎不会立即执行 exporter.mjs 的代码,而是先启动 Load → Link → Evaluate 流程链。由于 Evaluate 返回 Promise 并被宿主 await,你看到的“exporter 先打印、importer 后打印”,正是该 Promise 链被 microtask 驱动后呈现的确定性结果,而非同步阻塞。
❌ CommonJS 的同步阻塞模型(运行时事实)
CommonJS 没有“加载/链接/求值”的分离设计。它的 require() 是一个运行时函数调用,触发以下原子操作:
// require('./b.js') 执行时:
1. 同步解析路径(查 node_modules、package.json#main)
2. 同步读取文件(fs.readFileSync → 阻塞主线程)
3. 包装为函数并立即执行:(function(exports, require, module, __filename, __dirname) { ... })()
4. 缓存 module.exports 到 require.cache这意味着:
- 代码执行流在 require() 处完全暂停,直到被依赖模块执行完毕;
- 循环依赖时,module.exports 可能仅初始化为空对象({}),导致 b.js 中读取 a 为 undefined;
- 无法进行静态分析,Tree Shaking、摇树优化、类型推导均失效。
? 实验对比:循环依赖揭示本质差异
// a.cjs
const { b } = require('./b.cjs');
console.log('a: b =', b); // → "a: b = b"
module.exports.a = 'a';
// b.cjs
const { a } = require('./a.cjs');
console.log('b: a =', a); // → "b: a = undefined"(因 a 尚未赋值)
module.exports.b = 'b';// a.mjs
import { b } from './b.mjs';
console.log('a: b =', b); // → "a: b = b"
export const a = 'a';
// b.mjs
import { a } from './a.mjs';
console.log('b: a =', a); // → "b: a = a"(Link 阶段已建立绑定,Evaluate 时 a 已就绪)
export const b = 'b';✅ 输出差异的根本原因:
- CJS:require() 是执行时动态查找 → a.cjs 执行到 require('./b.cjs') 时,a 还未赋值;
- ESM:import 是编译期静态声明 → Link 阶段已将 a.mjs 的 a 绑定到 b.mjs 的 import,无论 a 是否已赋值,该绑定在 Evaluate 开始前已存在。
? 为什么 Bun 能 require('./x.mjs')?——兼容层的魔法
Node.js 严格遵循规范:CJS 不能 require() ESM(因其无法在同步上下文中等待 Load/Link/Evaluate Promise 链)。但 Bun 作为现代运行时,在 require() 内部实现了透明的 ESM 适配桥接:
- 当检测到 .mjs 文件时,Bun 自动启动 ESM 加载流程;
- 将 require() 调用转换为 await import() + 同步提取默认导出(或命名导出);
- 对开发者隐藏了异步细节,牺牲了规范纯度,换取了工程便利性。
这并非 ESM “变同步”了,而是 Bun 在 CJS 接口上封装了一层异步胶水代码。
?️ 开发者须知:最佳实践建议
- ✅ 优先使用静态 import:享受 Tree Shaking、IDE 智能提示、构建工具优化;
- ✅ 动态导入用 import():实现代码分割、按需加载、条件加载(如 if (env === 'prod') await import('./analytics.mjs'));
- ⚠️ 避免混合模块系统:Node.js 中 type: "module" 项目应统一用 import;若需 CJS 依赖,确保其导出兼容(如提供 exports.default);
- ⚠️ 警惕 Bun 的“便利陷阱”:其 require() 支持 ESM 不具跨平台性,上线前务必在目标环境(如 Node.js LTS)验证;
- ? 记住核心心智模型:
ESM = 静态声明 + 异步三阶段 + 实时绑定
CJS = 动态调用 + 同步阻塞 + 值拷贝(非绑定)
模块系统的演进,本质是 JavaScript 从脚本语言迈向工程化语言的缩影。理解 ESM 的异步性,不是纠结“是否立刻执行”,而是掌握它如何以可预测、可优化、可静态分析的方式,重构了代码的组织逻辑与执行契约。

















