携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第5天,点击查看活动详情
本篇文章想简单聊一聊关于ES6模块这方面的知识点,温故而知新
由循环引用引发的思考
为什么ESModule会允许循环依赖?
实际上循环依赖本身就是当初设计ES6 Module时需要去支持的目标之一,如下所示:
- 支持默认导出
- 静态的模块结构
- 同时支持同步和异步加载
- 支持模块间的循环依赖
- 等等...
规范如下: 实际上循环引用是很常见的,尤其是在javascript的对象中,比如说一棵DOM树,父节点可以引用子节点,子节点也可以引用父节点。
而在实现一些类库的时候,如果设计的好确实可以完全规避循环引用;但是在一些大型的多人协作项目里面,无可避免地会产生循环依赖。这个时候支持循环依赖就非常有用了,尤其是在我们重构的时候,不至于让这个系统不可用。
因此他们就将问题抛给了开发者,让开发者自己决定是否去支持循环依赖。
分析循环依赖
目前大多数的情况是,存量的代码里面本身确实有很多的循环引用,虽然本身并不会导致程序出问题,但是也是一个可以优化的点。
我们可以先找出整个依赖的图,然后再渐进式慢慢优化(如果直接通过eslint来去做限制,可能存量代码改起来工作量会非常大),只要打断其中一个环,便减少一个循环依赖。
这里只介绍一下如何更好地发现循环依赖,至于如何优化循环依赖,可以看大佬的文章。
我们可以借助一个webpack插件circular-dependency-plugin去直接得出项目里的循环依赖。

- 插件使用非常简单,在webpack的配置文件里加入如下配置:
plugins: [
new CircularDependencyPlugin({
include: '你的代码目录',
exclude: /node_modules/,
})
]
复制代码
-
可以得到如下的构建提醒:
-
由于这个插件本身并不提供可视化的能力,这里我们可以基于它来自己实现以下,去生成一张依赖图,使其看起来更加直观。
-
实际上插件本身暴露了一些生命周期的方法,我们可以在里面获取到一些循环依赖的节点
plugins: [
new CircularDependencyPlugin({
include: '代码目录',
exclude: /node_modules/,
// 在检测循环依赖开始前调用
onStart({ compilation }) {
},
// 每个模块发生循环依赖时被调用
onDetected({ module: webpackModuleRecord, paths, compilation }) {
// path是一个循环依赖数组的环
// 比如A->B->C->A 对应的path就是['A','B','C','A']
},
// 检测结束的回调
onEnd({ compilation }) {
},
})
]
复制代码
-
实际上我们拿到了循环依赖的节点之后,就可以做一些自定义的操作了,比如将它们可视化,得到一张循环依赖的图
-
首先确定生成图表需要什么样的数据结构,以ECharts为例,它提供的图需要如下的数据结构
// 实际上就是一个节点名称的数组
const data = [
{
name: '节点1'
}
]
// 加一个描述节点连接关系的数组
const links = [
{
souce: '节点1',
target: '节点2'
}
]
复制代码
- 确定好所需的数据结构后,就可以将我们从插件生命周期方法里拿到的循环依赖数据进行转换,可以根据自己的需要做转化,这里简单转换如下:
onDetected({ module: webpackModuleRecord, paths, compilation }) {
// path是一个循环依赖数组的环
// 比如A->B->C->A 对应的path就是['A','B','C','A']
for(let i = 0; i < paths.length-1; i++) {
if(set.has(paths[i]+paths[i+1])) {
return;
}
set.add(paths[i]+paths[i+1])
const item = {
source: paths[i],
target: paths[i+1],
};
// 这里是节点名称的集合,使用Set来防止名称重复的
nodes.add(item.source);
// 这里是连接关系的数组
links.push(item);
}
},
// 检测结束的回调
onEnd({ compilation }) {
// 检测结束拿到完整的依赖关系图
// 这里就是echarts生成依赖图所需要的数据
console.log(links);
console.log(Array.from(nodes.values()).map(item => ({name: item})));
},
复制代码
- 最终可以生成项目的循环依赖关系图,这样可以让你更直观地去分析发现自己项目中的循环依赖
ES6的模块系统是如何工作的
实际上ES模块系统工作时,会分三个阶段进行:
- 首先是构造
- 从入口点开始去查找模块,以浏览器为例,现代浏览器基本都支持通过script标签引入模块
<script src="./index.js" type="module">
复制代码
我上面提到过ES当初的设计目标之一有一个叫同时支持同步和异步加载。像在浏览器里面的实现,因为需要下载文件,所以就设计成异步的,如果是其他平台的loader,有可能是同步的。
-
找到入口文件之后,将模块文件转换成一种叫模块记录的数据结构。
-
继续根据import语句去往下寻找模块依赖,并且构建出一棵依赖树
// from后面的叫模块标识符,模块加载器就是根据这个标识符去寻找下一个依赖,需要注意的是,这里跟commonJS的区别是,模块标识符必须是一个确定的字符串值,而不是一个标量
import xxx from 'xxx';
复制代码
需要注意的是,当一个模块记录转换出来之后,将会以[url, Module]这样键值对的形式存到Map里,如果又有其他模块引用了这个模块,那这个时候将不再重新去获取,而直接从Map里面取
- 第二步是实例化
- 这一步实际上是以深度优先遍历的方式为第一步的每个模块记录创建一个模块环境记录
- 这个模块环境记录会找到这个模块内导出的所有变量,并为他们创建内存空间.
注意,这个时候普通的变量是未初始化的,而function的提升比较特殊,实际上已经对其进行初始化了
- 接着将一个模块的导入连接到它对应的导出的内存空间上,就像一根水管,将对应的导入和导出连接到一起,这就是所谓的动态绑定。
这就是为什么说import导入是值引用的方式,因为实际上它的导入跟导出指向的是同个地址,需要注意的是,不能够改变导入的模块变量的值
import a from 'moduleA';
a = 1; // 这里修改a的值会报错
// 当然,如果是对象的话,可以改变对象的值
复制代码
如果是通过webpack打包的话,我们可以看到webpack是怎么模拟实现规范里的这一步的,打包后的代码如下(不同的版本打包出来可能略有差异,这里以webpack 5.42.1为例)
__webpack_require__.d = (exports, definition) => {
for(var key in definition) {
// 先判断是否已经挂载到exports上
if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
// 在exports上添加相应的getter
Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
}
}
};
复制代码
通过上面简单的代码可以看出以下两点
- 为了实现ESM的规范,它定义了一个getter来通过闭包的方式引用了模块里面需要导出的值,这也说明了导出的不是值的拷贝,而是共享的内存空间。
- 由于没有定义setter,所以也不能修改导出变量的值。
像这样实现动态绑定的原因,实际上就是为了更好地去支持循环依赖。以导出一个相同的变量a为例,如果像commonJS一样是值拷贝的方式,且发生了循环依赖,后续程序运行的时候得到的值就只会一直是undefined; 而如果是ESM,用的是值引用的方式,后续运行时取值的时候,实际上触发的是getter,等到所有模块都初始化求值完成后,就不会一直是undefined
如下所示是commonjs的实现,当发生循环依赖的时候,因为require的时候是值的拷贝,此时代码还没执行的话,初始值就会是undefined,又因为是值拷贝,即使等到后续模块真正求值完成,在导入它的模块里面也得不到更新后的值
- 最后一步是求值的操作
这一步其实就是真正把代码执行起来,对变量进行初始化,把值填充到上面第二步所述的内存空间里面。
总结
通过上面的步骤,其实就可以解释我们常常听说的ESModule的特性了。
- 首先,import可能是同步的也可能是异步的,这个取决于不同环境的loader自己去实现。因为ESModule的规范只是定义了如何去将模块文件转成模块记录,如何去实例化并建立绑定关系,如何去求值。至于如何去获取这个模块文件,则交给loader自己去实现。比如在浏览器中就实现成异步的。
- 我们常说import是静态的(注意这里不包含动态引入import('xxx')),之所以这么说,你可以从上述步骤知道,ESModule工作的时候分成了三个阶段,它的模块依赖关系的建立是在编译阶段,而非运行阶段。
- export导出的是值的引用,这个特性使得它能支持循环依赖,因为等模块最后求值完成的时候,再去取它导出的值是不会像cjs一样取到undefined。