解析:Commonjs(广度优先-queue)和ES Module(深度优先-stack)
对于 ESModule的工作流程主要包含以下三个步骤:
- 构造(Construction) — 找到、下载并解析所有文件为模块记录。
- 实例化(Instantiation) — 在内存中找到位置用于存放所有的导出值,但是不用实际值来填充它们。然后让导出和导入都指向内存中的这些位置。这被称为链接(linking)。
- 评估(Evaluation) — 运行代码以真实值填充这些位置。
commonjs循环引用
// a.js
module.exports.a = 1
var b = require('./b')
console.log(b)
module.exports.a = 2
// b.js
module.exports.b = 11
var a = require('./a')
console.log(a)
module.exports.b = 22
//main.js
var a = require('./a')
console.log(a)执行 node main.js -> 第一行 require(a.js),(node执行也可以理解为调用了require方法,我们省略require(main.js)内容)进入 require(a)方法: 判断缓存(无) -> 初始化一个 module -> 将 module 加入缓存 -> 执行模块 a.js 内容,(需要注意 是先加入缓存, 后执行模块内容)a.js: 第一行导出 a = 1 -> 第二行 require(b.js)(a 只执行了第一行)进入 require(b) 内 同 1 -> 执行模块 b.js 内容b.js: 第一行 b = 11 -> 第二行 require(a.js)require(a) 此时 a.js 是第二次调用 require -> 判断缓存(有)-> cachedModule.exports -> 回到 b.js(因为js对象引用问题 此时的cachedModule.exports = { a: 1 })b.js:第三行 输出 { a: 1 } -> 第四行 修改 b = 22 -> 执行完毕回到 a.jsa.js:第二行 require 完毕 获取到 b -> 第三行 输出 { b: 22 } -> 第四行 导出 a = 2 -> 执行完毕回到 main.jsmain.js:获取 a -> 第二行 输出 { a: 2 } -> 执行完毕
es module 循环引用
// bar.js
import { foo } from './foo'
console.log(foo);
export let bar = 'bar'
// foo.js
import { bar } from './bar'
console.log(bar);
export let foo = 'foo'
// main.js
import { bar } from './bar'
console.log(bar)- 加载
main.js:main.js导入了bar模块,所以首先会加载bar.js。
- 加载
bar.js:bar.js导入了foo模块,因此开始加载foo.js。
- 加载
foo.js:foo.js导入了bar模块。此时由于bar模块尚未完成加载,会返回一个未定义的占位符。foo.js尝试访问并打印bar,但是bar还没有完成初始化,这就会导致报错ReferenceError: Cannot access 'bar' before initialization。- 在这一点,代码执行会被中断,报错信息会被抛出。
未报错情况下执行顺序:
- 加载
main.js:main.js导入了bar模块,所以首先会加载bar.js。
- 加载
bar.js:bar.js导入了foo模块,因此开始加载foo.js。
- 加载
foo.js:foo.js导入了bar模块。此时由于bar模块尚未完成加载,会返回一个未定义的占位符(因为bar模块还在初始化过程中)。foo.js执行console.log(bar);打印出undefined。这是因为bar还没有完成定义和导出。- 然后
foo.js导出foo变量并将其值设为'foo'。
- 继续加载
bar.js:bar.js继续执行,现在foo模块已经完成导出,foo的值是'foo'。bar.js执行console.log(foo);打印出'foo'。- 然后
bar.js导出bar变量并将其值设为'bar'。
- 继续加载
main.js:main.js继续执行,现在bar模块已经完成导出,bar的值是'bar'。main.js执行console.log(bar);打印出'bar'。
输出结果总结:
根据上述分析,输出结果将会是:
undefined
foo
baresmodule执行顺序为先查找依赖,然后从最底层的子依赖开始执行,执行完再依次向上层父级代码继续执行; commonjs是动态执行,执行代码,遇到require进入依赖,再次执行依赖的代码,执行完跳到上层父级代码继续执行
CommonJs 和 ES6 Module 的区别
其实上面我们已经说到了一些区别
CommonJs导出的是变量的一份浅拷贝,ES6 Module导出的是变量的引用/绑定(export default是特殊的)CommonJs是单个值导出,ES6 Module可以导出多个CommonJs是动态语法可以写在判断里,ES6 Module静态语法只能写在顶层CommonJs的this是当前模块,ES6 Module的this是undefined
思考:
exports.count = 100;
module.exports = { count: 1 };
// 打印count结果
1
module.exports = { count: 1 };
exports.count = 100;
// 打印count结果
1
exports只是module.exports的引用,一旦你给module.exports赋了一个新的对象或值,exports就不再指向同一个对象
循环依赖问题
在循环依赖(cyclic dependency)中,模块的依赖关系会形成一个图(graph)中的循环。通常,这个循环会很长,但为了更清楚地解释问题,我们用一个简单的短循环来举例说明。

左侧: 复杂的模块依赖图,包含一个由 4 个模块组成的循环。
右侧: 一个简单的 2 模块循环。
CommonJS 模块的执行方式
让我们看看 CommonJS 模块在循环依赖中的工作方式。
- 主模块(main.js)开始执行,直到遇到
require语句。- 然后,它会去加载
counter.js模块。
- 然后,它会去加载
- 加载
counter.js模块时,它尝试访问main.js的导出对象。- 但此时,
main.js还未完成执行,因此counter.js访问message变量时会得到undefined。 - 在 JS 引擎内部,它会在内存中为
message变量分配空间,并将初始值设为undefined。
- 但此时,
内存状态示意
在 counter.js 运行时,它的 require('./main') 访问的是一个尚未完全初始化的 main.js,所以它只能获取 undefined,内存状态如下:
main.js还未执行完毕,因此message变量在counter.js里是undefined****。
counter.js继续执行到文件末尾,并设置一个定时器(setTimeout),希望稍后能获取message的正确值。- 执行回到
main.js****,继续运行剩余代码。 message变量在main.js中被初始化,并存储到内存中。- 但由于
counter.js的require('./main')之前只获取到了undefined,它的引用不会自动更新。
- 但由于
最终的问题
尽管 main.js 已经正确地初始化了 message,但 counter.js 仍然引用的是之前的 undefined****,而不会自动更新。
ES 模块(ESM)如何解决这个问题?
如果**导出值(export)是通过“实时绑定”(live bindings)**的方式处理的,那么 counter.js 最终会看到正确的值。
- 因为 ES 模块采用了三阶段解析机制:
- 构建(Construction Phase):建立模块的导入/导出关系,但不执行代码。
- 实例化(Instantiation Phase):创建变量的引用,但不赋值。
- 执行(Evaluation Phase):真正执行代码,并更新变量的值。
这样,在模块执行完成后,counter.js 访问 message 变量时,就会获得最新的值,而不会停留在 undefined。
总结
- CommonJS 采用的是值的拷贝(值传递),所以在循环依赖的情况下,模块只能获取到当时的快照值,如果变量在稍后才赋值,导入模块不会看到更新后的值。
- ES 模块(ESM) 采用实时绑定(live bindings),允许模块在执行完成后仍然能访问到最新的值,这使得循环依赖不会导致
undefined问题。 - ES 模块的三阶段设计 让它能够支持复杂的循环依赖,这也是它的设计初衷之一。