January 16, 2019

【译】ES modules: A cartoon deep-dive

JavaScript ES module模块原理,以及和CommonJS规范差异。

【译】ES modules: A cartoon deep-dive

翻译自文章:ES modules  A cartoon deep-dive


尽管花了10年之久的时间,JavaScript还是有了官方,标准的模块系统:Es modules

等待快要结束了,随着Firefox 60的发布,所有的主流浏览器都支持Es modules了,而且Node的模块工作组也在开始在Node.js中支持Es modules的工作了(译注: 已经部分支持),而且Es modeule对WebAssembly的支持也已经在计划当中了。

许多JavaScript开发者都知道颇具争议的Es modules,但是很少人真正知道它是如何工作的。

下文让我们看看Es modules解决了什么问题,以及它和其他模块系统的不同之处。

模块解决了什么问题?

当我们说JavaScript编码时,讲的几乎是变量的管理。不外乎是变量的赋值,对变量增加数值,或者将两个变量加起来赋值给另外的变量。

01_variables-768x273

大量的代码都是变量的改变,因此如何组织这些变量直接影响你编码的质量甚至也影响你能否更好的维护代码。

如果只是少量的变量,JavaScript中的scope就可以一劳永逸的解决好这个问题。这个得益于scope的工作方式,函数并不能访问其他函数中的变量。

02_module_scope_01-768x448

这点很好,你在一个函数中编码时候,你不担心其他的函数会操作你的变量。

但它也有不好的一面,这样就很难在函数间共享变量。

如果你确实需要在scope之外共享变量呢? 通常的做法是将其放到你的上一层……,譬如全局的global scope中。

你可能还记得,在jQuery时代,在你加载jQuery 插件之前,你得确保jQuery是在global scope中。

02_module_scope_02-768x691

这样也行,但还是会有些让你恼火的问题。

首先,你所有的script标签都得保证正确的顺序,得保证任何一个不能把顺序弄错了。

一旦你弄错了顺序,你的app就会在运行时候报错。当函数在global scope中拿不到期望的jQuery变量时候就会报错,然后停止运行。

02_module_scope_03-768x691

这样就会让代码维护变得很困难。移除旧代码就像玩轮盘游戏一样,你永远不知道什么时候会出错。不同模块间的依赖也模糊不清。任何函数都可以操作global scope,你也就不知道哪个函数依赖哪个脚本。

另一个问题是,global scope之内的任何代码都能改变其中的变量,恶意代码会让不怀好意的更改某些变量,或者甚至正常代码也可能会意外改变了某些变量。

modules将会如何解决这些问题?

模块是一个种更好管理这些变量和函数的方法。你可以将相关的变量和函数使用模块来组织到一起。

这样就将变量和函数放到了模块scope中。其中的函数就可以在模块scope共享变量。

和函数scope不一样,模块将其中的变量分享给其他模块,而且模块可以决定分享哪些变量,类,或者函数。

这种分享叫做导出export。一旦导出了,其他模块就可以生命自己依赖这个模块中的哪个变量,类或者函数。

02_module_scope_04-768x691

如此精确的关系,当你删除了一方,自然很容易知道哪些地方会出错。

当你可以在模块间导入和导出变量的时候,你就可以更容易地将代码分成彼此独立的小块。然后你就可以像乐高一样,同样的模块集组合出各种不同的应用。

模块很有用,也有多次给JavaScript添加模块的尝试。现在还在使用还有两个。Node.js一直以来使用的CommonJS(CJS),以及最近才被添加到JavaScript标准中的ESM(EcmaScript modules),浏览器已经支持ESM了,Node.js也在支持之中。

来让我们深入了解es模块的工作方式。

ES模块工作方式

当你使用模块开发的时候,你会建立一个依赖图,其中不同依赖之间的连接就来自于你所使用的导入声明。

这些导入声明就是让浏览器或者Node知道需要加载哪些代码。你给定一个文件作为依赖图的入口,浏览器就会按照导入声明来依次加载代码。

04_import_graph-768x447

但是浏览器不会使用文件本身。它需要将所有的文件解析为一种叫做模块记录Module Records的数据结构才能让浏览器了解文件内容。

05_module_record-768x441

然后,模块记录需要转变为模块实例。一个实例结合了两个东西:规则code和状态state

规则code就是指令的集合。它就像是菜谱一样。但是对它本身来说,你需要新鲜的材料才能有用。

那么什么是状态state呢?状态state就提供的新鲜材料。状态state就是变量在任意时刻的值。当然,变量实际上是内存中持有值的盒子的昵称。

因此模块实例就是规则(指令集合)和状态(所有的变量的值)的结合。

06_module_instance-768x572

我们所需要的是每个模块的模块实例。那么模块加载就是从入口到得到全图模块实例的过程。

对于Es modules模块来说,会有三个步骤。

  1. 构建 : 查找,下载,并且解析所有文件到模块记录
  2. 实例化 : 找到内存中能够放置所有导出值的盒子(暂时先不要填充值)。然后将导出和导入指向内存中的这些盒子。这步叫做连接。
  3. 执行 : 执行代码然后将变量的真实值填入盒子。

07_3_phases-768x282

一般说Es modules是异步的。你可以将这个异步理解为它的工作非常了这三个步骤——加载,实例化,执行,而且这些阶段是可以单独分开完成。

这也意味着,上述的这种异步特性是CommonJS(CJS)所没有的(下文解释),在一个CJS模块中,加载,实例化,执行是一次完成的,中间没有停顿。

然而,这些步骤并不是一定需要异步。他们也可以同步地来完成。这取决于谁在做加载。这是因为ES module 标准并没有控制所有的事情,工作的两部分由两个不同的规范所覆盖。

Es module 规范规定了如何解析文件为模块记录Module Records,以及如何实例化和执行模块。但是,最开始的如何拿到文件并没有涉及。

文件是由加载器拉去文件,但是加载器又有着不同的规范。对浏览器来就是HTML规范。而且平台不同你也可以有不同的加载器。

07_loader_vs_es-768x439
加载器也是实际控制了模块是如何加载的。它执行ES模块的方法——ParseModule, Module.Instantiate, and Module.Evaluate,它就像线绳一样控制这JS引擎这个木偶。

08_loader_as_puppeteer-768x507

现在让我们更详尽地每个步骤。

构建Construction

每个模块在构建阶段会发生三件事情:

  1. 弄清楚哪里去下载包含模块的文件(也就是模块分解)
  2. 拉取文件(通过URL下载或者从文件系统中加载)
  3. 解析文件为模块记录

查找和拉取文件

加载器负责超着和下载文件。首先它需要找到入口文件。在HTML中,开发者通过script 标签告诉加载器。
08_script_entry-768x288

那它又怎么拿到下一批的模块呢——main.js中直接依赖的文件。

这就是导入声明的用处了。导入声明中有一部分那个叫做模块标识符module specifier。它告诉加载器哪里可以找到下一个模块。

09_module_specifier-768x161

模块标识符module specifier还有一点需要注意的是:还需要处理浏览器和Node两种不同的情况。它们使用各自的模块分析算法去解释模块标识符上的字符串,而且不同平台还不一样。目前一些能够在Node中工作的模块标识符在浏览器中并不能工作,不过目前已在在着手修复这个问题了

在这之前,浏览器只接受URl作为模块的标识符。它将会通过URL来加载模块文件。而且也不会一次性加载全模块图中的文件。因为你不解析文件你就没办法知道文件中的依赖……你也不能解析文件除非你拉取到了文件。

这也就意味着我们需要一层一层的经过这个依赖树:解析一个文件,得到所依赖的文件,然后再查找和加载这些依赖。
10_construction-768x464

如果主线程等待每个文件的下载,那么就会有很多其他的任务悬挂在队列里了。

因为我们在浏览器中,下载部分通常会花费很长的是将。
11_latency-768x415

如此的阻塞主线程会让我们的模块无法使用。这也是为什么ES module 规范中将算法分为几个阶段的原因之一。把模块构建分出去平台自己实现,也就容许浏览器能够拉取文件然后在同步实例化之前建立自己对模块图的理解。

这个实现——将算法分成几个步骤,是ES 模块和CommonJS模块的主要区别。

CommonJS能够不这么做的原因在于,从文件系统中加载文件花费的时间远远小于从网络下载。也就是Node可以在加载文件的时候阻塞主线程,而且一旦文件已经加载,直接实例化和执行也就很顺理成章(没有分成几个步骤),也就是在返回模块实例之前,已经实例化,执行全树的文件了。
12_cjs_require-768x457
CommonJS的实现还有几点说明,我会在稍后说明。但是其中一点是Node的CommonJS模块标识符中是容许使用变量的。因为它在寻找下一个模块的时候已经执行了次模块的所有代码,已经在模块分析之前拿到了变量的值。

但是对于Es modules来说,在做任何代码执行之前你需要拿到全部的模块依赖图。也就是说在模块分析模块标识符的时候,变量还没有被赋值。
13_static_import-768x225

但是,对于某些情况模块路径中有变量是很用的。比如,你可能需要根据代码执行或者环境不同来加载不同的模块哦。

为了能够在Es modules中实现这一点,就有了动态导入dynamic import的提议。这样就可以使用,import(${path}/foo.js)这样的表述。

这样能够工作的原因在于,将import()当成一个新的模块依赖图的入口。动态导入开启了一个新的模块依赖图,它也会被分开处理。

14dynamic_import_graph-768x597

还有一点需要注意的是,不同模块图中共有的模块会共享同一个模块实例。这是因为加载器会缓存模块实例。对于同一个全局作用域下的每个模块只会有一个模块实例。

这也会减少引擎的工作。例如,被不同模块所依赖模块文件只会被拉取一次。这也是模块实例需要被缓存的原因,我们还会在执行阶段将这个问题。

加载器会使用叫模块地图module map的东西来管理模块缓存。不同的全局环境使用各自的模块地图。

当加载器开始拉取一个URL时候,它会将这个URL放入地图并且标记为正在拉取文件。然后他会发起请求,进入下一个文件的拉取。

15_module_map-768x261

如果另外的模块依赖了同样的文件,加载器将会查看地图中的每个URL,如果它看到了fetching的存在,它会直接进入下一个URL。

模块地图不是仅仅缓存了被拉取的文件,他还是用于模块的缓存,我们接下来来看。

解析

当我们加载完文件后,我们需要将其解析为模块记录。这样才能帮助理解浏览器每个模块的不同。

25_file_to_module_record-768x306

一旦模块记录创建之后,它就会被放置在模块地图中,当它再次被需要的时候,加载器就直接从模块地图中直接取出。

25_module_map-768x367

解析还有一点值得注意的是,所有的模块会当作顶部有use strict来解析。还有点不同的,模块的顶层中await关键词不容许使用, this的值为undefined

这种不同的解析叫做“解析球门”,同一文件,你是用不同的球门来解析,你会得到不同的结果。因此你需要在解析之前知道你的球门是什么——是否是模块。

在浏览器中这很简单,你只需要在script标签中使用type="module"即可。

26_parse_goal-768x477

但是在Node中,你没有HTML标签能够使用,也就没有type属性。社区中一个方法是使用.mjs新的扩张,这些讨论在进行,社区也暂时未确定使用何种方式。

无论如何,加载器会决定是否按照模块来解析文件。如何它是一个模块而且还有依赖,它就会开始一遍遍的处理直到所有的文件被拉取和解析。

但我们做完加载的环节,你就会从一个入口文件得到一批的模块记录。

27_construction-768x624

下一步就是实例化模块,然后将所有的实例连接起来。

实例化

就像上文提及的,一个实例包含了规则code和状态state。状态state都在内存之中,因此实例化这步全都是往内存里写东西。

首先,JS引擎创建一个模块环境记录。来管理模块记录中的变量,然后它会找到所有导出在内存中的变量,模块环境记录就会跟踪内存中那个盒子对应哪个导出。

内存中盒子现在暂时还没有值。它们只有在执行之后才会被填充。在这里还有一点注意的是:任何被导出的函数声明初始化是在这个阶段。这样会让后续的执行更简单。

引擎采用深度优先后序遍历来实例化模块依赖图。这也意味着他会先找到图的底部——没有依赖其他模块——设置他们的导出。

30_live_bindings_01-768x316

引擎连接一个模块的导入和该模块所有依赖的导出。然后再和上一层依赖它的模块连接。

注意,导出和导入都处在内存中同样的位置。首先连接导出就能保证所有的导入都能够和它所配皮的导出相连。

30_live_bindings_02-768x316

这点和CommonJS模块很不一样。在CommonJS中,整个的导出都在会在导出时候被复制。这也意味这任何的值(例如数字)导出时候是复制的。

这也就是说如果导出方后面更改了这个值,导入它的模块将看不到变化。

30_live_bindings_04-768x316

相反,在Es modules模块系统中使用动态绑定的方案。导入和导出的模块指向内存中同样的地址。这样导出方更改了值,Es modules中导入方是可以看到变化的。

模块导出方可以随时改变导出值,但是模块导入饭不可以改变他们导入的值。这样,如果一个模块导入了一个对象,它可以改变这个对象上属性的值。

31_cjs_variable-768x174

之所以使用动态绑定的原因在于你后续可以在不运行任何代码的情况下连接所有的模块。这点也可以有助于环形依赖的执行,这点我会后续解释。

因此在这个步骤结束,我们得到了所有的模块实例和连接到一起的导入/导出变量。

然后我们就可以开始执行代码,向内存中的地址填值了。

执行

最后一步就是JS引擎执行顶层的代码——函数之外代码,来填写内存中的这些盒子。

而且,填充这些内存中的盒子,执行代码的时候也会触发一些副作用。例如,一个模块可能会发起一个服务端请求。

40_top_level_code-768x224

因为潜在的副作用,你只希望模块执行一次。但是和实例化连接过程多次进行结果严格一直不同,每次的执行都会有不同的结果。

这也是我们为什么有模块地图的原因。模块地图通过唯一的URL只为模块添加一条模块记录。这就保证了每个模块只执行一次。就像实例化,执行也是通过深度优先后续遍历。

那么就像环形依赖又是怎么样的呢?

在环形依赖中,你会得到一个循环的图。一边可能会比较长。但是为了好解释,我们认为构造一个简短的例子。

41_cjs_cycle-768x344

让我们看下CommonJS模块是怎么处理的,首先main模块执行它的模块require声明,然后许加载counter模块。

41_cyclic_graph-768x432

counter模块会尝试读取导入的message变量,但是mian模块尚未执行,它会返回undefined。JS引擎会为本地变量分配一块内存并设为undefined。

42_cjs_variable_2-768x174

继续执行counter模块的顶层代码。我们通过设置一个超时来看看我们到底能不能拿到message变量的正确值(等到main.js执行后),接下来开始main.js的执行。

43_cjs_cycle-768x344

message变量将会被初始化并添加到内存。但是因为其中并没有连接。它仍会在counter模块中保持undefined。

44_cjs_variable_2-768x331

如果我们导出使用动态绑定的话。counter模块最终会得到正确的值。当到超时回调时候,main.js的执行就会完成值也会被添加。

Es modules中三个步骤的设计是其能够支持环形依赖的最大原因。

Es modules的现状如何?

Firefox 60之后,全部主流浏览器都默认支持Es modules了。Node也在开始支持了,有工作小组,已经在解决CommonJSEs modules差异的问题了。

也即是,你可以开始在script标签中添加type=module来使用原声的es模块了。然而一些模块的特性还未支持。动态导入提议还暂时在规范的Stage 3 (翻译时间:2019-01-17),还有import.meta将会对Node有用处,并且模块的分析方案module resolution proposal也会有助于抹平浏览器和Node之间的差异。因此你可以期望Es modules未来可以更加好用。

致谢

略……