ESModule解析流程

关于ESModule是如何被浏览器解析并且让模块之间可以相互引用的详细内容可以看这篇文章:
https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/

我们回顾一下模块化的出现解决了什么问题?

  • 解决了文件之间的依赖管理问题,不必过于纠结模块的依赖关系引发的顺序问题和删除文件导致的依赖错误。

  • 避免了污染全局变量,如恶意更改代码和不小心覆盖了变量。

ESModule解析流程

对于ES模块,可以分三个步骤进行:

  1. Construction(构造):查找、下载和解析所有文件到模块记录中。

  2. Instantiation(实例化):在内存中的查找区域(区域内有一块一块的结构)内放置所有导出的值(但不要用值填充他们)。然后是exports和imports都指向内存中的查找区域块,这称为链接。

  3. Evaluation(求值):运行代码以使用变量的实际值填充内存中的查找区域块。

Construction(构造)

当使用模块化开发时,我们需要给JS运行时环境(如浏览器、NodeJS)指定一个入口文件,通过该入口文件,JS运行时环境将沿着入口文件的import语句向内查找可形成一个依赖关系图,不同的依赖项由各个模块的import语句连接。

而对于每个依赖项的寻找是通过模块说明符(module specifier)来寻找的。

如果模块说明符是一个相对路径的话,如./utils.js,会将其转换为绝对路径的URL。

  • 绝对路径的URL是基于HTTP协议的资源定位符。

  • 由当前服务器的IP和项目运行设定的端口,路径指向服务器定义的根目录。

如果模块说明符直接是一个模块名的话,会通过Import Maps映射为实际的URL,在浏览器端通过在script标签的type=importmap中编写json格式的数据实现。

1
2
3
4
5
6
7
<script type="importmap">
{
"imports": {
"react": "https://cdn.skypack.dev/react@17.0.1"
}
}
</script>

但由于一个个模块其实就是一个个文件,文件并不是浏览器可以使用的东西,浏览器如果想解析这些文件,就需要将其解析为一个个模块记录。

模块记录其实就是一个对象,包含导入导出声明,以及其依赖关系。

注意:模块记录并不包含完整代码的AST,在构造这一阶段,会将代码解析为AST,但仅保留AST中的关键信息,就是上述的导入导出声明以及依赖关系。当构造阶段结束,AST就会被销毁,但运行时环境(浏览器/NodeJS)通常会保留原始代码或编译后的可执行代码(如字节码),用于求值阶段的执行。

每个模块记录中的依赖关系不仅可以形成一个模块的依赖关系图,同时也决定了模块的执行顺序。

当模块解析完后,就会对其进行模块缓存,其缓存会存储在全局Moudle Map中,以模块解析后的URL为键,以模块记录为值。

  • 目的就是为了避免重复下载:如果模块已加载或正在加载,直接复用缓存。

  • 模块的状态:

    • Fetching(加载中)
    • Fetched(已加载)
    • Instantiation(实例化)
    • Evaluated(已求值)

Instantiation(实例化)

在构造阶段,我们已经获得了一个个模块记录,每一个模块记录中都包含其相应的导入导出声明和依赖关系,这一阶段的目的就是将每一个模块记录中的依赖关系转换为内存的查找区域块,并完成导入和导出的指向。

在实例化开始阶段,JS引擎会创建一个模块环境记录(module environment record),模块环境记录管理着模块记录中的导入导出变量,并由JS引擎在内存中为它们分配空间,由模块环境记录对这些变量进行关联,但此时的变量并没有实际值,如果是普通变量的话,值为undefined,如果是函数的话,会对函数进行创建,这有利于求值阶段的执行。

对于模块环境记录中的导入导出变量,可以称之为状态,对于这些状态,JS引擎会通过类似于哈希表的结构进行存储,键为变量名,值为内存地址。

如果模块记录转换为模块环境记录成功后,就会将这个模块环境记录追加到Module Map中对应的模块记录的后面。

总而言之,实例化这一阶段,就是将构造阶段的模块记录中的导入导出声明在内存中开辟一块区域进行存储,并与构造阶段的模块记录中的代码解析遗留下来的可执行代码(如字节码)进行结合形成模块环境记录,用于管理变量的内存地址,并为求值阶段的填充值做准备。

Evaluation(求值)

上文提到,在构造阶段,虽然解析代码后的AST销毁了,但保留了可执行代码(如字节码),在此阶段其实就是进行可执行代码的执行,并为实例化阶段的模块环境记录中跟踪的状态的值填充。

在代码执行完毕,并对值进行填充完毕后,同样会在对应模块环境记录对应的Module Map的后面追加此模块求值的状态。

在此阶段,会进行顶层代码的执行,顶层代码指的就是直接就在最外层的代码,即不在任何函数,代码块或异步上下文内的代码,只有从顶层代码开始执行,才能执行其他代码嘛。

在此阶段,模块的顶层代码只会执行一次,就是在第一次被导入的时候执行,后续再导入就会直接复用结果了,因为对其进行缓存了嘛。

同时,会开启严格模式对代码进行执行,并不需要显式的指定"use strict",默认就会开启。所以,通过ESModule的模块化都是在严格模式下进行的。

同时我们在构造阶段可以得到一个依赖关系图,在求值阶段就会根据该依赖关系进行后序深度优先遍历的顺序执行模块的顶层代码。这样可以确保父模块执行时,使用子模块时已经执行完毕了。

但模块之间的关系可能会存在循环依赖的问题,对于循环依赖,在构造和实例化阶段是不会有影响的,因为它们并不牵扯到代码的执行,仅仅是依赖关系的解析和变量的实时绑定,但在求值阶段可能会导致变量未初始化(TDZ错误)或副作用顺序异常。

对于这个循环依赖问题,js引擎对它有一定的处理步骤

假如a.js和b.ja相互依赖

1
2
3
4
5
6
7
8
9
// b.js
import a from 'a.js'
console.log(a)
export let b = 'b'

// a.js
import b from 'b.js'
console.log(b)
export let a = 'a'
  1. 执行a.js顶层代码,遇到了b.js导入。

  2. 执行b.js代码,但发现a.js导入,但a.js处于正在处理状态。

  3. 强制执行b.js代码,需要处理a.js导出的变量,如果a.js导出的变量是let,const声明的,而a.js中对于b.js的导出在a.js中导出的前面,就会按照浏览器抛出暂时性死区TDZ错误,如果是var声明的,则是undefined。

  4. 返回继续执行a.js代码。

解决循环依赖的方案:

  • 重构代码,提取公共部分到带三方模块。

  • 延迟访问。

  • 使用动态导入。

注意:ESModule在求值阶段可能会产生副作用,如TDZ和执行顺序问题,因为顶层代码指的是最外层的代码,即不在代码块内部的代码,所有如果存在打印语句,也是会执行,因为它们并不是导出和导入相关的,在求值阶段的执行可行性代码就可能会产生上述问题,但也就只会执行一次,因为会对模块的状态和导入导出变量进行缓存,再次引入模块只会获得导入导出的变量在内存中的值罢了。

关于ESModule和CommonJS

缓存方面

CommonJS和ESModule的缓存都是一次执行期间缓存,在内存进行缓存,但它们之间缓存方式还是有所区别的,CommonJS缓存的是导出的对象,并且可以通过api进行缓存删除,而ESModule缓存的是模块记录,实例化环境和求值结果,且没有api能对缓存结果删除。

执行方面

而由于ESModule可以在浏览器环境下使用,为了防止线程阻塞,构造、实例化和求值三个阶段可以是异步的并且是并行的,如一个模块引入了多个模块,多个模块之间可以并行解析,但每个阶段内部可以是同步执行的。

而CommonJS是在用于Node服务端的,并不需要考虑渲染等层面的问题,而且读取文件也是本地读取的方式,所以采用同步执行不是影响整个流程的。

导入和导出方面

CommonJS导出的变量是一个拷贝,如果是基本数据类型的话,导入和导出一方改变,另一方是看不到的。

ESModule导出和导入的变量指向的是同一块内存地址,由模块环境记录阶段处理,所以它们的修改是同步的可见的,而且对于导入的变量是不能进行修改的,如果是对象的话,还是能能修改内部属性值的。