原文:ES modules: A cartoon deep-dive, Lin Clark
ES modules(ESM) 是 JavaScript 官方的标准化模块系统。
然而,它在标准化的道路上已经花费了近 10 年的时间。
可喜的是,标准化之路马上就要完成了。等到 2018 年 5 月 Firefox 60 发布之后,所有的主流浏览器就都支持 ESM 了。同时,Node 模块工作小组也正在为 Node.js 添加 ESM 支持。为 WebAssembly 提供 ESM 集成的工作也正在如火如荼的进行。
许多 JS 开发者都知道,对 ESM 的讨论从开始至今一直都没停过。但是很少有人真正理解 ESM 的工作原理。
今天,让我们来梳理梳理 ESM 到底解决了什么问题,以及它跟其他模块系统之间有什么区别。
为何要模块化
说到 JS 编程,其实说的就是如何管理变量。
编程的过程都是关于如何给变量赋值,要么直接赋值给变量,要么是把两个变量结合起来然后再把结果赋值给另一个变量。
因为大部分代码都是关于改变变量的,所以如何组织这些变量就直接影响了编码质量,以及维护它们的成本。
如果代码中仅有少量的变量,那么组织起来其实是很简单的。
JS 本身就提供了一种方式帮你组织变量,称为函数作用域。因为函数作用域的缘故,一个函数无法访问另一个函数中定义的变量。
这种方式是很有效的。它使得我们在写一个函数的时候,只需要考虑当前函数,而不必担心其它函数可能会改变当前函数的变量。
不过,它也有不好的地方。它会让我们很难在不同函数之间共享变量。
如果我们想跟当前函数以外的函数共享变量要怎么办呢?一种通用的做法是把要共享的变量提升到上一层作用域,比如全局作用域。
在 jQuery 时代这种提升做法相当普遍。在我们加载任何 jQuery 插件之前,我们必须确保 jQuery 已经存在于全局作用域。
这种做法也确实行之有效,但是也带来了令人烦恼的影响。
首先,所有的 必须以正确的顺序排列,开发者必须非常谨慎地确保没有任何一个脚本排列错误。
如果排列错了,那么在运行过程中,应用将会抛出错误。当函数在全局作用域寻找 jQuery 变量时,如果没有找到,那么它将会抛出异常错误,并且停止继续运行。
这同时也使得代码的后期维护变得困难。
它会使得移除旧代码或者脚本标签变得充满不确定性。你根本不知道移除它会带来什么影响。代码之间的依赖是不透明的。任何函数都可能依赖全局作用域中的任何变量,以至于你也不知道哪个函数依赖哪个脚本。
其次,由于变量存在于全局作用域,所以任何代码都可以改变它。
恶意的代码可能会故意改变全局变量,从而让你的代码做出危险行为。又或者,代码可能不是恶意的,但是却无意地改变了你期望的变量。
模块化的作用
模块化为你提供了一种更好的方式来组织变量和函数。你可以把相关的变量和函数放在一起组成一个模块。
这种组织方式会把函数和变量放在模块作用域中。模块中的函数可以通过模块作用域来共享变量。
不过,与函数作用域不同的是,模块作用域还提供了一种暴露变量给其他模块使用的方式。模块可以明确地指定哪些变量、类或函数对外暴露。
对外暴露的过程称为导出。一旦导出,其他模块就可以明确地声称它们依赖这些导出的变量、类或者函数。
因为这是一种明确的关系,所以你可以很简单地辨别哪些代码能移除,哪些不能移除。
拥有了在模块之间导出和导入变量的能力之后,你就可以把代码分割成更小的、可以独立运行地代码块了。然后,你就可以像搭乐高积木一样,基于这些代码块,创建所有不同类型的应用。
由于模块化是非常有用的,所以历史上曾经多次尝试为 JS 添加模块化的功能。不过截止到目前,真正得到广泛使用的只有两个模块系统。
一个是 Node.js 使用的 CommonJS (CJS);另一个是 JS 规范的新模块系统 EcmaScript modules(ESM),Node.js 也正在添加对 ESM 的支持。
下面就让我们来深入理解下这个新的模块系统是如何工作的。
ESM 原理
当你在使用模块进行开发时,其实是在构建一张依赖关系图。不同模块之间的连线就代表了代码中的导入语句。
正是这些导入语句告诉浏览器或者 Node 该去加载哪些代码。
我们要做的是为依赖关系图指定一个入口文件。从这个入口文件开始,浏览器或者 Node 就会顺着导入语句找出所依赖的其他代码文件。
但是呢,浏览器并不能直接使用这些代码文件。它需要解析所有的文件,并把它们变成一种称为模块记录(Module Record)的数据结构。只有这样,它才知道代码文件中到底发生了什么。
解析之后,还需要把模块记录变成一个模块实例。模块实例会把代码和状态结合起来。
所谓代码,基本上是一组指令集合。它就像是制作某样东西的配方,指导你该如何制作。
但是它本身并不能让你完成制作。你还需要一些原料,这样才可以按照这些指令完成制作。
所谓状态,它就是原料。具体点,状态是变量在任何时候的真实值。
当然,变量实际上就是内存地址的别名,内存才是正在存储值的地方。
所以,可以看出,模块实例中代码和状态的结合,就是指令集和变量值的结合。
对于模块而言,我们真正需要的是模块实例。
模块加载会从入口文件开始,最终生成完整的模块实例关系图。
对于 ESM ,这个过程包含三个阶段:
- 构建:查找,下载,然后把所有文件解析成模块记录。
- 实例化:为所有模块分配内存空间(此刻还没有填充值),然后依照导出、导入语句把模块指向对应的内存地址。这个过程称为链接(Linking)。
- 运行:运行代码,从而把内存空间填充为真实值。
大家都说 ESM 是异步的。
因为它把整个过程分为了三个不同的阶段:加载、实例化和运行,并且这三个阶段是可以独立进行的。
这意味着,ESM 规范确实引入了一种异步方式,且这种异步方式在 CJS 中是没有的。
后面我们会详细说到为什么,然而在 CJS 中,一个模块及其依赖的加载、实例化和运行是一起顺序执行的,中间没有任何间断。
不过,这三个阶段本身是没必要异步化。它们可以同步执行,这取决于它是由谁来加载的。因为 ESM 标准并没有明确规范所有相关内容。实际上,这些工作分为两部分,并且分别是由不同的标准所规范的。
其中,ESM 标准 规范了如何把文件解析为模块记录,如何实例化和如何运行模块。但是它没有规范如何获取文件。
文件是由加载器来提取的,而加载器由另一个不同的标准所规范。对于浏览器来说,这个标准就是 HTML。但是你还可以根据所使用的平台使用不同的加载器。
加载器也同时控制着如何加载模块。它会调用 ESM 的方法,包括 ParseModule
、Module.Instantiate
和 Module.Evaluate
。它就像是控制着 JS 引擎的木偶。
下面我们将更加详细地说明每一步。
构建
对于每个模块,在构建阶段会做三个处理:
- 确定要从哪里下载包含该模块的文件,也称为模块定位(Module Resolution)
- 提取文件,通过从 URL 下载或者从文件系统加载
- 解析文件为模块记录
下载模块
加载器负责定位文件并且提取。首先,它需要找到入口文件。在 HTML 中,你可以通过 标签来告诉加载器。
但是,加载器要如何定位 main.js
直接依赖的模块呢?
这个时候导入语句就派上用场了。导入语句中有一部分称为模块定位符(Module Specifier),它会告诉加载器去哪定位模块。
对于模块定位符,有一点要注意的是:它们在浏览器和 Node 中会有不同的处理。每个平台都有自己的一套方式来解析模块定位符。这些方式称为模块定位算法,不同的平台会使用不同的模块定位算法。
当前,一些在 Node 中能工作模块定位符并不能在浏览器中工作,但是已经有一项工作正在解决这个问题。
在这个问题被解决之前,浏览器只接受 URL 作为模块定位符。
它们会从 URL 加载模块文件。但是,这并不是在整个关系图上同时发生的。因为在解析完这个模块之前,你根本不知道它依赖哪些模块。而且在它下载完成之前,你也无法解析它。
这就意味着,我们必须一层层遍历依赖树,先解析文件,然后找出依赖,最后又定位并加载这些依赖,如此往复。
如果主线程正在等待这些模块文件下载完成,许多其他任务将会堆积在任务队列中,造成阻塞。这是因为在浏览器中,下载会耗费大量的时间。
而阻塞主线程会使得应用变得卡顿,影响用户体验。这是 ESM 标准把算法分成多个阶段的原因之一。将构建划分为一个独立阶段后,浏览器可以在进入同步的实例化过程之前下载文件然后理解模块关系图。
ESM 和 CJS 之间最主要的区别之一就是,ESM 把算法化为为多个阶段。
CJS 使用不同的算法是因为它从文件系统加载文件,这耗费的时间远远小于从网络上下载。因此 Node 在加载文件的时候可以阻塞主线程,而不造成太大影响。而且既然文件已经加载完成了,那么它就可以直接进行实例化和运行。所以在 CJS 中实例化和运行并不是两个相互独立的阶段。
这也意味着,你可以在返回模块实例之前,顺着整颗依赖树去逐一加载、实例化和运行每一个依赖。
CJS 的方式对 ESM 也有一些启发,这个后面会解释。
其中一个就是,在 Node 的 CJS 中,你可以在模块定位符中使用变量。因为已经执行了 require
之前的代码,所以模块定位符中的变量此刻是有值的,这样就可以进行模块定位的处理了。
但是对于 ESM,在运行任何代码之前,你首先需要建立整个模块依赖的关系图。也就是说,建立关系图时变量是还没有值的,因为代码都还没运行。
不过呢,有时候我们确实需要在模块定位符中使用变量。比如,你可能需要根据当前的状况加载不同的依赖。
为了在 ESM 中实现这种方式,人们已经提出了一个动态导入提案。该提案允许你可以使用类似 import(\`${path}/foo.js`)
的导入语句。
这种方式实际上是把使用 import()
加载的文件当成了一个入口文件。动态导入的模块会开启一个全新的独立依赖关系树。
不过有一点要注意的是,这两棵依赖关系树共有的模块会共享同一个模块实例。这是因为加载器会缓存模块实例。在特定的全局作用域中,每个模块只会有一个与之对应的模块实例。
这种方式有助于提高 JS 引擎的性能。例如,一个模块文件只会被下载一次,即使有多个模块依赖它。这也是缓存模块的原因之一,后面说到运行的时候会介绍另一个原因。
加载器使用模块映射(Module Map)来管理缓存。每个全局作用域都在一个单独的模块映射中跟踪其模块。
当加载器要从一个 URL 加载文件时,它会把 URL 记录到模块映射中,并把它标记为正在下载的文件。然后它会发出这个文件请求并继续开始获取下一个文件。
当其他模块也依赖这个文件的时候会发生什么呢?加载器会查找模块映射中的每一个 URL 。如果发现 URL 的状态为正在下载,则会跳过该 URL ,然后开始下一个依赖的处理。
不过,模块映射的作用并不仅仅是记录哪些文件已经下载。下面我们将会看到,模块映射也可以作为模块的缓存。
解析模块
至此,我们已经拿到了模块文件,我们需要把它解析为模块记录。
这有助于浏览器理解模块的不同部分。
一旦模块记录创建完成,它就会被记录在模块映射中。所以,后续任何时候再次请求这个模块时,加载器就可以直接从模块映射中获取该模块。
解析过程中有一个看似微不足道的细节,但是实际造成的影响却很大。那就是所有的模块都按照严格模式来解析的。
也还有其他的小细节,比如,关键字 await
在模块的最顶层是保留字, this
的值为 undefinded
。
这种不同的解析方式称为解析目标(Parse Goal)。如果按照不同的解析目标来解析相同的文件,会得到不同的结果。因此,在解析文件之前,必须清楚地知道所解析的文件类型是什么,不管它是不是一个模块文件。
在浏览器中,知道文件类型是很简单的。只需要在 脚本中添加
type="module"
属性即可。这告诉浏览器这个文件需要被解析为一个模块。而且,因为只有模块才能被导入,所以浏览器以此推测所有的导入也都是模块文件。
不过在 Node 中,我们并不使用 HTML 标签,所以也没办法通过 type
属性来辨别。社区提出一种解决办法是使用 .mjs
拓展名。使用该拓展名会告诉 Node 说“这是个模块文件”。你会看到大家正在讨论把这个作为解析目标。不过讨论仍在继续,所以目前仍不明确 Node 社区最终会采用哪种方式。
无论最终使用哪种方式,加载器都会决定是否把一个文件作为模块来解析。如果是模块,而且包含导入语句,那它会重新开始处理直至所有的文件都已提取和解析。
到这里,构建阶段差不多就完成了。在加载过程处理完成后,你已经从最开始只有一个入口文件,到现在得到了一堆模块记录。
下一步会实例化这些模块并且把所有的实例链接起来。
实例化
正如前文所述,一个模块实例结合了代码和状态。状态存储在内存中,所以实例化的过程就是把所有值写入内存的过程。
首先,JS 引擎会创建一个模块环境记录(Module Environment Record)。它管理着模块记录的所有变量。然后,引擎会找出多有导出在内存中的地址。模块环境记录会跟踪每个导出对应于哪个内存地址。
这些内存地址此时还没有值,只有等到运行后它们才会被填充上实际值。有一点要注意,所有导出的函数声明都在这个阶段初始化,这会使得后面的运行阶段变得更加简单。
为了实例化模块关系图,引擎会采用深度优先的后序遍历方式。
即,它会顺着关系图到达最底端没有任何依赖的模块,然后设置它们的导出。
最终,引擎会把模块下的所有依赖导出链接到当前模块。然后回到上一层把模块的导入链接起来。
这个过程跟 CJS 是不同的。在 CJS 中,整个导出对象在导出时都是值拷贝。
即,所有的导出值都是拷贝值,而不是引用。
所以,如果导出模块内导出的值改变了,导入模块中导入的值也不会改变。
相反,ESM 则使用称为实时绑定(Live Binding)的方式。导出和导入的模块都指向相同的内存地址(即值引用)。所以,当导出模块内导出的值改变后,导入模块中的值也实时改变了。
模块导出的值在任何时候都可以能发生改变,但是导入模块却不能改变它所导入的值,因为它是只读的。
举例来说,如果一个模块导入了一个对象,那么它只能改变该对象的属性,而不能改变对象本身。
ESM 采用这种实时绑定的原因是,引擎可以在不运行任何模块代码的情况下完成链接。后面会解释到,这对解决运行阶段的循环依赖问题也是有帮助的。
实例化阶段完成后,我们得到了所有模块实例,以及已完成链接的导入、导出值。
现在我们可以开始运行代码并且往内存空间内填充值了。
运行
最后一步是往已申请好的内存空间中填入真实值。JS 引擎通过运行顶层代码(函数外的代码)来完成填充。
除了填充值以外,运行代码也会引发一些副作用(Side Effect)。例如,一个模块可能会向服务器发起请求。
因为这些潜在副作用的存在,所以模块代码只能运行一次。
前面我们看到,实例化阶段中发生的链接可以多次进行,并且每次的结果都一样。但是,如果运行阶段进行多次的话,则可能会每次都得到不一样的结果。
这正是为什么会使用模块映射的原因之一。模块映射会以 URL 为索引来缓存模块,以确保每个模块只有一个模块记录。这保证了每个模块只会运行一次。跟实例化一样,运行阶段也采用深度优先的后序遍历方式。
那对于前面谈到的循环依赖会怎么处理呢?
循环依赖会使得依赖关系图中出现一个依赖环,即你依赖我,我也依赖你。通常来说,这个环会非常大。不过,为了解释好这个问题,这里我们举例一个简单的循环依赖。
首先来看下这种情况在 CJS 中会发生什么。
最开始时,main
模块会运行 require
语句。紧接着,会去加载 counter
模块。
counter
模块会试图去访问导出对象的 message
。不过,由于 main
模块中还没运行到 message
处,所以此时得到的 message
为 undefined
。JS 引擎会为本地变量分配空间并把值设为 undefined
。
运行阶段继续往下执行,直到 counter
模块顶层代码的末尾处。我们想知道,当 counter
模块运行结束后,message
是否会得到真实值,所以我们设置了一个超时定时器。之后运行阶段便返回到 main.js
中。
这时,message
将会被初始化并添加到内存中。但是这个 message
与 counter
模块中的 message
之间并没有任何关联关系,所以 counter
模块中的 message
仍然为 undefined
。
如果导出值采用的是实时绑定方式,那么 counter
模块最终会得到真实的 message
值。当超时定时器开始计时时,main.js
的运行就已经完成并设置了 message
值。
支持循环依赖是 ESM 设计之初就考虑到的一大原因。也正是这种分段设计使其成为可能。
ESM 的当前状态
等到 2018 年 5 月 Firefox 60 发布后,所有的主流浏览器就都默认支持 ESM 了。Node 也正在添加 ESM 支持,为此还成立了工作小组来专门研究 CJS 和 ESM 之间的兼容性问题。
所以,在未来你可以直接在 标签中使用
type="module"
,并且在代码中使用 import
和 export
。
同时,更多的模块功能也正在研究中。
比如动态导入提案已经处于 Stage 3 状态;import.meta
也被提出以便 Node.js 对 ESM 的支持;模块定位提案 也致力于解决浏览器和 Node.js 之间的差异。
相信在不久的未来,跟模块一起玩耍将会变成一件更加愉快的事!