本文中所有的漫画都来自于: https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
基本概念
不论是浏览器还是 Node,ESModule 规定都是按照如下步骤进行:
- 从一个被认为是要通过 ESModule 规则处理的入口模块出发
- 通过文件中
import
语句构建依赖关系图,加载所有需要的代码 - 将文件转化为内存中的“模块实例”以执行
模块实例是一个引擎内部的数据结构,也是 ESModule 中的概念;将依赖关系图中的模块符号彻底转化为实例的过程就是加载模块。
模块记录
在 ESModule 的语境下,每个代码文件都应该是一个模块;但是实际上使用的过程中需要将它们转化为内部的数据结构 —— 模块记录:
模块记录中至少包含了代码 AST,以及它导入的模块入口和导出的符号。
模块实例
模块实例包含了两个部分: 代码 + 状态;这里就可以用组成原理的类比:它们都储存在内存中,但是代码是可执行的,而状态则包含了待处理的数据。加载模块的最终目的就是将文件的模块转化为内存中模块实例,以供引擎使用。
模块映射
模块映射通过模块标识符区分模块,追踪一个模块的加载状态,并缓存模块记录和模块实例。
模块映射的存在保证了同一标识符所指向的模块只会被加载/执行一次;之后所有对该模块的重复加载都共享了同一个模块实例。
加载模块
ESModule 的模块加载分为三个阶段 —— 构造、实例化、求值
- 构造阶段 —— 需要从入口文件(模块)出发,构建依赖关系图,查找并下载图中的所有文件,并且将它们解析为模块记录
- 实例化阶段 —— 为每个模块(的导出符号)分配内存空间,并且将这些符号和所有的导入都链接到符号对应的内存上
- 求值阶段 —— 运行模块的代码,并且将这些符号的实际值存储到内存中
正是因为 ESModule 将加载模块分为了这样的三个阶段,因此使得模块的加载具有了异步的可能 —— 这种异步性是不严格区分这三个阶段的 CommonJS 和按顺序加载的浏览器所不具备的。
但是 ESModule 并没有严格地规定死模块加载的进行方式;实际上,ESModule 的标准只约束了三个阶段中的解析、实例化、求值的环节;而将如何获取模块文件的问题留给了引擎。一般地,引擎通过装载器决定从哪里如何获得模块文件。
而且,虽然模块的解析、实例化和求值的过程都是被 ESModule 规范控制的,但是实际上怎么进行还是引擎说的算 —— 引擎可以通过调用 ESModule 的 API ParseModule
、Module.Instantiate
和 Module.Evaluate
来控制这些过程实际发生的时间。
接下来分别介绍加载模块的三个阶段:
构造阶段
对于每个模块而言,构造阶段又分为了三个阶段:
- 模块解析:查找获得模块文件的方法
- 模块获取:下载模块文件,或者从文件系统读取模块文件
- 将模块文件解析为模块记录
和任何模块系统一样,要加载一个模块,首先需要知道它的入口;而在浏览器中,使一个 JS 文件成为 ESModule 入口点的方法是将它声明为 type="module"
;
从入口模块开始,JS 文件中会包含 ES6 规范下的导入语句 —— 这些导入语句中字符串的部分就是模块标识符,它告诉加载器如何找到带引入的模块;但是加载器如何理解这个模块标识符则取决于引擎:Node 会根据这个字符串确定是相对地址、绝对地址还是模块名,再进一步查找入口文件;而浏览器只会单纯地将字符串考虑为 URL。
引擎从入口开始边加载模块便构建依赖关系图。显然,整个图上的文件不是同时下载的 —— 因为构建依赖图必须是层层推进的,只有加载完了一个文件才能分析它的依赖,从而知道下一层的文件应该如何加载。
显然,主线程不应该停下来等待所有文件加载。而 ESModule 将加载模块分为了多个阶段,而不是像 CommonJS 那样一把梭则为这里的并行提供了依据:实例化和求值的阶段是同步的,需要已经有了整个模块依赖图,因此代码的运行必须在所有模块加载完成之后;但是加载模块环节的内部则完全可以是并行的。
正因为 ESModule 的模块加载和执行是完全分开的过程,所以在 CommonJS 里常用的动态导入的语句在 ESModule 下就不成立了:
这很好理解;ESModule 在加载模块的时候不知道任何运行期的值,自然无法通过一般的导入语句实现动态加载,但这不意味着动态导入没有意义。为了满足这种场景使用动态加载的需求,除了一般的 import
语句之外,ESModule 还引入了 import()
语法。但是这种导入又是怎么融合进我们刚才所说的加载-执行分开的体系呢?
答案是,任何 import()
加载的模块会被作为一张新的模块依赖关系图的入口;这张新的图的处理是单独进行的,有独立的三阶段。
这样,动态导入只是在入口模块所在的图完成构建并且执行的过程中,触发的新的加载模块任务罢了;新图的构造器当然可以知道旧图运行期的值。
完成了整张模块依赖关系图的构建之后,就该进行构造阶段最后的步骤 —— 解析;在解析阶段,引擎将之前已经获得但仍然未被解析的模块文件解析为模块记录并加入模块映射中。
在解析模块文件时,引擎的行为和解析一般的 JS 文件相比有以下不同:
- 总是按照严格模式(
"use strict"
)进行解析 - 关键字
await
在顶层代码(函数之外的代码)中被保留 this
的值是undefined
这都是 ESModule 规范所要求的;当整张依赖关系图上的所有的模块都已经被解析为模块记录并且加入模块映射后,就代表着构造阶段的结束。
实例化阶段
目前,引擎中的模块还是以模块记录的形式存在,距离模块实例还是相去甚远。而实例化阶段就是根据模块记录的信息为模块分配内存,并且创建导入导出符号之间的关联。
对于每一个模块记录而言,JS 引擎会创建它的“模块环境记录” —— 它包含了为所有导出变量和函数创建的内存,其中导出的函数这个阶段已经被初始化了,但是导出的值没有求值;接着将这些内存和导出的符号相关联,并且关联自身导入的符号和对应的内存。
对于整个模块依赖关系图,JS 引擎会执行深度优先后序遍历 —— 也就是它每次都尝试深入到依赖图的底部,找到一个没有任何依赖项的模块,然后再为它设置模块环境记录和导出内存。当完成了这些模块的实例化后,就会返回上一级模块(对下层模块有依赖的模块),将这些模块的导入和下层模块的导出(的内存块)相连接。
先设置导出在绑定导入可以保证每个导入都可以正确的连接到导出,而且这样的动态绑定保证了在求值之前就可以完成当前依赖关系图上所有模块导入导出的链接,这被称为“动态绑定”;这种处理导入导出的方法和 CommonJS 有着本质的不同:
CommonJS 只会会按照顺序执行模块,在遇到导入模块时将控制流转移到新的模块中;模块的执行产生的变量只会留在当前模块的内存中,导出的只是副本,模块内部后续的更新不能被依赖它的模块观察到;但是 ESModule 则是在执行前就确保了导入和导出的符号指向同一块内存,当模块内发生变动时,可以被所有依赖它的模块观察到。
求值阶段
已经为模块实例的状态分配了内存和链接,接下来要做的事情就是为这些内存赋值;JS 引擎通过执行顶层代码(除了函数之外的代码)来实现这一点。
但是这些顶层代码未必是无副作用的;正是由于这些可能的副作用,你只希望这些代码执行一次,毕竟副作用的存在可能使得多次执行这些代码会产生不同的结果;而模块依赖关系图/模块映射的存在就可以确保这一点。
和实例化阶段的链接过程一样,模块代码的执行也是按照深度优先后序遍历的顺序完成的。由于再执行之前已经建立了模块之间的动态绑定,所以即使出现了循环依赖也不应该出现什么问题;当然优秀的程序设计中不应该出现循环依赖就是。