ES Modules
是浏览器原生支持的模块系统。而在之前,常用的是CommonJS
和基于 AMD
的其他模块系统 如 RequireJS
。
来看下目前浏览器对其的支持:
主流的浏览器(IE11 除外)均已经支持,其最大的特点是在浏览器端使用 export
、 import
的方式导入和导出模块,在 script
标签里设置 type="module"
,然后使用模块内容:
import a from 'xxx'
import b from 'xxx'
import c from 'xxx'
ES Modules如何工作
当我们使用ES Modules
进行开发时,实际上是在构建一个依赖关系图。不同依赖项之间通过导入语句来进行关联。
浏览器或 Node通过这些import语句判断加载哪些代码。从入口文件开始查找其余问价代码。
但是浏览器不可以直接使用文件本身。需要将这些文件转换为模块记录。
之后,需要将模块记录转化为模块实例。
模块实例包含两部分:代码和状态。
我们需要的就是最后生成的模块实例。
模块的加载
模块加载的过程就是从入口文件到拥有一个完整的模块实例图的过程。
对于 ES 模块来说,分三步进行。
构造
——查找、下载并解析所有文件到模块记录中
实例化
——在内存中寻找一块区域来存储所有导出的变量(但还没有填充值)。然后让 export 和 import 都指向这些内存块。这个过程叫做链接(linking)
求值
—,在内存块中填入变量的实际值。
构造阶段(Construction)
在构造阶段,每个模块都会经历三件事情:
Find:找出从哪里下载包含该模块的文件(也称为模块解析)
Download:获取文件(从 URL 下载或从文件系统加载)
Parse:将文件解析为模块记录
Find 查找
通常我们会有一个入口文件main.js:
然后通过如下代码去寻找与之关联的其他模块。
mport 语句中的一部分称为 Module Specifier。它告诉 Loader 在哪里可以找到引入的模块。
关于模块标识符有一点需要注意:它们有时需要在浏览器和Node之间进行不同的处理。每个宿主都有自己的解释模块标识符字符串的方式。
目前在浏览器中只能使用 URL 作为 Module Specifier,也就是使用 URL 去加载模块。
Download 下载
而有个问题也随之而来,浏览器在解析文件前并不知道文件依赖哪些模块,当然获取文件之前更无法解析文件。这意味着我们必须一层一层地遍历树,解析一个文件,然后找出它的依赖项,然后找到并加载这些依赖项,如下,一层层的去解析。
如果主线程要等待这些文件中的每一个下载,那么许多其他任务将堆积在其队列中。
这将导致整个解析依赖关系的流程是阻塞的。
像这样阻塞主线程会让采用了模块的应用程序速度太慢而无法使用。这是 ES
模块规范将算法分为多个阶段的原因之一。将构造过程单独分离出来,使得浏览器在执行同步的初始化过程前可以自行下载文件并建立自己对于模块图的理解。
但是在commonJs中,加载文件是直接从系统中加载的,这比从浏览器下载快很多。因为Node使用commonJs,所以这也造成了其在加载过程中可能会阻塞主线程。
因为commonJs是同步的,值执行之前,可以知道变量的值,所以在模块标识符中可以使用变量。
但对于 ES
模块,先构建整个模块图在前,此时还未进行求值。所以模块标识符中不能有变量,因为这些变量还没有值。
但有时候在模块路径使用变量确实非常有用。例如,你可能需要根据代码的运行情况或运行环境来切换加载某个模块。
为了让 ES
模块支持这个,有一个名为 动态导入
的提案。有了它,你可以像 import(${path} /foo.js
这样使用 import
语句。
它的原理是,任何通过 import()
加载的的文件都会被作为一个独立的依赖图的入口。动态导入的模块开启一个新的依赖图,并单独处理。
不过需要注意的一点是——这两个图中的任何模块都将共享一个模块实例。这是因为加载器缓存了模块实例。对于特定全局范围内的每个模块,将只有一个模块实例。
Parse 解析
我们把它解析出来的模块构成表 称为 Module Record
模块记录
模块记录包含了当前模块的 AST
,引用了哪些模块的变量,以前一些特定属性和方法。
一旦模块记录被创建,它会被记录在模块映射Module Map
中。被记录后,如果再有对相同 URL
的请求,Loader
将直接采用 Module Map
中 URL
对应的Module Record
。
解析中有一个细节可能看起来微不足道,但实际上有很大的影响。所有的模块都被当作在顶部使用了 "use strict"
来解析。还有一些其他细微差别。例如,关键字 await
保留在模块的顶层代码中,this
的值是 undefined
。
这种不同的解析方式被称为解析目标
。如果你使用不同的目标解析相同的文件,你会得到不同的结果。所以在开始解析前你要知道正在解析的文件的类型:它是否是一个模块?
在浏览器中只需在 script
标记中设置 type="module"
,告诉浏览器此文件应该被解析为一个模块。
但在 Node
中,是没有 HTML
标签的,所以需要其他的方式来辨别,社区目前的主流解决方式是修改文件的后缀为 .mjs
,来告诉 Node
这将是一个模块。不过还没有标准化,而且还存在很多兼容问题。
到这里,在加载过程结束时,从普通的主入口文件变成了一堆模块记录Module Record
。
下一步是实例化此模块并将所有实例链接在一起。
实例化阶段
就像我之前提到的,实例结合了代码和状态。该状态存在于内存中,因此实例化步骤就是写入内存。
首先,JS引擎创建一个模块环境记录来管理模块记录的变量。然后它在内存中找到所有导出内容对应的位置。模块环境记录将跟踪内存中导出内容对应的位置与导出内容间的联系。
此时内存中的这些位置中还不会存放值,只有在计算后才会有值。此规则有一个警告:此阶段初始化所有导出的函数声明。
为了实例化模块图,引擎将执行深度优先后序遍历。这意味着它将深入到图的底部——因为底部不依赖任何其他东西——并设置它们的导出。
请注意,导出和导入都指向内存中的同一位置。首先连接导出可确保所有导入都可以连接到匹配的导出。
ES Modules 的这种连接方式被称为 Live Bindings(动态绑定)
这与 CommonJS 模块不同。在 CommonJS 中,整个导出对象在导出时被复制。这意味着导出的任何值(如数字)都是副本。
这意味着如果导出模块稍后更改该值,则导入模块不会看到该更改。
相反,ES 模块使用称为实时绑定的东西。两个模块都指向内存中的相同位置。这意味着当导出模块更改值时,该更改将显示在导入模块中。导出值的模块可以随时更改这些值,但导入模块不能更改其导入的值。话虽如此,如果一个模块导入一个对象,它可以更改该对象上的属性值。
拥有这样的实时绑定可以使我们在不运行任何代码的情况下连接所有模块。。
所以在这一步结束时,我们已经连接了导出/导入变量的所有实例和内存位置。
求值阶段(evaluate)
最后一步是在内存区中填充这些值。JS 引擎通过执行顶层代码(函数之外的代码)来实现这一点。
Evaluation代码还会引发副作用。例如,模块可能会调用服务器。
由于潜在的副作用,您只想Evaluation模块一次。与实例化中发生的链接相反,链接可以多次执行并获得完全相同的结果,Evaluation可能会产生不同的结果,具体取决于您执行的次数。
这是拥有模块映射( module map)的原因之一。模块映射通过规范 URL 缓存模块,以便每个模块只有一个模块记录。这确保每个模块只执行一次。就像实例化一样,这是作为深度优先后序遍历完成的。
我们之前谈到的那些循环依赖周期呢?
在循环依赖中,最终会在图中出现一个循环。通常,这是一个长循环。但是为了解释这个问题,我将使用一个带有短循环的人为示例。
让我们看看这将如何与 CommonJS 模块一起工作。首先,主模块将执行到 require 语句。然后它会去加载计数器模块。
然后计数器模块将尝试message
从导出对象进行访问。但是由于这还没有在主模块中进行评估,这将返回 undefined。JS 引擎会在内存中为局部变量分配空间并将值设置为 undefined。
评估一直持续到计数器模块的顶层代码结束。我们想看看我们最终是否会得到 message 的正确值(在 main.js 评估之后),所以我们设置了一个超时。然后评估在 上继续main.js
。
消息变量将被初始化并添加到内存中。但由于两者之间没有联系,它将在所需的模块中保持undefined状态。
如果导出是使用实时绑定(live bindings)处理的,计数器模块最终会看到正确的值。到超时运行时,main.js
的评估将完成并填充值。支持这些循环是 ES 模块设计背后的一个重要基本原理。
参考:https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/