原文:ES modules: A cartoon deep-dive, Lin Clarkhtml
ES modules(ESM) 是 JavaScript 官方的标准化模块系统。
然而,它在标准化的道路上已经花费了近 10 年的时间。node
可喜的是,标准化之路立刻就要完成了。等到 2018 年 5 月 Firefox 60 发布以后,全部的主流浏览器就都支持 ESM 了。同时,Node 模块工做小组也正在为 Node.js 添加 ESM 支持。为 WebAssembly 提供 ESM 集成的工做也正在如火如荼的进行。git
许多 JS 开发者都知道,对 ESM 的讨论从开始至今一直都没停过。可是不多有人真正理解 ESM 的工做原理。github
今天,让咱们来梳理梳理 ESM 到底解决了什么问题,以及它跟其余模块系统之间有什么区别。算法
说到 JS 编程,其实说的就是如何管理变量。
编程的过程都是关于如何给变量赋值,要么直接赋值给变量,要么是把两个变量结合起来而后再把结果赋值给另外一个变量。编程
由于大部分代码都是关于改变变量的,因此如何组织这些变量就直接影响了编码质量,以及维护它们的成本。浏览器
若是代码中仅有少许的变量,那么组织起来实际上是很简单的。
JS 自己就提供了一种方式帮你组织变量,称为函数做用域。由于函数做用域的缘故,一个函数没法访问另外一个函数中定义的变量。缓存
这种方式是颇有效的。它使得咱们在写一个函数的时候,只须要考虑当前函数,而没必要担忧其它函数可能会改变当前函数的变量。
不过,它也有很差的地方。它会让咱们很难在不一样函数之间共享变量。服务器
若是咱们想跟当前函数之外的函数共享变量要怎么办呢?一种通用的作法是把要共享的变量提高到上一层做用域,好比全局做用域。网络
在 jQuery 时代这种提高作法至关广泛。在咱们加载任何 jQuery 插件以前,咱们必须确保 jQuery 已经存在于全局做用域。
这种作法也确实行之有效,可是也带来了使人烦恼的影响。
首先,全部的 <script>
必须以正确的顺序排列,开发者必须很是谨慎地确保没有任何一个脚本排列错误。
若是排列错了,那么在运行过程当中,应用将会抛出错误。当函数在全局做用域寻找 jQuery 变量时,若是没有找到,那么它将会抛出异常错误,而且中止继续运行。
这同时也使得代码的后期维护变得困难。
它会使得移除旧代码或者脚本标签变得充满不肯定性。你根本不知道移除它会带来什么影响。代码之间的依赖是不透明的。任何函数均可能依赖全局做用域中的任何变量,以致于你也不知道哪一个函数依赖哪一个脚本。
其次,因为变量存在于全局做用域,因此任何代码均可以改变它。
恶意的代码可能会故意改变全局变量,从而让你的代码作出危险行为。又或者,代码可能不是恶意的,可是却无心地改变了你指望的变量。
模块化为你提供了一种更好的方式来组织变量和函数。你能够把相关的变量和函数放在一块儿组成一个模块。
这种组织方式会把函数和变量放在模块做用域中。模块中的函数能够经过模块做用域来共享变量。
不过,与函数做用域不一样的是,模块做用域还提供了一种暴露变量给其余模块使用的方式。模块能够明确地指定哪些变量、类或函数对外暴露。
对外暴露的过程称为导出。一旦导出,其余模块就能够明确地声称它们依赖这些导出的变量、类或者函数。
由于这是一种明确的关系,因此你能够很简单地辨别哪些代码能移除,哪些不能移除。
拥有了在模块之间导出和导入变量的能力以后,你就能够把代码分割成更小的、能够独立运行地代码块了。而后,你就能够像搭乐高积木同样,基于这些代码块,建立全部不一样类型的应用。
因为模块化是很是有用的,因此历史上曾经屡次尝试为 JS 添加模块化的功能。不过截止到目前,真正获得普遍使用的只有两个模块系统。
一个是 Node.js 使用的 CommonJS (CJS);另外一个是 JS 规范的新模块系统 EcmaScript modules(ESM),Node.js 也正在添加对 ESM 的支持。
下面就让咱们来深刻理解下这个新的模块系统是如何工做的。
当你在使用模块进行开发时,实际上是在构建一张依赖关系图。不一样模块之间的连线就表明了代码中的导入语句。
正是这些导入语句告诉浏览器或者 Node 该去加载哪些代码。
咱们要作的是为依赖关系图指定一个入口文件。从这个入口文件开始,浏览器或者 Node 就会顺着导入语句找出所依赖的其余代码文件。
可是呢,浏览器并不能直接使用这些代码文件。它须要解析全部的文件,并把它们变成一种称为模块记录(Module Record)的数据结构。只有这样,它才知道代码文件中到底发生了什么。
解析以后,还须要把模块记录变成一个模块实例。模块实例会把代码和状态结合起来。
所谓代码,基本上是一组指令集合。它就像是制做某样东西的配方,指导你该如何制做。
可是它自己并不能让你完成制做。你还须要一些原料,这样才能够按照这些指令完成制做。
所谓状态,它就是原料。具体点,状态是变量在任什么时候候的真实值。
固然,变量实际上就是内存地址的别名,内存才是正在存储值的地方。
因此,能够看出,模块实例中代码和状态的结合,就是指令集和变量值的结合。
对于模块而言,咱们真正须要的是模块实例。
模块加载会从入口文件开始,最终生成完整的模块实例关系图。
对于 ESM ,这个过程包含三个阶段:
你们都说 ESM 是异步的。
由于它把整个过程分为了三个不一样的阶段:加载、实例化和运行,而且这三个阶段是能够独立进行的。
这意味着,ESM 规范确实引入了一种异步方式,且这种异步方式在 CJS 中是没有的。
后面咱们会详细说到为何,然而在 CJS 中,一个模块及其依赖的加载、实例化和运行是一块儿顺序执行的,中间没有任何间断。
不过,这三个阶段自己是不必异步化。它们能够同步执行,这取决于它是由谁来加载的。由于 ESM 标准并无明确规范全部相关内容。实际上,这些工做分为两部分,而且分别是由不一样的标准所规范的。
其中,ESM 标准 规范了如何把文件解析为模块记录,如何实例化和如何运行模块。可是它没有规范如何获取文件。
文件是由加载器来提取的,而加载器由另外一个不一样的标准所规范。对于浏览器来讲,这个标准就是 HTML。可是你还能够根据所使用的平台使用不一样的加载器。
加载器也同时控制着如何加载模块。它会调用 ESM 的方法,包括 ParseModule
、Module.Instantiate
和 Module.Evaluate
。它就像是控制着 JS 引擎的木偶。
下面咱们将更加详细地说明每一步。
对于每一个模块,在构建阶段会作三个处理:
加载器负责定位文件而且提取。首先,它须要找到入口文件。在 HTML 中,你能够经过 <script>
标签来告诉加载器。
可是,加载器要如何定位 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)。若是按照不一样的解析目标来解析相同的文件,会获得不一样的结果。所以,在解析文件以前,必须清楚地知道所解析的文件类型是什么,无论它是否是一个模块文件。
在浏览器中,知道文件类型是很简单的。只须要在 <script>
脚本中添加 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 设计之初就考虑到的一大缘由。也正是这种分段设计使其成为可能。
等到 2018 年 5 月 Firefox 60 发布后,全部的主流浏览器就都默认支持 ESM 了。Node 也正在添加 ESM 支持,为此还成立了工做小组来专门研究 CJS 和 ESM 之间的兼容性问题。
因此,在将来你能够直接在 <script>
标签中使用 type="module"
,而且在代码中使用 import
和 export
。
同时,更多的模块功能也正在研究中。
好比动态导入提案已经处于 Stage 3 状态;import.meta
也被提出以便 Node.js 对 ESM 的支持;模块定位提案 也致力于解决浏览器和 Node.js 之间的差别。
相信在不久的将来,跟模块一块儿玩耍将会变成一件更加愉快的事!