
ESM 模块加载在语法层面表现为“同步执行顺序”,但其底层机制(加载、链接、求值)全程基于 Promise 链与微任务调度,本质上是异步的;而 CommonJS 的 require 是阻塞式同步调用,二者在时序模型、依赖解析时机和循环引用处理上存在根本差异。
esm 模块加载在语法层面表现为“同步执行顺序”,但其底层机制(加载、链接、求值)全程基于 promise 链与微任务调度,本质上是异步的;而 commonjs 的 `require` 是阻塞式同步调用,二者在时序模型、依赖解析时机和循环引用处理上存在根本差异。
一、ESM 的“同步感”从何而来?——三阶段模型揭示真相
许多开发者观察到 import "a.mjs" 后 console.log("importer") 总在 a.mjs 中的代码之后执行,便误以为 ESM 是“同步加载”。这其实是模块生命周期三阶段(Construction → Instantiation → Evaluation)严格串行化 + 微任务队列保障执行顺序的结果,并非传统意义的同步 I/O。
根据 ECMAScript 规范 §16.2,ESM 加载流程如下:
| 阶段 | 关键行为 | 是否异步 | 说明 |
|---|---|---|---|
| Load(加载) | 递归获取所有依赖模块源码(HTTP 请求或文件读取) | ✅ 异步(返回 Promise) | 浏览器中触发网络请求;Node.js 中触发异步文件 I/O |
| Link(链接) | 构建模块记录(Module Record),建立导入/导出绑定映射,分配内存槽位 | ⚠️ 同步准备,但需等待 Load 完成 | 确保 import { x } from './a' 与 export const x = 1 指向同一内存地址(live binding) |
| Evaluate(求值) | 执行模块顶层代码(含 export 声明和语句) | ✅ 异步(返回 Promise) | 严格按拓扑排序:叶子模块优先;每个模块仅执行一次;自动启用严格模式 |
✅ 关键结论:import 语句本身不阻塞 JS 主线程,但整个模块图的 Load → Link → Evaluate 链路被设计为可链式等待的异步流程,最终呈现“同步执行效果”。
下面这段代码清晰印证该机制:
// a.mjs
console.log('→ a: start');
import './b.mjs';
console.log('→ a: end');
// b.mjs
console.log('→ b: start');
setTimeout(() => console.log('→ b: setTimeout'), 0);
Promise.resolve().then(() => console.log('→ b: Promise'));
console.log('→ b: end');
运行 node a.mjs 输出:
→ a: start → b: start → b: end → b: Promise → a: end → b: setTimeout
可见:
- b.mjs 的顶层代码(含 console.log)在 a.mjs 继续执行前已同步完成(Link + Evaluate 保证);
- 但 setTimeout(macrotask)晚于 Promise.then(microtask),证明 Evaluate 阶段内部仍运行在事件循环的 microtask 队列中。
二、CommonJS 的“真同步”:函数调用即阻塞
CommonJS 的 require() 是一个运行时函数调用,其行为完全不同于 ESM 的声明式 import:
// cjs/a.cjs
console.log('→ a: start');
require('./b.cjs'); // ❗ 阻塞!直到 b.cjs 执行完毕并返回 module.exports
console.log('→ a: end');
// cjs/b.cjs
console.log('→ b: start');
module.exports = { value: 'b' };
console.log('→ b: end');
输出恒为:
→ a: start → b: start → b: end → a: end
⚠️ 本质差异:
- CJS 依赖在执行时动态发现(require 可写在 if、for、回调中);
- ESM 依赖在解析时静态确定(import 必须顶层,路径必须字面量);
- CJS 导入的是值拷贝(const x = require('./m').x),后续 m.js 修改 x 不影响 x;
- ESM 导入的是实时绑定(import { x } from './m'),m.js 中 x++ 会立即反映在所有导入处。
三、为什么 Bun 能 require("./mod.mjs")?——运行时兼容层的魔法
Node.js 官方明确禁止 require() 加载 .mjs 文件(抛出 ERR_REQUIRE_ESM),因其无法在同步函数调用中等待 ESM 的异步加载链。而 Bun 之所以支持:
// importer.cjs
const { number } = require("./exporter.mjs"); // ✅ Bun 允许
是因为 Bun 在底层实现了CJS-to-ESM 桥接适配器:
- 当检测到 require() 请求 .mjs 时,Bun 自动将该请求转换为等效的 await import() 调用;
- 利用 require() 的同步语义“伪装”,实际在内部 await 微任务完成;
- 最终将 ESM 模块的命名空间对象({ default, ...namedExports })包装后返回给 CJS 消费者。
? 这并非标准行为,而是 Bun 为提升开发者体验所做的运行时妥协。Node.js 坚持规范一致性,故要求 CJS 想用 ESM 必须显式使用 import() 动态导入:
// Node.js 中 CJS 正确写法 const { number } = await import('./exporter.mjs');
四、实践建议:何时关注“异步性”?
你无需为日常 import 担心异步问题,但以下场景必须警惕:
| 场景 | 风险 | 解决方案 |
|---|---|---|
| 动态导入 | import() 返回 Promise,不可直接解构 | const mod = await import('./utils.mjs') 或 .then() |
| 顶层 await | ESM 支持 await 在模块顶层,但会延迟整个模块求值 | 仅在真正需要异步初始化时使用(如配置加载) |
| 跨环境兼容 | 浏览器 <script type="module"> 加载天然异步;Node.js 需 --experimental-top-level-await</script> | 统一使用 import() 处理条件加载逻辑 |
| 循环引用调试 | CJS 中 b.js 的 require('./a') 可能拿到 {}(未完成导出);ESM 中因 live binding 总能访问已声明变量 | 优先用 ESM,避免在导出前依赖对方模块状态 |
总结:一句话厘清核心
ESM 的“异步”指模块图构建与执行依赖 Promise 链与微任务调度,确保静态可分析性与优化能力;CommonJS 的“同步”指 require() 是阻塞式函数调用,牺牲分析能力换取运行时灵活性。所谓“ESM 异步”,不是指代码执行乱序,而是指其加载机制天然拥抱现代 JavaScript 的异步并发模型。
Java免费学习笔记:立即使用
解锁 Java 大师之旅:从入门到精通的终极指南











