解析: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.js
a.js:第二行 require 完毕 获取到 b -> 第三行 输出 { b: 22 } -> 第四行 导出 a = 2 -> 执行完毕回到 main.js
main.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
bar
esmodule执行顺序为先查找依赖,然后从最底层的子依赖开始执行,执行完再依次向上层父级代码继续执行; 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 模块的三阶段设计 让它能够支持复杂的循环依赖,这也是它的设计初衷之一。