阅读 2111

【翻译】ES modules:通过漫画进行深入理解

原文:ES modules: A cartoon deep-dive

ES modules 给 JavaScript 带来了一个官方的规范的模块化系统。将近花了10年的时间才完成了这个标准化的工作。

我们的等待即将结束。随着 Firefox 60 在今年5月的发布(目前是测试阶段),所有的主流浏览器都将支持 ES modules,与此同时,Node modules 工作小组目前正在尝试让 Node.js 能够支持 ES module。另外的,针对 WebAssembly 的 ES module 整合也正在进行。

众多 JS 开发者都知道 ES modules 至今已经饱受争议。但是很少有人真正知道 ES modules 到底是如何工作的。

让我们一起来看一下,ES modules 解决了什么问题,以及它究竟和其他模块化系统有什么区别。

模块化解决了什么问题?

当我们在写 JS 代码的时候会去思考一般如何处理变量。我们的操作几乎完全是为了给变量进行赋值或者是去将两个变量相加又或者是去将两个变量连接到一起并且将它们赋值给另外一个变量。

由于我们大部分的代码都仅仅是为了去改变变量的值,你如何去组织这些变量将对你写出怎样的代码以及如何更好的去维护这些代码产生巨大的影响。

一次只用处理少量的变量将会让我们的工作更容易。JS 本身提供了一种方法去帮助我们这么做,叫作 作用域。由于 JS 中作用域的原因,在每个函数中不能去使用其他函数中定义的变量。

这很棒!这意味着当你在一个函数中编码时,你只需要考虑当前这个函数了。你不必再担心其他函数可能会对你的变量做什么了。

虽然是这样没错,但是它也有不好的地方。这会让你很难去在不同的函数之间去共享变量。

假使你确实想要在作用域外去共享你的变量,将会怎么样呢?一个常用的做法是去将它们放在一个外层的作用域。举个例子来说,全局作用域。

你可能还记得下面这个在 jQuery 中的操作。在你加载 jQuery 之前,你不得不去把 jQuery 引入到全局作用域。

ok,可以正常运行了。但是这里存在相同的有争议的问题。

首先,你的 script 标签需要按正确的顺序摆放。然后你不得不非常的谨慎去确认没有人会去改变这个顺序。

如果你搞砸了这个顺序,然后你中间又使用到了前面的依赖,你的应用将会抛出一个错误~你函数将会四处查找 jQuery 在哪儿呢?在全局吗?然后,并没有找到它,它将会抛出一个错误然后你的应用就挂掉了。

这将会让你的代码维护变得非常困难。这会使你在删除代码或者删除 script 标签的时候就像在摇色子一样。你并不知道这个什么时候会崩溃。不同代码之间的依赖关系也不够明显。任何的函数都能够使用在全局的东西,所以你不知道哪些函数会依赖哪些 script 文件。

第二个问题是因为这些变量存在于全局作用域,所有的代码都存在于全局作用域内,并且可以去修改这些变量。可能是去让这些变量变成恶意代码,从而故意执行非你本意的代码,还有可能是变成非恶意的代码但是和你的变量有冲突。

模块化是如何帮助我们的?

模块化给你了一个方式去组织这些变量和函数。通过模块化,你可以把变量和函数合理的进行分组归类。

它把这些函数和变量放在一个模块的作用域内。这个模块的作用域能够让其中的函数一起分享变量。

但是不像函数的作用域,模块的作用域有一种方式去让它们的变量能过被其他模块所用。它们能够明确的安排其中哪些变量、类或者函数可以被其他模块使用。

当某些东西被设置成能被其他模块使用的时候,我需要一个叫做 export 的函数。一旦你使用了这个 export 函数,其他的模块就明确的知道它们依赖于哪些变量、类或者函数。

因为这是一个明确的关系。一旦你想移除一个模块时,你可以知道哪一个模块将会被影响。

当你能够去使用 export 和 import 去处理不同模块之间的变量时,你将会很容易的将你的代码分成一些小的部分,它们之间彼此独立的运行。然后你可以组合或者重组这些部分,就像乐高积木一样,去在不同的应用中引用这些公用的模块。

由于模块化真的非常有用,所以这里有很多尝试去在 JS 中添加一些实用的模块。时至今日,有两个比较常用的模块化系统。一个是 Node.js 一直以来使用的 CommonJS。还有一个是晚一些但是专门为 JS 设计的 ES modules。浏览器端已经支持 ES modules,与此同时,Node 端正在尝试去支持。

让我们一起来深入了解一下,这个新的模块化系统到底是如何进行工作的。

ES modules 是如何工作的?

当你在开发这些模块时,你建立了一个图。

浏览器或者 Node 是通过这些引入声明,才明确的知道你需要加载哪些代码。你需要创建一个文件作为这个依赖关系的入口。之后就会根据那些 import 声明去查找剩余的代码。

但是这些文件不能直接被浏览器所用,这些文件会被解析成叫做模块记录的数据结构。

之后,这个模块记录将会被转变成一个模块实例。一个模块实例是由两部分组成:代码和状态。

代码是这一列指令的基础。它就像该如何去做的引导。但是只凭它你并不能做什么。你需要材料才能够去使用这些引导。

什么是状态?状态给你提供了材料!在任何时候,状态都会为你提供这些变量真实的值。当然这些变量都仅仅只是作为内存中存储这些值的别名而已(引用)。

模块实例将代码(一系列的引导)和状态组合起来(所有变量在内存中的值)。

我们需要的是每个模块拥有自己的模块实例。模块的加载过程是通过入口文件,找到整个模块实例的关系表。

对于 ES modules 来说,这个过程需要三步:

  1. 构建——查找、下载以及将所有文件解析进入模块记录。
  2. 实例化——查找暴露出的值应该放在内存中的哪个位置(但是不会给它们填充值),然后在内存中创建 exports 和 imports 应该存在的地方。这被称作链接。
  3. 求值——运行代码,把内存中的变量赋予真实的值。

人们都说 ES modules 是异步的。你完全可以将它想成异步的,因为整个流程被分成三个不同的阶段——加载,实例化以及求值——还有,这些步骤都是被分开执行的。

这就意味着,这个规则是一种异步的而且不从属于 CommonJS。我将在稍后解释它,在 CommonJS 中,一个模块的依赖是在模块加载之后才立刻进行加载、实例化、求值的,中间不会有任何的打断(也就是同步)。

无论如何,这些步骤本身并不一定是异步的。它们可以被同步处理。这就依赖于加载的过程取决于什么?那是因为并不是所有的东西都尊崇于 ES modules 规范。这其实是两部分工作,从属于不同的规范。

ES module 规范阐述了你应该如何将这些文件解析成模块记录,以及你应该如何去实例化和进行求值。但是,它没有说明如何去首先获得这些文件。

获取这些文件有相应的加载器,在不同的说明中,加载器都被明确定义了。对于浏览器,它的规范是HTML spec。但是你可以在不同平台使用不同的加载器。

加载器同样明确指出了控制模块应该如何被加载。这被称作 ES 模块方法 —— ParseModule,Module.Instantiate,以及Module.Evaluate。这就像JS 引擎操纵的木偶一样。

现在我们来一起探寻每一步到底发生了什么。

构建

构建阶段每一个模块发生了三件事。

  1. 判断应该从何处下载文件所包含的模块(又叫模块解决方案)。
  2. 获取文件(通过 url 下载 或者 通过文件系统加载)
  3. 将文件解析进模块记录
查找到文件然后获取到它

加载器将会尽可能的去找到文件然后去下载它。首先要去找到入口文件。在 HTML 中,你应该通过 script 标签告诉加载器入口文件在哪。

但是你应该如何查找到下一个模块化文件呢——那些 main.js 直接依赖的模块?

这个时候 import 声明就登场了,import 声明中有一部分叫做模块声明,它告诉了加载器可以在依次找到下一个模块。

关于模块声明有一点需要注意的是:在浏览器端和 Node 端有不同的处理方式。每一个宿主环境有它自己的方法去解释用来模块声明的字符串。为了完成这个,模块声明使用了一种叫做模块解释的算法去区分不同的宿主环境。目前来说,一些能在 Node 端运行的模块声明方法并不能在浏览器端执行,但是我们有为了修复这个而在做的事情

除非等到这个问题被修复,浏览器只能接受 URLs 作为模块声明。它们将从这个 URL 去加载这个模块文件。但是对于整个图而言,这并不是一个同步行为。你无法知道哪一个依赖你需要去获取直到你把整个文件都解析完成。以及你只有等获取到文件才能开始解析它。

这就意味着我们必须去解析这个文件通过一层一层的解析这个依赖关系。然后查明所有的依赖关系,最后找到并且加载这些依赖。

如果主线程在等待每一个文件下载,那么其他的任务将会排在主线程事件队列的后面。

持续的阻塞主线程就会像这样让你的应用在使用这些模块时变得非常的慢。这就是 ES modules 规范将这个算法拆分到多个阶段任务的原因之一。在进行实例化之前把它的构建拆分到它自己的阶段然后允许浏览器去获取文件和理清依赖关系表。

ES modules 和 CommonJS modules 之间的区别之一就是将模块声明算法拆分到各个阶段去执行。

CommonJS 能够比 ES modules 的不同是,通过文件系统去加载文件,要比从网上下载文件要花的时间少得多。这就意味着,Node 将会阻塞主线程当它正在加载文件的时候。只要文件加载完成,它就会去实例化并且去做求值操作(这也就是 CommonJS 不会在各个独立阶段去做的原因)。这同样说明了,当你在返回模块实例之前,你就会遍历整个依赖关系树然后去完成加载、实例化以及对各个依赖进行求值的操作。

CommonJS 带来的一些影响,我会在稍后做更多的解释。在使用 CommonJS 的 Node 中你可以去使用变量进行模块声明。在你查找下一个模块之前,你将执行完这个模块所有的代码(直到通过require去返回这个声明)。这就意味着你的这些变量将会在你去处理模块解析时被赋值。

但是在 ES modules 中,你将在执行模块解析和进行求值操作前就建立好整个模块依赖关系图表。这也就是说在你的模块声明时,你不能去使用这些变量,因为这些变量那时还并没有被赋值。

但是有的时候我们有非常需要去使用变量作为模块声明,举个例子,你可能会存在的一种情况是需要根据代码的执行效果来决定你需要引入哪个模块。

为了能在 ES modules 这么去做,于是就存在一种叫做动态引入的提议。就像这样,你可以像这样去做引入声明import(`${path}/foo.js`)

这种通过import()去加载任意文件的方法是把它作为每一个单独的依赖图表的入口。这种动态引入模块会开始一个新的被单独处理的图。

即使如此,有一点要注意的是,对于任意模块而言所有的这些图都共享同一个模块实例。这是因为加载器会缓存这些模块实例。对于每一个模块而言都存在于一个特殊的作用域内,这里面仅仅只会存在一个模块实例。

显然,这会减少引擎的工作量。举个例子,目标模块文件只会被加载一次即使此时有多个模块文件都依赖于它。(这就是缓存模块的原因,我们将看到的只是另一次的求值过程而已)

加载器是通过一个叫做模块映射集合的东西来管理这个缓存。每一个全局作用域通过栈来保存这些独立的模块映射集合。

当加载器准备去获取一个 URL 的时候,它会将这个 URL 放入模块映射中,然后对当前正在获取的文件做一个标记。然后它将发送一个请求(状态为 fetching),紧接着开始准备开始获取下一个文件。

<img src="http://o8gh1m5pi.bkt.clouddn.com/18-4-15/64202072.jpg"/ height="300px">

那当其他模块也依赖这个同样的文件时会发生什么呢?加载器将会在模块映射集合中去遍历这个 URL,如果它发现这个文件正在被获取,那么加载器会直接查找下一个 URL 去。

但是模块映射集合并不会去保存已经被获取过的文件的栈。接下来我们会看到,模块映射集合对于模块而言同样也会被作为一个缓存。

解析

现在我们已经获取到了这个文件,我们需要将它解析为一条模块记录。这会帮助浏览器知道这些模块不一样的部分。

一旦这条模块记录被创建,它将会被放置到模块映射集合内。这就意味着,无论何时它在这被请求,加载器都会从映射集合中录取它。

在编译过程中有一个看似微不足道的细节,但是它却有着重大的影响。所有的模块被解析后都会被当做在顶部有use strict。还有另外两个细节。用例子来说明吧,await关键词会被预先储备到模块代码的最顶部,以及顶级作用域中thisundefined

这种不同的解析方式被称作“解析目标”。如果你解析相同的文件,但是目标不同,你将会得到不同的结果。因此,在开始解析你要解析的文件类型之前,你需要知道它是否是一个模块。

在浏览器中,这将非常的简单,你只需要在 script 标签中设置type="module"。这就会高速浏览器,这个文件将被当做模块进行解析。以及只有模块才能被引用,浏览器知道任意引入都是模块。

但是在 Node 端,你不会使用到 HTML 标签,所以你没办法去使用type属性。社区为此想出了一个解决办法,对于这类文件使用了mjs的扩展名。通过这个扩展名告诉 Node,“这是一个模块”。你可以看出人们把这个视为解析目标的信号。这个讨论仍在进行中,现在还不清楚最后 Node 社区会采用哪种信号。

无论哪种方式,加载器将会决定是否将一个文件当做模块去处理。如果这是一个模块并且存在引用,那么它将会再次进行刚才的过程,直到所有的文件都被获取到,解析完。

下一步就是将这个模块实例化并且将所有的实例链接起来。

实例化

就像我之前所说的,一个实例是由代码和状态结合起来的。状态存在于内存中,所以实例化的步骤其实是将所有的内容连接到内存中。

首先,JS 引擎会创建一条模块环境的记录。它会为这条模块记录管理变量。然后它在内存中的相关区域找到所有导出的值。这条模块环境记录将会跟踪内存中与每个导出相关联的区域。

直到进行求值操作的时候这些内存区域才会被填充真实的值。对于这个规则,有一条警告:所有被导出的函数声明将会在这个阶段被初始化。这将会让求值过程变得更容易。

在实例化模块的过程,引擎将会采用深度优先后续遍历的算法。意思就是引擎一直往下直到图的最底部——也就是依赖关系的最底部(不依赖于其它了),然后才会去设置它们的导出值。

引擎完成了这个模块下所有导出的串联——模块依赖的所有导出。然后它就会返回顶部然后将这个模块所有的引入串联起来。

要注意的是导出和引入在内存中同一块区域。将所有导出都串联起来的前提是保证所有的引用能和与它对应的导出匹配(译者注:这也说明了 ES mdules 中的 import 属于引用)。

这不同于 CommonJS 的模块化。在 CommonJS 中整个导出的对象是导出的一个复制。这就意味着,所有的值(比方说数字)都是导出值的复制。

这同时也说明,导出的模块如果在之后发生改变,那个引入该模块的模块并不会发现这个改变。

与此完全相反的是,ES modules 使用的是活跃绑定,所有的模块引入和导出的全是指向相同的内存区域。意思就是说,一旦当模块被导出的值发生了改变,那么引入该模块的模块也会受到影响。

模块本身可以对导出的值做出变化,但是去引入它们的模块禁止去对这些值进行修改。话虽如此,但是如果一个模块引入的是一个对象,是可以去修改这个对象上的值的。

使用活跃绑定的原因是,你可以将所有的模块串联起来,而不需要执行任何的代码。这将有助于你去使用我接下来要讲的循环依赖。

在这一步的最后,我们已经成功对模块进行了实例化并且将内存中引入和导出的值串联起来。

现在,我们可以开始对代码进行求值并且给它们在内存中的值进行赋值。

求值操作

最后一步是对内存中的相关区域进行填充。JS 引擎是通过执行顶层代码去完成这件事的——在函数外的代码。

除了对内存中相关进行填充外,对代码进行求值也会造成副作用。比如说,模块可能会去调用一个服务。

由于潜在的副作用,你只需要对模块进行一次求值。与发生实例化时产生的链接不同,在这里相同的结果可以被多次使用。求值的结果也会随着你求值次数的不同而产生不同的结果。

这就是我们去使用模块映射集合的原因。模块映射集合缓存规范的 URL ,所以每一个模块只存在一条对应的模块记录。这就保证了每一个模块只被执行一次。和实例化的过程一样,它同样采用的是深度优先后序遍历的方法。

那么,我们之前谈到的循环依赖呢?

在循环依赖中,你最终在图中是一个循环。通常来说,这是一个比较长的循环。但是为了去解释这个问题,我将只会人为的去设计一个较短的循环去举个例子。

让我们来看看在 CommonJS 的模块中是如何做的,首先,那个 main 模块会执行 require 声明。然后就去加载 counter 模块。

这个 counter 模块将会从导出的模块中去尝试获取 message,但是它在 main 模块中还并没有被求值,于是它会返回 undefined。JS 引擎将会在内存中为它分配一个空间,然后将其赋值为 undefined。

求值操作会一直持续到 counter 模块顶层代码的末尾。我们想知道最后是否能够得到 message 的值(在 main.js 进行求值操作之后),于是我们设置一个 timeout, 然后对 main.js 进行求值。

message 这个变量将会被初始化并且被添加到内存中去。但是这两者并没有任何关系,它仍在被 require 的模块中是 undefined。

如果导出的值被活跃绑定处理,counter 模块将在最后得到正确的值。当 timeout 被执行的时候,main.js 的求值操作已经被完成而且内存中的区域也被填充了真实的值。

去支持循环依赖是 ES modules去这么设计的原因之一。正是这三个阶段让这一切变得可能。

ES modules 现在是什么状态?

随着 Firefox 60 在今年五月早期发布,所有的主流浏览器都将默认支持 ES modules。Node 也将会支持这种方式,工作组正在尝试去让 CommonJS 和 ES modules 进行兼容。

这就意味着你将可以去使用 script 标签 加上type=module,去使用引入和导出。无论如何,越来越多的模块特性将会可以使用。动态引入的提案已经明确进入 Stage 3 阶段,同时import.meta提案将会让 Node.js 支持这种写法。[解决模块问题的提案](module resolution proposal)也将平滑的同时支持浏览器和 Node.js。所以你们期待一下未来模块化的工作会做的越来越好。 翻译原文