参考: ES modules: A cartoon deep-diveEs modules spec
Es modules 原理
当有一个入口文件长这样:
// main.js entry
import {A} from './a.js'
import {B} ffrom './b.js'
浏览器拿到具体的某个文件也没办法,需要将它转化成更能理解的 模块记录(Module record)
模块记录接下来会被转化成模块实例,一个模块实例由 代码 和 状态组成。 代码 就是 原生的代码, 状态 是 在当前某个时刻下变量具体的值。
我们要做的就是将每一个模块文件转换为一个模块实例。模块加载的过程就是从入口文件开始,经过一系列的引入编程一个 模块实例的依赖图。 对于 一个 Es modules, 一共经历 3 步。
- 构造阶段(Construction)— 找到并下载文件,将文件转化为模块记录
- 实例化阶段(Instantiation)— 找到存储变量的内存映射表,将导入和导出指向内存中的块,这个步骤称为链接(linking)
- 计算(Evaluation) — 运行代码 计算变量实际的值并填充
可以这样说 es modules 是异步的, 因为这种模块化的加载方式可以拆分为 构造、实例化、计算 三个步骤, 这三个阶段都能够分开进行。 commonjs 不同的是,所有的依赖都会一次性加载、实例化和计算,中间不能中断。
es modules 的特性决定了怎么将将文件转换为模块记录、怎么构造和实例化和计算,但是怎么加载文件是由 loader 决定的,在浏览器中就是通过 HTML 加载器加载的,这在不同的平台会有所区别。
接下来具体介绍 构造、实例化、再计算的具体过程。
构造
构造阶段包含三个步骤:
- 找到模块文件下载地址
- 根据 URL 下载文件或者从文件系统加载
- 将文件转化为 模块记录
找到文件并加载
loader 会帮你找到文件并下载。首先会从入口文件开始,在 HTML 中,通过使用脚本标记告诉加载程序在哪里找到它。
<script src='main.js' type='module'>
但是它如何找到下一组模块呢 — main.js 直接依赖于哪些模块?
这就是导入语句的用武之地。import 语句的一部分称为模块说明符。它告诉加载程序在哪里可以找到下一个模块。 关于模块说明符号,HTML 和 node 等不同平台表现不太一致。 在修复之前,浏览器只接受 URL 作为模块说明符。他们将从该 URL 加载模块文件。但这不会同时发生在整个图形中。在解析文件之前,您不知道模块需要您获取哪些依赖项……在获取文件之前,您无法解析文件。
这意味着我们必须逐层遍历树,解析一个文件,然后找出它的依赖项,然后找到并加载这些依赖项。 如果主线程等待这些文件下载,那么其他的任务将会被压入队列。 在浏览器中加载资源会占用比较长的时间。
阻塞主线程将会使那些使用模块来构建的 app 运行很慢。这也是 ES 模块规范将实现算法划分成多个阶段的原因之一。将构建阶段单独划分出来允许浏览器在处理同步的实例化阶段之前就能够下载文件并且构建模块依赖图。
拆分成多个阶段的算法是 CommonJs 和 Esm 的主要区别之一。
因为从文件系统中加载文件速度很快,CommonJs 可以在加载文件的时候就进行实例化和计算操作,同时也意味着在你 return 一个模块实例的同时,已经访问完整个依赖树,并实例完和计算完所有的依赖。
因为上述这些特性使得我们的在 CommonJs 中是可以在模块引入中使用变量的。 就像下面的例子:
当你执行到 require 语句时,你已经完成了上面所有依赖的实例和计算,这时的变量是有值的。
但对于 ES 模块,在进行任何计算之前,需要事先构建整个模块图。这意味着模块说明符中不能有变量,因为这些变量还没有值。
使用变量引入是非常必要的,因为你常常会想要根据环境加载不同的依赖。
转化
现在我们已经获取到了这个文件,我们需要将它解析为一条模块记录。这会帮助浏览器知道这些模块不一样的部分。 一旦这条模块记录被创建,它将会被放置到模块映射集合内。这就意味着,无论何时它在这被请求,加载器都会从映射集合中录取它。
在编译过程中有一个看似微不足道的细节,但是它却有着重大的影响。所有的模块被解析后都会被当做在顶部有 use strict。还有另外两个细节。用例子来说明吧,await 关键词会被预先储备到模块代码的最顶部,以及顶级作用域中 this 是 undefined。 这种不同的解析方式被称作“解析目标”。如果你解析相同的文件,但是目标不同,你将会得到不同的结果。因此,在开始解析你要解析的文件类型之前,你需要知道它是否是一个模块。 在浏览器中,这将非常的简单,你只需要在 script 标签中设置 type="module"。这就会告诉浏览器,这个文件将被当做模块进行解析。以及只有模块才能被引用,浏览器知道任意引入都是模块。
但是在 Node 端,你不会使用到 HTML 标签,所以你没办法去使用 type 属性。社区为此想出了一个解决办法,对于这类文件使用了 mjs 的扩展名。通过这个扩展名告诉 Node,“这是一个模块”。你可以看出人们把这个视为解析目标的信号。这个讨论仍在进行中,现在还不清楚最后 Node 社区会采用哪种信号。 无论哪种方式,加载器将会决定是否将一个文件当做模块去处理。如果这是一个模块并且存在引用,那么它将会再次进行刚才的过程,直到所有的文件都被获取到,解析完。
实例化
像我刚才提到的,一个实例由代码和状态构成。状态存在于于内存,所以整个实例化的步骤是关于如何与内存建立联系的。
首先,将 JS 引擎创立一个模块环境记录。这管理了模块记录要用的变量,然后找到所有导出对应的内存盒子。模块环境记录就会持续跟踪这些与各处导出相关的内存盒块中。
这些内存块在后面的计算完成之前并不会赋值。有一点要注意的就是: 所有的方法申明都是在这个阶段初始化。这会方便后边的计算阶段。
在实例化一个模块图的时候,引擎会做一个深度优先后序遍历。也就是说,引擎会先遍历到图的底部节点,先对没有其他依赖的模块进行实例化。
当引擎处理完一个模块所有的导出(exports)之后,就会重新回到顶层去处理所有的引入(imports)
要注意的是导入和导出都指向内存中的同一个位置。将导出在内存中绑定的前提是所有的导入都能找到对应的导出。
这里和 CommonJs 是有很大区别的。在 CommonJs 里面,整个导出的对象都是导出的一份拷贝,所有的值都是备份的。如果导出的模块改变了某一个值,引入的模块是不能感知到变化(存储的还是旧值)
但是在 Es module 里,使用的是 live bindings。 所有的模块都指向内存中的同一位置。当导出的模块的值发生改变,引入的模块该值也会发生变化。
**导出值的模可以在任何时候改变这个值,引入该值的模块却不能改变这个值。也就是说,如果一个模块引入可一个对象,不能改变这个对象的引用,但是却可以改变对象的属性。
使用 Live binding 的原因是,可以不用运行代码就将变量绑定内存。这能够帮助处理计算阶段的循环依赖,接下来将会提到。
完成这个阶段之后,我们将能够得到模块的实例和与内存关联的导入和导出的变量。
计算
在计算阶段,主要是将实例化步骤中建立关联的内存块赋值。 js 引擎将会执行顶部的代码——除了函数声明之外的代码。
除了为内存块赋值,计算阶段还会执行一些副作用代码。比如,一个模块可能会发送 http 请求向 server 保存数据。
因为副作用可能会有潜在的影响,所以不会想要一个模块执行多次计算阶段。尽管在实例化阶段,可能会自顶向下执行多次,但是得到的结果会是一样的,而计算阶段,只会执行一次,副作用也会因为时机不同,得到不同的结果。
这就是引入 module map 的原因,module map 能够根据 URL 缓存模块,保证一个模块只有一个模块记录。
循环依赖是怎么解决的呢?
在循环依赖中,你遇到的通常是比较大的圈。为了方便解释问题,我们使用一个简化的例子。
来看看 CommonJs 是怎么解决的,首先 main 会执行 require 语句,然后加载 counter 模块。 counter 模块尝试去获取 message 的值,因为 message 还没有在 main 中计算赋值,所以这时的值是 undefined.
下面的计时器开始注册,main 模块开始解析,message 被赋值,但是因为是拷贝的原因,计时器执行获得的 message 还是 undefined . 而因为 Es module 的热绑定,计时器注册时 message 是 undefind, 但是执行时是被赋值的。
es 模块的当前状态
随着 Firefox 60 在五月早期的发布,所有主流浏览器都将默认支持 ES 模块了。Node 也添加了对其的支持,并且有一个致力于解决 CommonJS 和 ES 模块之间的兼容性的问题的工作小组在不断地努力。 这意味着,你将可以使用 type=module 的方式来使用模块的导入和导出。然而,更多的模块特性也即将到来。处于 Stage 3 的提案 dynamic import 也在具体的进程中。import.
核心问题
- CommonJS 和 Es module 如何解决循环依赖的?