ES 模块:漫画式深入解读

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 中。今天,有两个模块系统正在积极使用。CommonJS (CJS) 是 Node.js 历史上一直使用的系统。ESM (EcmaScript 模块) 是一种较新的系统,已添加到 JavaScript 规范中。浏览器已经支持 ES 模块,而 Node 正在添加支持。

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

ES 模块的工作原理

当你使用模块进行开发时,你会构建一个依赖关系图。不同依赖关系之间的连接来自你使用的任何 import 语句。

这些 import 语句是浏览器或 Node 如何确切知道它需要加载哪些代码的方式。你向它提供一个文件作为图的入口点。从那里,它只跟随任何 import 语句来找到剩余的代码。

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

但文件本身并不是浏览器可以使用的东西。它需要解析所有这些文件,才能将它们转换为称为模块记录的数据结构。这样,它才能真正知道文件中发生了什么。

A module record with various fields, including RequestedModules and ImportEntries

之后,需要将模块记录转换为模块实例。实例将两件事结合在一起:代码和状态。

代码基本上是一组指令。就像制作某样东西的食谱。但就其本身而言,你无法使用代码来执行任何操作。你需要使用这些指令的原材料。

什么是状态?状态为你提供这些原材料。状态是在任何时间点的变量的实际值。当然,这些变量只是内存中包含值的框的昵称。

所以模块实例将代码(指令列表)与状态(所有变量的值)结合在一起。

A module instance combining code and state

我们需要为每个模块提供一个模块实例。模块加载的过程是从这个入口点文件到拥有完整的模块实例图。

对于 ES 模块,这将分三个步骤完成。

  1. 构造 - 查找、下载和解析所有文件到模块记录。
  2. 实例化 - 在内存中查找框来放置所有导出的值(但不要用值填充它们)。然后使导出和导入都指向内存中的这些框。这称为链接。
  3. 评估 - 运行代码以用变量的实际值填充框。

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

人们说 ES 模块是异步的。你可以将其视为异步,因为工作分为三个不同的阶段 - 加载、实例化和评估 - 这些阶段可以分别完成。

这意味着规范确实引入了一种 CommonJS 中不存在的异步性。我将在后面进一步解释,但在 CJS 中,模块及其下面的依赖项会立即加载、实例化和评估,中间没有任何断开。

但是,步骤本身不一定是异步的。它们可以以同步方式完成。这取决于正在进行加载的操作。这是因为并非所有事情都由 ES 模块规范控制。实际上,工作分为两个部分,分别由不同的规范覆盖。

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

加载器会获取文件。加载器在不同的规范中指定。对于浏览器,该规范是 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.

加载器还控制模块的加载方式。它调用 ES 模块方法 - 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 直接依赖的模块?

这就是 import 语句发挥作用的地方。import 语句的一部分称为模块说明符。它告诉加载器在哪里可以找到每个下一个模块。

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

关于模块说明符需要注意的一点是:它们有时需要在浏览器和 Node 之间进行不同的处理。每个主机都有自己解释模块说明符字符串的方式。为此,它使用称为模块解析算法的东西,该算法在不同平台之间有所不同。目前,一些在 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 模块之间关键区别之一。

CommonJS 可以采用不同的方式,因为从文件系统加载文件比从 Internet 下载要快得多。这意味着 Node 可以在加载文件时阻塞主线程。由于文件已经加载,因此直接实例化和评估(在 CommonJS 中不是单独的阶段)是有意义的。这也意味着您需要遍历整棵树,加载、实例化和评估所有依赖项,然后才能返回模块实例。

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

CommonJS 方法有一些影响,我将在稍后解释更多相关内容。但其中一项影响是,在使用 CommonJS 模块的 Node 中,可以在模块说明符中使用变量。在查找下一个模块之前,将执行此模块中的所有代码(直到 require 语句)。这意味着当您进行模块解析时,该变量将具有值。

但在 ES 模块中,您是在构建整个模块图……然后才进行任何评估。这意味着不能在模块说明符中使用变量,因为这些变量还没有值。

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

但有时使用变量作为模块路径确实很有用。例如,您可能希望根据代码正在执行的操作或运行的环境来切换加载的模块。

为了使 ES 模块能够做到这一点,有一个名为 动态导入 的提案。使用它,可以使用类似 import(`${path}/foo.js`) 的导入语句。

它的工作原理是,使用 import() 加载的任何文件都将被视为单独图形的入口点。动态导入的模块会启动一个新的图形,该图形将单独处理。

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

需要注意的是,在这两个图形中都存在的任何模块都将共享一个模块实例。这是因为加载程序会缓存模块实例。在特定全局范围内的每个模块都只有一个模块实例。

这意味着引擎的工作量更少。例如,这意味着即使多个模块依赖于模块文件,该模块文件也只会被获取一次。(这是缓存模块的原因之一。我们将在评估部分看到另一个原因。)

加载程序使用名为 模块映射 的东西来管理此缓存。每个全局范围都会在其单独的模块映射中跟踪其模块。

当加载程序去获取 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。如果在其中看到 fetching,它只会继续处理下一个 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

解析中有一个细节可能看起来微不足道,但实际上影响很大。所有模块都按如下方式解析,就像它们在顶部包含 "use strict" 一样。还有一些细微的区别。例如,关键字 await 在模块的顶层代码中是保留的,this 的值为 undefined

这种不同的解析方式称为“解析目标”。如果您解析同一个文件,但使用不同的目标,您将获得不同的结果。因此,您需要在开始解析之前就知道要解析的文件类型——它是否是一个模块。

在浏览器中,这很简单。您只需在脚本标签上添加 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 引擎创建一个模块环境记录。这将管理模块记录的变量。然后,它在内存中查找所有导出的框。模块环境记录将跟踪内存中哪个框与每个导出相关联。

这些内存中的框还没有获得它们的值。只有在评估之后,它们才会被填充实际的值。这条规则有一个例外:在此阶段将初始化任何导出的函数声明。这使评估更容易。

为了实例化模块图,引擎将执行所谓的深度优先后序遍历。这意味着它将遍历到图形的底部——到最底层的依赖项(它们不依赖于任何其他依赖项)——并设置其导出。

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.

这与 CommonJS 模块不同。在 CommonJS 中,整个导出对象在导出时会被复制。这意味着任何导出的值(例如数字)都是副本。

这意味着如果导出模块稍后更改该值,导入模块将不会看到该更改。

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 模块使用一种称为实时绑定的东西。两个模块都指向内存中的同一位置。这意味着当导出模块更改值时,该更改将出现在导入模块中。

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

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

这样做的原因是,您可以连接所有模块而无需运行任何代码。这有助于在存在循环依赖关系时进行评估,正如我将在下面解释的那样。

因此,在此步骤结束时,我们拥有所有实例以及导出的/导入的变量的内存位置已连接。

现在我们可以开始评估代码并将这些内存位置填充为它们的值。

评估

最后一步是填充这些内存中的框。JS 引擎通过执行顶层代码——函数外部的代码——来做到这一点。

除了填充这些内存中的框之外,评估代码还可以触发副作用。例如,模块可能会调用服务器。

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.

让我们看看这如何在 CommonJS 模块中工作。首先,主模块将执行到 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。但由于它尚未在主模块中被评估,因此这将返回 undefined。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 模块的顶层代码的末尾。我们希望查看我们是否最终会获得 message 的正确值(在 main.js 被评估之后),因此我们设置了一个超时。然后,评估恢复到 main.js

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

message 变量将被初始化并添加到内存中。但由于两者之间没有连接,因此它将在所需模块中保持为 undefined。

main.js getting its export connection to memory and filling in the correct value, but counter.js still pointing to the other memory location with undefined in it

如果导出使用实时绑定处理,counter 模块最终将看到正确的值。在超时运行时,main.js 的评估将完成并填充值。

支持这些循环是 ES 模块设计背后的一个重要原因。正是这种三阶段设计使它们成为可能。

ES 模块的现状如何?

随着 5 月初 Firefox 60 的发布,所有主要浏览器都将默认支持 ES 模块。Node 也在添加支持,并有一个专门负责解决 CommonJS 和 ES 模块之间兼容性问题的 工作组

这意味着您将能够使用带有 type=module 的脚本标签,并使用导入和导出。但是,更多模块功能尚未推出。动态导入提案 处于规范过程的第 3 阶段,import.meta 也处于第 3 阶段,它将有助于支持 Node.js 的用例,以及 模块解析提案 也将有助于消除浏览器和 Node.js 之间的差异。因此,您可以预期未来模块的使用会变得更加出色。

鸣谢

感谢所有对这篇文章提供反馈的人,以及他们的写作或讨论为这篇文章提供了信息,包括 Axel Rauschmayer、Bradley Farias、Dave Herman、Domenic Denicola、Havi Hoffman、Jason Weathersby、JF Bastien、Jon Coppeard、Luke Wagner、Myles Borins、Till Schneidereit、Tobias Koppers 和 Yehuda Katz,以及 WebAssembly 社区小组、Node 模块工作组和 TC39 的成员。

关于 Lin Clark

Lin 在 Mozilla 的高级开发部门工作,专注于 Rust 和 WebAssembly。

更多 Lin Clark 的文章...


20 条评论

  1. Plopz

    我发现当前 ES 模块的问题是很多像这样的语句。

    import {thing} from ‘../../../../thing/in/dir.js’;

    能够定义所有导入都相对于其的路径会非常棒。

    import {thing} from ‘thing/in/dir.js’;

    我认为 babel 有类似的东西,使用 ‘@/thing/…’,但我没有使用 babel。

    2018 年 3 月 29 日 上午 06:10

    1. George

      它被称为模块解析,webpack 也可以做到:https://webpack.js.cn/configuration/resolve/。看看吧 :)

      2018 年 4 月 1 日 上午 03:04

      1. Candy

        是的,我们使用它。也为另一个不同的文件夹写了另一个自定义导入前缀。写下所有的自定义导入前缀吧!

        2018 年 4 月 3 日 下午 21:26

  2. Tod

    感谢 Lin,我感谢你写得这么好的文章。
    Tod

    2018 年 3 月 29 日 上午 07:25

  3. Thomazella

    一如既往地信息丰富,做得很好!

    2018 年 3 月 29 日 上午 08:19

  4. Daniel Worsnup

    感谢你写了这篇很棒的文章!信息量很大。关于浏览器在解析阶段遇到模块时需要获取导入模块的本质,你能推荐一些让浏览器提前知道哪些模块需要获取的方法吗?

    2018 年 3 月 29 日 上午 08:59

    1. Mark

      以及 http2 服务器推送

      2018 年 3 月 30 日 上午 11:06

  5. harikrishnan

    很棒的文章!感谢你解开了 ES 模块的神秘面纱。我喜欢关于实时绑定的部分。

    2018 年 3 月 29 日 下午 13:13

  6. Rick Marshall

    感谢 Lin。
    传统上,我使用变量和 lambda,然后在 window.onload 事件触发时调用它们
    https://medium.com/@rickmarshall_57431/javascript-function-pattern-78dce6d2786
    从表面上看,这种模式似乎可以实现所有相同目标(至少对我自己的代码而言)。
    你有没有任何关于如何使用你的模块来替代它的例子?
    这可能需要更高级的文章,但 ECMAScript 也具有与模块需要解析的显示相关的异步和关键交互。

    2018 年 3 月 29 日 下午 15:20

  7. Billy

    非常棒的文章。感谢你花时间写了一篇清晰的文章,卡通也很好。

    2018 年 3 月 29 日 下午 18:46

  8. LZX

    感谢你生动的卡通,使 ES 模块机制清晰易懂!

    2018 年 3 月 29 日 下午 19:38

  9. Dummy

    Node 决定‘require’ 加载旧代码,‘import’ 加载新代码,而不是 ‘import “something.mjs”‘ 加载新代码,‘”import somethingelse.js”‘ 加载旧代码,是否有原因?

    2018 年 3 月 30 日 上午 02:02

  10. Mariusz Nowak

    > 这与 CommonJS 模块不同。在 CommonJS 中,整个导出对象在导出时被复制。

    从技术上讲,这是不正确的(或者至少是误导的)。CJS 中没有复制任何东西。值只是通过引用传递,这等效于 `importedValue = exportedValue`

    尽管如此,由于 `require` 始终返回*当前*导出的值,它并不能阻止 CJS 模块导出随时间推移而发生变化的值(如果情况并非如此,那么 CJS 中的循环引用根本不可能出现)。

    因此,在 CJS 中,导出也可以动态更改。与 ESM 的区别在于,CJS 只导出一个值,并且没有创建方便的跨模块实时绑定。在 CJS 中,为了获取更新的值,我们需要再次调用 `require`(因此指的是第一句话的调用:`importedValue = exportedValue` 再次)。

    2018 年 3 月 30 日 上午 02:31

    1. Lin Clark

      据我所知,使用 `require` 多次来获取同一个模块,作为更新导入值的一种方式,这不是 Node 的习惯用法。

      你可以了解更多关于 CJS 的循环依赖限制这里。你也可以尝试我给出的例子,看看这些类型的循环依赖如何在 Node 中不起作用。

      2018 年 3 月 30 日 上午 09:44

  11. Albino Tonnina

    很棒的文章!很喜欢它!

    2018 年 3 月 30 日 上午 02:54

  12. Manuel Ramos Martínez

    感谢你写了这篇文章。当我阅读它时,它看起来非常熟悉。就像 Angular 5!

    2018 年 3 月 30 日 上午 03:54

  13. Lennie

    那么,在网页上加载最快的速度是列出所有在(模板)HTML 中使用的模块吗?这样它就可以同时获取它们吗?

    2018 年 3 月 30 日 上午 05:00

  14. Ashish

    很棒的阅读。谢谢!

    2018 年 3 月 30 日 上午 05:14

  15. Mike

    感谢 Clark 女士!这个解释很棒。现在我只需要等待 Firefox 60...

    2018 年 3 月 30 日 上午 09:21

  16. George Brata

    又一篇很棒的文章,非常非常有用,卡通也容易理解。我已经分享给我的所有朋友,让他们了解它。感谢 Mozilla,但尤其感谢你,Lin。

    2018 年 4 月 1 日 上午 03:06

本文评论已关闭。