图解ESM

525 阅读18分钟

ES 模块 为 JavaScript 带来了官方的、标准化的模块系统。不过,它花了将近10年的标准化工作才走到这一步 。

但是等待即将结束,随着 5 月份 Firefox 60 的发布(目前处于测试阶段),所有主流浏览器都将支持 ES 模块,而Node模块工作组目前正在努力为Node.js添加 ES 模块支持。而WebAssembly对于ES模块的支持工作也在进行中。

许多 JavaScript 开发人员都知道 ES 模块一直存在争议。但很少有人真正了解 ES 模块的工作原理。

我们来看看 ES 模块解决了什么问题,以及它们与其他模块系统中的模块有何不同。

模块化能解决什么问题?

仔细想想,用 JavaScript 编码就是管理变量。这一切都是关于为变量赋值,或为变量添加数字,或将两个变量组合在一起并将它们放入另一个变量中。

Code showing variables being manipulated

因为您的大部分代码都只是关于更改变量,所以您如何组织这些变量将对您编写代码的能力以及后期维护产生重大影响。

一次只需要考虑几个变量,事情就会变得简单。JavaScript通过作用域来实现这一点。由于JavaScript中存在作用域,所以函数无法访问在其他函数中定义的变量。

Two function scopes with one trying to reach into another but failing

这很好。这意味着,当你在处理一个函数时,你可以把关注点就放在这一个函数上。你不必担心其他函数会对你的变量做什么。

不过,它也有一个缺点。我们很难在不同的函数之间共享变量。

如果你确实想在一个作用域之外共享你的变量呢?一个常见的处理方法是把它放在当前作用域之外的一个作用域上......比如说,放在全局作用域上。

你可能还记得jQuery时代的事情。在加载任何 jQuery 插件之前,您必须确保 jQuery 已经被挂载到全局。

Two function scopes in a global, with one putting jQuery into the global

这很有效,但会产生一些恼人的问题。首先,所有脚本都需要按正确的顺序排列。然后,你必须小心翼翼地确保没有人会打乱这个顺序。

如果你真的打乱了这个顺序,那么在运行过程中,你的应用程序将抛出一个错误。当该函数在它期望的地方(在全局上)寻找 jQuery 并且没有找到它时,它会抛出一个错误并停止执行。

The top function scope has been removed and now the second function scope can’t find jQuery on the global

这使得后期维护代码变得棘手。它使删除旧的代码或脚本标签成为一种轮盘游戏。你不知道什么会导致程序无法执行。不同的代码片段之间的依赖关系是隐性的。任何函数都可以抓取全局上的任何东西,所以你不知道哪些函数依赖于哪些脚本。

第二个问题是,由于这些变量是在全局范围内的,所以全局范围内的每一部分代码都可以改变这个变量。恶意代码可以故意改变该变量,使你的代码做一些你不想做的事情,或者非恶意代码可能只是意外地破坏你的变量。

模块是如何提供帮助的?

模块为您提供了一种更好的方式来组织这些变量和函数。使用模块,您可以将有关联的变量和函数组合在一起。

将这些函数和变量放入模块范围。在同一个模块中的函数之间可以共享变量。

但与函数作用域不同的是,模块作用域有办法对其他模块暴露他们的变量。他们可以明确地指定模块中的哪些变量、类或函数可以被其它模块使用。

当某些东西暴露于其他模块时,它被称为导出。一旦你有了一个导出,其他模块就可以明确地声明它们依赖于那个变量、类或函数。

Two module scopes, with one reaching into the other to grab an export

因为这是一种明确的关系,所以你可以知道如果你删除了某一个模块,哪些模块会被破坏。

一旦您能够在模块之间导出和导入变量,就可以更容易地将你的代码分解成可以独立工作的小块。然后,你可以将这些小块重新组合,有点像乐高积木,用同一组模块创建各种不同的应用程序。

既然模块化如此有用,已经有多次向 JavaScript 添加模块功能的尝试。如今,有两个模块系统正在被积极使用。一个是 已经在Node.js中使用的 CommonJS (CJS)。ESM(EcmaScript modules)是一个刚刚被加入到 Javascript 规范的新的模块化系统。浏览器已经支持 ES 模块了,Node 正在添加支持对应的支持。

让我们深入了解一下这个新的模块系统是如何工作的。

ES 模块是如何工作的?

当你使用模块进行开发的时候,就创建了一个依赖图(dependencies graph)。每个依赖之间的连接来自于你使用的 import 语句。

浏览器或者 Node 遇到 import 语句时,能精确地知道应该加载什么代码。你需要提供一个文件作为依赖图的入口。通过入口文件中的import语句可以找出所依赖的其他代码文件。

A module with two dependencies. The top module is the entry. The other two are related using import statements

但是文件本身并不是浏览器可以直接识别的东西。ES的模块系统需要解析那些文件将他们转换为一种叫做模块记录(module record)的一种数据结构。这样,浏览器才知道文件里面发生了什么。

A module record with various fields, including RequestedModules and ImportEntries

解析之后,还需要把模块记录变成一个模块实例。模块实例会把代码和状态结合起来。

所谓代码,基本上是一组指令集合。它就像是制作某样东西的配方,指导你该如何制作。 但是它本身并不能让你完成制作。你还需要一些原料,这样才可以按照这些指令完成制作。

所谓状态,它就是原料。具体点,状态是变量在任何时候的真实值。 当然,变量实际上就是内存地址的别名,内存才是正在存储值的地方。

因此,模块实例结合了代码(指令列表)和状态(所有变量的值)。

A module instance combining code and state

我们需要的是每个模块的模块实例。模块加载会从入口文件开始,最终生成完整的模块实例关系图。

对于 ESM ,这个过程包含三个阶段:

  1. 构建: 查找、下载所有文件并将其解析为模块记录。
  2. 实例化:为所有模块分配内存空间(此刻还没有填充值),然后依照导出、导入语句把模块指向对应的内存地址。这个过程称为链接(Linking)。
  3. 运行:运行代码,从而把内存空间填充为真实值。

The three phases. Construction goes from a single JS file to multiple module records. Instantiation links those records. Evaluation executes the code.

大家都说 ESM 是异步的。因为它把整个过程分为了三个不同的阶段:加载、实例化和运行,并且这三个阶段是可以独立进行的。

这意味着,ESM 规范确实引入了一种在 CJS 中是没有的异步方式。我稍后会解释更多,但是在 CJS 中,一个模块及其依赖的加载、实例化和运行是一起顺序执行的,中间没有任何间断。

不过,这三个阶段本身是没必要异步化。它们可以同步执行,这取决于它是由谁来加载的。不过,这三个阶段本身是没必要异步化。它们可以同步执行,这取决于它是由谁来加载的。

其中,ESM 标准 规范了如何把文件解析为模块记录,如何实例化和如何运行模块。但是它没有规范如何获取文件。

文件是由加载器来提取的,而加载器由另一个不同的标准所规范。对于浏览器来说,这个标准就是 HTML。但是你还可以根据所使用的平台使用不同的加载器。

Two cartoon figures. One represents the spec that says how to load modules (i.e., the HTML spec). The other represents the ES module spec.

加载器也同时控制着如何加载模块。它会调用 ESM 的方法,包括 ParseModuleModule.InstantiateModule.Evaluate 。它就像是控制着 JS 引擎的木偶。

The loader figure acting as a puppeteer to the ES module spec figure.

现在让我们更详细地介绍每个步骤。

构建

在构建阶段,每个模块都会执行三个操作:

  1. 确定要从哪里下载包含该模块的文件(又名模块解析)
  2. 获取文件(通过从 URL 下载或从文件系统加载)
  3. 将文件解析为模块记录

查找文件并获取它

加载程序将负责查找文件并下载它。首先它需要找到入口文件。在 HTML 中,你可以通过 <script> 标签告诉加载器在哪里找到入口文件。

A script tag with the type=module attribute and a src URL. The src URL has a file coming from it which is the entry

但是它如何找到下一组模块——main.js直接依赖的模块?

这个时候导入语句就派上用场了。导入语句中有一部分称为模块定位符(Module Specifier),它会告诉加载器在哪里可以找到模块。

An import statement with the URL at the end labeled as the module specifier

对于模块定位符,有一点要注意的是:每个平台都有自己的一套方式来解析模块定位符。这些方式称为模块定位算法,不同的平台会使用不同的模块定位算法。目前,一些在 Node 中能工作模块定位符并不能在浏览器中工作,但这个问题正在被努力解决

在这个问题被解决之前,浏览器只能接受 URL 作为模块定位符。它们会从 URL 加载模块文件。但是,这并不是在整个关系图上同时发生的。在解析文件之前,你根本不知道它依赖哪些模块。而且在它下载完成之前,你也无法解析它。

这就意味着,我们必须一层层遍历依赖树,先解析文件,然后找出依赖,最后又定位并加载这些依赖,如此往复。

A diagram that shows one file being fetched and then parsed, and then two more files being fetched and then parsed

如果主线程正在等待这些模块文件下载完成,许多其他任务将会堆积在任务队列中,造成阻塞。因为在浏览器中,下载会耗费大量的时间。

A chart of latencies showing that if a CPU cycle took 1 second, then main memory access would take 6 minutes, and fetching a file from a server across the US would take 4 years

而阻塞主线程会使得应用变得卡顿,影响用户体验。这是 ES 模块规范将算法拆分为多个阶段的原因之一。将构建划分为一个独立阶段后,浏览器可以在进入同步的实例化过程之前下载文件然后理解模块关系图。

这种将算法分成多个阶段的方法是 ES 模块和 CommonJS 模块之间的主要区别之一。

CJS 使用不同的算法是因为它从文件系统加载文件,这耗费的时间远远小于从网络上下载。因此 Node 在加载文件的时候可以阻塞主线程,而不造成太大影响。并且由于文件已经加载,那么它就可以直接进行实例化和运行。所以在 CJS 中实例化和运行并不是两个相互独立的阶段。这也意味着,你可以在返回模块实例之前,顺着整颗依赖树去逐一加载、实例化和运行每一个依赖。

A diagram showing a Node module evaluating up to a require statement, and then Node going to synchronously load and evaluate the module and any of its dependencies

CJS 的方式对 ESM 也有一些启发,这个后面会解释。

其中一个就是,在 Node 的 CJS 中,你可以在模块定位符中使用变量。因为已经执行了 require 之前的代码,所以模块定位符中的变量此刻是有值的,这样就可以进行模块定位的处理了。

但是对于 ESM,在运行任何代码之前,你首先需要建立整个模块依赖的关系图。也就是说,建立关系图时变量是还没有值的,因为代码都还没运行。

A require statement which uses a variable is fine. An import statement that uses a variable is not.

不过呢,有时候我们确实需要在模块定位符中使用变量。比如,你可能需要根据当前的状况加载不同的依赖。

为了在 ESM 中实现这种方式,人们已经提出了一个动态导入提案。该提案允许你可以使用类似 import(${path}/foo.js)的导入语句。

这种方式实际上是把使用 import() 加载的文件当成了一个入口文件。动态导入的模块会开启一个全新的独立依赖关系树。

Two module graphs with a dependency between them, labeled with a dynamic import statement

不过有一点要注意的是,这两棵依赖关系树共有的模块会共享同一个模块实例。这是因为加载器会缓存模块实例。在特定的全局作用域中,每个模块只会有一个与之对应的模块实例。

这种方式有助于提高 JS 引擎的性能。例如,即使有多个模块依赖它, 一个模块文件只会被下载一次。(这也是缓存模块的原因之一,后面说到运行的时候会介绍另一个原因。)

加载器使用模块映射(Module Map)来管理缓存。每个全局作用域都在一个单独的模块映射中跟踪其模块。

当加载器去获取一个 URL 时,它把这个 URL 放在模块映射中,并记下它当前正在获取文件。然后它将发出请求并继续开始获取下一个文件。

The loader figure filling in a Module Map chart, with the URL of the main module on the left and the word fetching being filled in on the right

当其他模块也依赖这个文件的时候会发生什么呢?加载器会查找模块映射中的每一个 URL 。如果发现 URL 的状态为正在下载,则会跳过该 URL ,然后开始下一个依赖的处理。

不过,模块映射的作用并不仅仅是记录哪些文件已经下载。下面我们将会看到,模块映射也可以作为模块的缓存。

解析

现在我们已经获取了这个文件,我们需要把它解析成一个模块记录。这有助于浏览器了解模块的各个组成部分。

Diagram showing main.js file being parsed into a module record

一旦模块记录创建完成,它就会被记录在模块映射中。所以,后续任何时候再次请求这个模块时,加载器就可以直接从模块映射中获取该模块记录。

The “fetching” placeholders in the module map chart being filled in with module records

解析中有一个细节看似微不足道,但实际上却有很大的影响。那就是所有的模块都按照严格模式来解析的。也还有其他的小细节,比如,关键字 await 在模块的最顶层是保留字, this 的值为 undefinded

这种不同的解析方式称为“解析目标”。如果您解析相同的文件但使用不同的目标,则最终会得到不同的结果。因此,在开始解析您正在解析的文件类型之前,您想知道它是否是一个模块。

在浏览器中,这很容易。只需要在 <script> 脚本中添加 type="module" 属性即可。这告诉浏览器这个文件需要被解析为一个模块。而且,因为只有模块才能被导入,所以浏览器以此推测所有的导入也都是模块文件。

The loader determining that main.js is a module because the type attribute on the script tag says so, and counter.js must be a module because it’s imported

不过在 Node 中,我们并不使用 HTML 标签,所以也没办法通过 type 属性来辨别。社区提出一种解决办法是使用 .mjs 拓展名。使用该拓展名会告诉 Node 说“这是个模块文件”。你会看到大家正在讨论把这个作为解析目标。不过该讨论目前仍在继续,所以目前仍不明确 Node 社区最终会采用哪种方式。

无论最终使用哪种方式,加载器都会决定是否把一个文件作为模块来解析。如果是模块,而且包含导入语句,那它会重新开始处理直至所有的文件都已提取和解析。

到这里,构建阶段差不多就完成了。在加载过程处理完成后,你已经从最开始只有一个入口文件,到现在得到了一堆模块记录。

A JS file on the left, with 3 parsed module records on the right as a result of the construction phase

下一步会实例化这些模块并且把所有的实例链接起来。

实例化

正如前文所述,一个模块实例结合了代码和状态。该状态存在于内存中,所以实例化的过程就是把所有值写入内存的过程。

首先,JS 引擎会创建一个模块环境记录(Module Environment Record)。它管理着模块记录中所有变量。然后它在内存中找到所有导出内容所对应的内存地址。模块环境记录将跟踪内存中的哪个内存地址与导出相关联。

这些内存地址此时还没有值,只有等到运行后它们才会得到实际值。有一点要注意,所有导出的函数声明都在这个阶段初始化,这会使得后面的运行阶段变得更加简单。

为了实例化模块关系图,引擎会采用深度优先的后序遍历方式。这意味着它会顺着关系图到达最底端没有任何依赖的模块,然后设置它们的导出。

A column of empty memory in the middle. Module environment records for the count and display modules are wired up to boxes in memory.

最终,引擎会把模块下的所有依赖导出链接到当前模块。然后回到上一层把模块的导入链接起来。

Same diagram as above, but with the module environment record for main.js now having its imports linked up to the exports from the other two modules.

这个过程跟 CJS 是不同的。在 CJS 中,整个导出对象在导出时都是值拷贝。这意味着所有的导出值都是拷贝值,而不是引用。

所以,如果导出模块内导出的值改变了,导入模块中导入的值也不会改变。

Memory in the middle with an exporting common JS module pointing to one memory location, then the value being copied to another and the importing JS module pointing to the new location

相反,ES 模块使用的是称为实时绑定(Live Binding)的东西。两个模块都指向内存中的相同位置。这意味着当导出模块内导出的值改变后,导入模块中的值也会实时改变。

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

The exporting module changing the value in memory. The importing module also tries but fails.

ESM 采用这种实时绑定的原因是,您可以在不运行任何代码的情况下连接所有模块。这对解决运行阶段的循环依赖问题也是有帮助的。我将在下面解释。

实例化阶段完成后,我们已经连接了导出/导入变量的所有实例和内存位置。

现在我们可以开始运行代码并且往内存空间内填充值了。

运行

最后一步是往已申请好的内存空间中填入真实值。JS 引擎通过运行顶层代码(函数外的代码)来完成填充。

除了填充值以外,运行代码也会引发一些副作用(Side Effect)。例如,一个模块可能会向服务器发起请求。

A module will code outside of functions, labeled top level code

正是因为这些潜在副作用的存在,所以模块代码只能运行一次

前面我们看到,实例化阶段中发生的链接可以多次进行,并且每次的结果都一样。但是,如果运行阶段进行多次的话,则可能会每次都得到不一样的结果。

这正是为什么会使用模块映射的原因之一。模块映射会以 URL 为索引来缓存模块,以确保每个模块只有一个模块记录。这保证了每个模块只会运行一次。跟实例化一样,运行阶段也采用深度优先的后序遍历方式。

那对于前面谈到的循环依赖会怎么处理呢?

循环依赖会使得依赖关系图中出现一个依赖环,即你依赖我,我也依赖你。通常来说,这个环会非常大。这里我们使用一个简单的循环依赖来解释这个问题。

A complex module graph with a 4 module cycle on the left. A simple 2 module cycle on the right.

首先来看下这种情况在 CJS 中会发生什么。最开始时,main 模块会运行 require 语句。紧接着,会去加载 counter 模块。

A commonJS module, with a variable being exported from main.js after a require statement to counter.js, which depends on that import

counter 模块会试图去访问导出对象的 message 。不过,由于 main 模块中还没运行到 message 处,所以此时得到的 messageundefined。JS 引擎会为本地变量分配空间并把值设为 undefined

Memory in the middle with no connection between main.js and memory, but an importing link from counter.js to a memory location which has undefined

运行阶段继续往下执行,直到 counter 模块顶层代码的末尾处。我们想知道,当 counter 模块运行结束后,message 是否会得到真实值,所以我们设置了一个定时器。之后运行阶段便返回到 main.js 中。

counter.js returning control to main.js, which finishes evaluating

这时,message 将会被初始化并添加到内存中。但是这个 messagecounter 模块中的 message 之间并没有任何关联关系,所以 counter 模块中的 message 仍然为 undefined

如果导出值采用的是实时绑定方式,那么 counter 模块最终会得到真实的 message 值。当定时器开始计时时,main.js 的运行就已经完成并设置了 message 值。

支持循环依赖是 ESM 设计之初就考虑到的一大原因。也正是这种分段设计使其成为可能。