漫画:深入浅出 ES 模块(上篇)

达芬奇密码2018-06-27 13:33

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

ES 模块为 JavaScript 提供了官方标准化的模块系统。然而,这中间经历了一些时间 —— 近 10 年的标准化工作。

但等待已接近尾声。随着 5 月份 Firefox 60 发布(目前为 beta 版),所有主流浏览器都会支持 ES 模块,并且 Node 模块工作组也正努力在 Node.js 中增加 ES 模块支持。同时用于 WebAssembly 的 ES 模块集成 也在进行中。

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

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

模块要解决什么问题?

可以这样说,JavaScript 编程就是管理变量。所做的事就是为变量赋值,或者在变量上做加法,或者将两个变量组合在一起并放入另一个变量中。

因为你的代码中很多都是关于改变变量的,你如何组织这些变量会对你编码方式以及代码的可维护性产生很大的影响。

一次只需要考虑几个变量就可以让事情变得更简单。JavaScript 有一种方法可以帮助你做到这点,称为作用域。由于 JavaScript 中的作用域规则,一个函数无法访问在其他函数中定义的变量。

这很好。这意味着当你写一个函数时,只需关注这个函数本身。你不必担心其他函数可能会对函数内的变量做些什么。

尽管如此,它仍然存在缺陷。这让在函数间共享变量变得有点困难。

如果你想在作用域外共享变量呢?处理这个问题的一种常见方法是将它放在更外层的作用域里……例如,在全局作用域中。

你可能还记得 jQuery 时代的这种情况。在加载任何 jQuery 插件之前,你必须确保 jQuery 在全局作用域中。

这在有效的同时也产生了副作用。

首先,所有的 script 标签都需要按照正确的顺序排列。所以你必须小心确保那个顺序没被打乱。

如果你搞乱了这个顺序,那么在运行的过程中,你的应用程序就会抛出一个错误。当函数寻找它期望的 jQuery 时 —— 在全局作用域里 —— 却没有找到它,它会抛出一个错误并停止运行。

这使得维护代码非常棘手。这让移除老代码或老 script 标签变成了一场轮盘赌游戏。你不知道会弄坏什么。代码的不同部分之间的依赖关系是隐式的。任何函数都可以获取全局作用域中的任何东西,所以你不知道哪些函数依赖于哪些 script 标签。

第二个问题是,因为这些变量位于全局范围内,所以全局范围内的代码的每个部分都可以更改该变量。恶意代码可能会故意更改该变量,以使你的代码执行某些你并不想要的操作,或者非恶意代码可能会意外地弄乱你的变量。

模块是如何提供帮助的?

模块为你提供了更好的方法来组织这些变量和函数。通过模块,你可以将有意义的变量和函数分组在一起。

这会将这些函数和变量放入模块作用域。模块作用域可用于在模块中的函数之间共享变量。

但是与函数作用域不同,模块作用域也可以将其变量提供给其他模块。它们可以明确说明模块中的哪些变量、类或函数应该共享。

当将某些东西提供给其他模块时,称为 export。一旦你声明了一个 export,其他模块就可以明确地说它们依赖于该变量、类或函数。

因为这是显式的关系,所以当删除了某个模块时,你可以确定哪些模块会出问题。

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

由于模块非常有用,历史上有多次向 JavaScript 添加模块功能的尝试。如今有两个模块系统正在大范围地使用。CommonJS(CJS)是 Node.js 历史上使用的。ESM(EcmaScript 模块)是一个更新的系统,已被添加到 JavaScript 规范中。浏览器已经支持了 ES 模块,并且 Node 也正在添加支持。

让我们来深入了解这个新模块系统的工作原理。

ES 模块如何工作

使用模块开发时,会建立一个依赖图。不同依赖项之间的连接来自你使用的各种 import 语句。

浏览器或者 Node 通过 import 语句来确定需要加载什么代码。你给它一个文件来作为依赖图的入口。之后它会随着 import 语句来找到所有剩余的代码。

但浏览器并不能直接使用文件本身。它需要把这些文件解析成一种叫做模块记录(Module Records)的数据结构。这样它就知道了文件中到底发生了什么。

之后,模块记录需要转化为模块实例(module instance)。一个实例包含两个部分:代码和状态。

代码基本上是一组指令。就像是一个告诉你如何制作某些东西的配方。但你仅依靠代码并不能做任何事情。你需要将原材料和这些指令组合起来使用。

什么是状态?状态就是给你这些原材料的东西。指令是所有变量在任何时间的实际值的集合。当然,这些变量只是内存中保存值的数据块的名称而已。

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

我们需要的是每个模块的模块实例。模块加载就是从此入口文件开始,生成包含全部模块实例的依赖图的过程。

对于 ES 模块来说,这主要有三个步骤:

  1. 构造 —— 查找、下载并解析所有文件到模块记录中。
  2. 实例化 —— 在内存中寻找一块区域来存储所有导出的变量(但还没有填充值)。然后让 export 和 import 都指向这些内存块。这个过程叫做链接(linking)。
  3. 求值 —— 运行代码,在内存块中填入变量的实际值。

人们说 ES 模块是异步的。你可以把它当作时异步的,因为整个过程被分为了三阶段 —— 加载、实例化和求值 —— 这三个阶段可以分开完成。

这意味着 ES 规范确实引入了一种在 CommonJS 中并不存在的异步性。我稍后会再解释,但是在 CJS 中,一个模块和其下的所有依赖会一次性完成加载、实例化和求值,中间没有任何中断。

当然,这些步骤本身并不必须是异步的。它们可以以同步的方式完成。这取决于谁在做加载这个过程。这是因为 ES 模块规范并没有控制所有的事情。实际上有两部分工作,这些工作分别由不同的规范控制。

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

是加载器来获取文件。加载器在另一个不同的规范中定义。对于浏览器来说,这个规范是 HTML 规范。但是你可以根据所使用的平台有不同的加载器。

加载器还精确控制模块的加载方式。它调用 ES 模块的方法 —— ParseModuleModule.InstantiateModule.Evaluate。这有点像通过提线来控制 JS 引擎这个木偶。

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

构造

在构造阶段,每个模块都会经历三件事情。

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

查找文件并获取

加载器将负责查找文件并下载它。首先它需要找到入口文件。在 HTML 中,你通过使用 script 标记来告诉加载器在哪里找到它。

但它如何找到剩下的一堆模块 —— 那些 main.js 直接依赖的模块?

这就要用到 import 语句了。import 语句中的一部分称为模块标识符。它告诉加载器哪里可以找到余下的模块。

关于模块标识符有一点需要注意:它们有时需要在浏览器和 Node 之间进行不同的处理。每个宿主都有自己的解释模块标识符字符串的方式。要做到这一点,它使用了一种称为模块解析的算法,它在不同平台之间有所不同。目前,在 Node 中可用的一些模块标识符在浏览器中不起作用,但这个问题正在被修复

在修复之前,浏览器只接受 URL 作为模块标识符。它们将从该 URL 加载模块文件。但是,这并不是在整个依赖图上同时发生的。在解析文件前,并不知道这个文件中的模块需要再获取哪些依赖……并且在获取文件之前无法解析那个文件。

这意味着我们必须逐层遍历依赖树,解析一个文件,然后找出它的依赖关系,然后查找并加载这些依赖。

如果主线程要等待这些文件的下载,那么很多其他任务将堆积在队列中。

这是就是为什么当你使用浏览器时,下载部分需要很长时间。

基于此图表

像这样阻塞主线程会让采用了模块的应用程序速度太慢而无法使用。这是 ES 模块规范将算法分为多个阶段的原因之一。将构造过程单独分离出来,使得浏览器在执行同步的初始化过程前可以自行下载文件并建立自己对于模块图的理解。

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

CommonJS 可以以不同的方式处理的原因是,从文件系统加载文件比在 Internet 上下载需要少得多的时间。这意味着 Node 可以在加载文件时阻塞主线程。而且既然文件已经加载了,直接实例化和求值(在 CommonJS 中并不区分这两个阶段)就理所当然了。这也意味着在返回模块实例之前,你遍历了整棵树,加载、实例化和求值了所有依赖关系。

CommonJS 方法有一些隐式特性,稍后我会解释。其中一个是,在使用 CommonJS 模块的 Node 中,可以在模块标识符中使用变量。在查找下一个模块之前,你执行了此模块中的所有代码(直至 require 语句)。这意味着当你去做模块解析时,变量会有值。

但是对于 ES 模块,在进行任何求值之前,你需要事先构建整个模块图。这意味着你的模块标识符中不能有变量,因为这些变量还没有值。

但有时候在模块路径使用变量确实非常有用。例如,你可能需要根据代码的运行情况或运行环境来切换加载某个模块。

为了让 ES 模块支持这个,有一个名为 动态导入 的提案。有了它,你可以像 import(`${path}`/foo.js 这样使用 import 语句。

它的原理是,任何通过 import() 加载的的文件都会被作为一个独立的依赖图的入口。动态导入的模块开启一个新的依赖图,并单独处理。

有一点需要注意,同时存在于这两个依赖图中的模块都将共享同一个模块实例。这是因为加载器会缓存模块实例。对于特定全局作用域中的每个模块,都将只有一个模块实例。

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

加载器使用一种叫做模块映射的东西来管理这个缓存。每个全局作用域都在一个单独的模块映射中跟踪其模块。

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

如果另一个模块依赖于同一个文件会发生什么?加载器将查找模块映射中的每个 URL。如果看到了 fetching,它就会直接开始下一个 URL。

但是模块映射不只是跟踪哪些文件正在被获取。模块映射也可以作为模块的缓存,接下来我们就会看到。

相关阅读:漫画:深入浅出 ES 模块 (下篇)

本文来自网易实践者社区,经作者徐子航授权发布。