【译】通过漫画深入了解ES Modules

2,206 阅读15分钟

原文地址:ES modules: A cartoon deep-dive 原文作者: Lin Clark 原文时间: 2018-3-28

前言

ES modules 为JavaScript带来了官方的、标准化的模块系统。这估计需将近10年的标准化工作来实现。

但好消息是,等待马上就要结束了,随着FireFox 60版本的发布,所有的主流浏览器都将支持ES modules,并且Node的模块工作组也正在努力为node添加ES modules支持,针对WebAssembly的ES moudles 集成也在研发中。

许多Js开发者都知道ES modules一直存在争议,但是很少有人真正了解ES modules的工作原理。

让我们一起看看ES modules解决了什么问题,同时它和其他的模块系统有什么区别。

模块系统解决了什么问题?

使用JS进行代码开发的本质,就是对变量进行管理,类似将某个值赋予给某个变量,通过计算修改某个变量值,或者通过组合两个变量并将这个值赋予另一个变量等。

图片

因为你的大部分代码都是关于改变变量的,所以如何组织这些变量,对编码的质量和对代码的管理非常重要。

每次只考虑少数的几个变量会让事情变得简单,JS通过scoped(作用域)这种方式来实现。由于作用域的工作原理,一个函数无法访问定义在另一个函数中的变量。

图片

这是非常好的设计,因为这意味着当你处于一个函数作用域时,你只需考虑这一个函数。你不需要关心其他函数会对你这个函数作用域内的变量产生影响。

然而这种方式也存在缺点,他让不同函数间的变量共享变得十分困难。

如果你确实想要在作用域之外共享变量该怎么办?一个常见的方式是将变量放在更高一级的作用域中,比如global scope(全局作用域)

你可能还记得jQuery时代是什么样的情况,在你加载jQuery插件之前,你必须在全局作用域加载jQuery。

图片

这种方式没什么大问题,但是它却会带来一些让人讨厌的小问题。

首先你的所有<script>标签都要按照正确的顺序引入,同时一定不能搞乱这个顺序。

如果你搞乱了顺序,在运行过程中,你的app会因为函数在全局作用域找不到jQuery而引起错误并停止执行。

图片

这会让代码维护变得十分困难,移除旧代码就像是在赌博,因为你不知道什么时候会让程序崩溃。代码之间的依赖关系是隐式的,任何函数都可以访问全局作用域,你却不知道哪个函数取决于哪个脚本。

第二个问题就是由于这些变量位于全局作用域,全局作用域中的任何一段代码都能修改这些变量。恶意代码可以通过修改变量达到自己的目的,非恶意代码也可能破坏你的变量。

模块如何提供帮助?

模块给予你一个更好的方式去组织、管理这些变量和函数。通过模块你可以将函数和变量整合到一起。

模块会将这些函数和变量放入一个module scoep(模块作用域)。这个作用域可以让模块中的函数共享变量。

和函数作用域不同,模块作用域可以显露自身变量给其他模块。还可以显式地声明将哪些变量、类、函数显露给其他模块。

这种显露就称为export,使用export可以让其他模块显式的声明,其依赖于本模块的哪些变量、类或函数。

图片

受益于这种显式的声明关系,你可以知道删除一个模块后会导致哪些模块不可用。

一旦能够在模块之间导出和导入变量,就可以更轻松地将代码分块,然后可以像玩乐高积木一样组合和重组模块,创建各种各样的应用。

因为模块系统功能强大,历史上曾有很多次在JS中引入模块系统的尝试。当前有两种模块系统使用比较广泛。一种是Node.js之前使用的CommonJs(CJS)规范。另一种就是新加入JS规范的EcmaScript modules(ESM)规范。浏览器已经支持ESM,而Node.js也正在添加ESM支持。

ES modules 是如何工作的?

在使用模块进行开发时,系统会建立一个模块依赖图。你使用importexport语句,就可以建立不同依赖之间的联系。

这些语句让浏览器或Node知道其需要加载哪些代码,而你需要提供一个文件作为这个模块依赖图的入口。之后通过import语句找到其余需要加载的代码。

图片

浏览器本身无法直接使用这些文件,这些文件需要被转化为一种叫做Module Records的数据结构。通过这种方式才能知道每个文件执行了何种操作。

图片

在这之后,module record需要被转化为模块实例。一个模块实例由两部分组成——代码(code)和状态(state)。

代码基本上是一组指令的集合,就像是做饭的菜谱。但仅有这些指令什么也做不了,你需要一些"原材料"来和这个”食谱“一起使用。

什么是状态?状态为你提供“做菜”所需要的“原材料”。状态是变量任一时间的值,当然这些变量只是存储这些值的内存地址的一个绰号。

所以模块实例就是代码(指令集合)和状态(所有变量的值)的组合

图片

我们需要的是每一个模块的实例。模块加载的过程就是从入口开始获取一个完整的模块实例表的过程。

在ES modules中,这个过程分为三步。

  1. Construction——获得模块文件,并将其解析为module record
  2. Instantiation——为变量值分配内存将export和import点存入内存,这个过程称为linking
  3. Evaluation——运行代码,并将变量真实值写入内存

图片

你可以认为ES modules是异步的,因为整个过程被分为三个不同的阶段——loadinginstantiatingevaluating,这些阶段都可以分别完成。

这意味着规范确认引入了CommonJS中不存在的异步。在CommonJS中一个模块下的依赖,从加载到实例化再到求值,两两之间间隔很小,且基本没有间断。

然而,这些步骤本身并不一定是异步的,它们还可以用同步的方式完成。这完全取决于当前正在加载什么,因为并非所有内容都受ES模块规范控制。

ES模块规范说明了如何将文件解析为模块记录,以及如何实例化和求值。但是,它并没有说明如何先获取文件。

加载程序是用来提取文件的,而且是在其他规范中指定的。对于浏览器,该规范就是HTML规范。你可以根据所使用的平台,使用不同的装载程序。

图片

Construction

每个模块在Construction阶段要执行三个操作

  1. 找出从哪里下载包含模块的文件
  2. 获取文件(从URL下载或从文件系统加载)
  3. 将文件解析为module record

找到并获取文件

loader负责查找文件并下载。首先它需要找到入口文件。在HTML中,可以通过script标签的来告知loader从哪里加载文件。

但是如何找到链路的下一级,也就是main.js直接依赖的模块呢?

这就是import语句的来源,import语句from后面的称为模块说明符,它告诉loader去哪里找到下一个模块。

图片

关于模块提示符有一点需要提醒:有时我们需要对浏览器和Node进行不同的处理。每个主机都有自己的解释模块,来说明符字符串的方式。为此,它使用一种称为模块解析算法的方法,该算法在平台之间有所不同。某些在Node中可用的模块说明符在浏览器却中不起作用。

在这个问题解决之前,浏览器仅接受URL作为模块说明符。浏览器将从该URL加载模块文件,在解析文件之前,浏览器不知道模块需要获取哪些依赖项,并且在获取文件之前,无法解析该文件。

这意味着我们必须一层一层的解析模块依赖树,解析一个文件,找出这个文件的依赖,然后继续加载这些依赖。

图片

因为下载会占用很长的时间,如果主进程等待这些文件中每一个文件下载,就会有许多其他任务堆积在队列中。

图片

阻塞主线程会让使用modules的应用程序加载过于缓慢,这是ES modules规范将算法分为多个阶段的原因之一。将construction分为一个单独的阶段,浏览器就可以在开始实例化之前获取文件,并增强对模块图的理解。

这种将算法分为多个阶段的做法是ES moudles和CommonJS之间的主要区别之一。

由于从文件系统中加载文件所花费的时间,要比通过Internet下载文件少的多,CommonJs可以采取一种不同的方式。这也意味着Node可能在加载文件时阻塞主线程,而且由于文件已经完成加载,就只需要进行instantiate和evaluate(在CommonJs中并不是一个单独的阶段)。这意味着在返回模块实例之前,你要遍历整个模块树,对所有依赖性进行加载、实例化和求值。

图片

在使用CommonJs规范的Node时,你可以在模块说明符中使用变量,在解析模块时执行此模块中的所有代码。这意味着当你在解析某个模块时,某块中的变量会有一个值。

但是在ES 规范中,你要在求值之前建立整个模块依赖图。在这个阶段变量没有值,因此你也就不能在模块说明符中使用变量。

图片

当你想在不同的代码中或不同的运行环境中切换不同的模块的时候,在模块路径中使用变量又真的很有必要。

在ES modules中可以使用动态引入来实现以上需求,语法为import(path)

通过这种方式加载的任何文件都会将自己作为单独的入口进行处理,动态导入的模块会建立一个单独的模块依赖图。

图片

不过有个事情需要注意,这两张图中的所有模块都将会共享一个模块实例。loader会缓存模块实例,在某个特定全局作用域中多个相同模块只会有一个唯一实例。

这意味这引擎的工作减少了。例如,即使多个模块都依赖同一个模块,这个模块也只会被加载一次。(这就是缓存模块的意义,另外一个原因我们将在稍后的求值(evaluation)部分看到。

loader使用一种叫做module map的方式管理模块缓存。每个全局作用域通过一个单独的module map追踪模块。

当loader去请求一个URL的时候,loader会把这个URL放入module map并将其打上标记来标识正在下载该文件。之后就发送请求获取该文件,然后继续获取下一个文件。

图片

当另外一个模块也同样依赖这个文件呢?lodaer会在module map中找的这个URL,如果已经被标记为fetching,loader则会去加载下一个URL。

module map不仅仅用来追踪正在加载的文件,它也同样充当模块的缓存。

解析

当我们完成文件的下载后,我们解析并将它转为能使浏览器理解不同模块之间区别的 module record结构。

图片

module record会被保存在moduel map中,这意味着不论何时loader都能从loader map中获取依赖。

图片

解析时某个很小的细节都会造成很大的影响。比如所有的模块文件都会按照严格的模式解析。还有一些其他的小细节,比如,await关键字会在顶层代码中保留,并且this的值是undefined。

解析的不同方式被称为parse goal.如果你使用不同的goal解析相同的文件,最终得到的也是不同的结果。所以你需要在解析之前了解你到底在解析什么,这些到底是不是一个模块。

在浏览器中这是很容易办到的,你只需要在script标签上使用type="module"。这会告知浏览器这个文件是个模块,需要按照模块的方式解析,又因为只有模块可以被import,浏览器也就知道所有import的文件都是模块。

图片

但是在node中你不能使用HTML标签,你也就不能使用type属性。社区中有个解决方式是使用.mjs拓展名来告诉node此文件是一个模块。

无论使用什么方式,loader都会决定是否将文件按照模块的方式解析。如果它是一个模块,并且有模块同样含有import,loader会一直不断的运行直到所有的文件都被获取并解析。

随着这些加载程序的结束,我们就从只有一个入口文件,变为拥有大量的module recored了。

下一步就是实例化所有的模块,并将他们整合到一起。

实例化

正如我之前所提到的,一个模块实例是由code和state组成。state被保存在内存中,因为实例化的步骤实质上就是将内容写入内存。

首先,JS引擎会创建一个module environment record。它是用来管理module record中的变量,之后引擎找到所有export的内存地址。module environment record会追踪内存地址和export之间的对应关系。

此时内存中还没有值,只有在求值之后真正的值才会写入内存。这会导致所有导出的函数声明在这个阶段才会实例化,以让求值变得更简单。

为了完成模块依赖图的实例化,JS引擎会深度优先后序遍历模块树。

图片

请注意,import和export都会指向内存中的同一个地址,我们首先完成export的遍历,以确保每个import都能找到和它匹配的export。

ES moudles和CommonJS实例化有所不同。在CommonJS中,整个导出对象在导出时都会被复制,也就是说导出的任何值都是副本,也就是深拷贝。这意味修改导出的文件并不会反应在模块文件本身。

图片

与之相反的是,ES modules是使用动态绑定来实现。所有模块实例都指向同一块内存地址,这意味着当我在export中修改值,变化也会反映在import上。

我们知道,导出值的模块可以随时更改这些值,但是导入模块不能更改其导入的值。话虽如此,如果模块导入了一个对象,则它可以更改该对象上的属性值。

图片

求值

最后一步是将这些值填充到内存中。 JS引擎通过执行顶级代码(即函数外部的代码)来实现此目的。

除了仅在内存中填充这些值外,求值代码还可能触发副作用。例如,模块可能会请求服务器。

图片

与在实例化阶段的linking每次都能得到相同的结果不同,每次evluation执行所得到的结果都可能不同,为了避免这种情况可能会造成的潜在副作用,我们应该只进行一次evaluation

这就是module map存在的原因,module map通过规范url对模块进行缓存来保证每个仅有一个module record。这确保了每个模块仅被执行一次。和instantiation(实例化)阶段一样,这个阶段也是通过深度优先后序遍历来处理的。

我们之前提过的循环依赖又是怎样的呢?

在循环依赖关系中,最终在图中会有一个循环,通常这是一个漫长的循环。但是,为了解释这个问题,我将使用一个简短的循环为例子。

图片

让我们看看CommonJS是如何处理的。首先,主模块将执行直到require语句,然后它将加载计数器模块。

图片

然后,计数器模块将尝试访问来自导出对象的message。但是由于尚未在主模块中进行求值,因此它将返回undefined。 JS引擎将在内存中为局部变量分配空间,并将其值设置为undefined。

图片

Evaluation continues down to the end of the counter module’s top level code. We want to see whether we’ll get the correct value for message eventually (after main.js is evaluated), so we set up a timeout. Then evaluation resumes on main.js.

图片

消息变量将被初始化并添加到内存中。但是由于两者之间没有连接,因此值扔为undefined

图片

如果使用实时绑定处理导出,则计数器模块最终将看到正确的值。到超时运行时,main.js的求值将完成并填入该值。

支持这些循环是ES模块设计背后的重要原因。