ES modules 给 JavaScript 带来了一个官方的规范的模块化系统。将近花了10年的时间才完成了这个标准化的工做。node
咱们的等待即将结束。随着 Firefox 60 在今年5月的发布(目前是测试阶段),全部的主流浏览器都将支持 ES modules,与此同时,Node modules 工做小组目前正在尝试让 Node.js 可以支持 ES module。另外的,针对 WebAssembly 的 ES module 整合也正在进行。git
众多 JS 开发者都知道 ES modules 至今已经饱受争议。可是不多有人真正知道 ES modules 究竟是如何工做的。github
让咱们一块儿来看一下,ES modules 解决了什么问题,以及它究竟和其余模块化系统有什么区别。web
当咱们在写 JS 代码的时候会去思考通常如何处理变量。咱们的操做几乎彻底是为了给变量进行赋值或者是去将两个变量相加又或者是去将两个变量链接到一块儿而且将它们赋值给另一个变量。算法
因为咱们大部分的代码都仅仅是为了去改变变量的值,你如何去组织这些变量将对你写出怎样的代码以及如何更好的去维护这些代码产生巨大的影响。api
一次只用处理少许的变量将会让咱们的工做更容易。JS 自己提供了一种方法去帮助咱们这么作,叫做 做用域。因为 JS 中做用域的缘由,在每一个函数中不能去使用其余函数中定义的变量。浏览器
这很棒!这意味着当你在一个函数中编码时,你只须要考虑当前这个函数了。你没必要再担忧其余函数可能会对你的变量作什么了。缓存
虽然是这样没错,可是它也有很差的地方。这会让你很难去在不一样的函数之间去共享变量。数据结构
假使你确实想要在做用域外去共享你的变量,将会怎么样呢?一个经常使用的作法是去将它们放在一个外层的做用域。举个例子来讲,全局做用域。
你可能还记得下面这个在 jQuery 中的操做。在你加载 jQuery 以前,你不得不去把 jQuery 引入到全局做用域。
ok,能够正常运行了。可是这里存在相同的有争议的问题。
首先,你的 script 标签须要按正确的顺序摆放。而后你不得不很是的谨慎去确认没有人会去改变这个顺序。
若是你搞砸了这个顺序,而后你中间又使用到了前面的依赖,你的应用将会抛出一个错误~你函数将会四处查找 jQuery 在哪儿呢?在全局吗?而后,并无找到它,它将会抛出一个错误而后你的应用就挂掉了。
这将会让你的代码维护变得很是困难。这会使你在删除代码或者删除 script 标签的时候就像在摇色子同样。你并不知道这个何时会崩溃。不一样代码之间的依赖关系也不够明显。任何的函数都可以使用在全局的东西,因此你不知道哪些函数会依赖哪些 script 文件。
第二个问题是由于这些变量存在于全局做用域,全部的代码都存在于全局做用域内,而且能够去修改这些变量。多是去让这些变量变成恶意代码,从而故意执行非你本意的代码,还有多是变成非恶意的代码可是和你的变量有冲突。
模块化给你了一个方式去组织这些变量和函数。经过模块化,你能够把变量和函数合理的进行分组归类。
它把这些函数和变量放在一个模块的做用域内。这个模块的做用域可以让其中的函数一块儿分享变量。
可是不像函数的做用域,模块的做用域有一种方式去让它们的变量能过被其余模块所用。它们可以明确的安排其中哪些变量、类或者函数能够被其余模块使用。
当某些东西被设置成能被其余模块使用的时候,我须要一个叫作 export 的函数。一旦你使用了这个 export 函数,其余的模块就明确的知道它们依赖于哪些变量、类或者函数。
由于这是一个明确的关系。一旦你想移除一个模块时,你能够知道哪个模块将会被影响。
当你可以去使用 export 和 import 去处理不一样模块之间的变量时,你将会很容易的将你的代码分红一些小的部分,它们之间彼此独立的运行。而后你能够组合或者重组这些部分,就像乐高积木同样,去在不一样的应用中引用这些公用的模块。
因为模块化真的很是有用,因此这里有不少尝试去在 JS 中添加一些实用的模块。时至今日,有两个比较经常使用的模块化系统。一个是 Node.js 一直以来使用的 CommonJS。还有一个是晚一些可是专门为 JS 设计的 ES modules。浏览器端已经支持 ES modules,与此同时,Node 端正在尝试去支持。
让咱们一块儿来深刻了解一下,这个新的模块化系统究竟是如何进行工做的。
当你在开发这些模块时,你创建了一个图。
浏览器或者 Node 是经过这些引入声明,才明确的知道你须要加载哪些代码。你须要建立一个文件做为这个依赖关系的入口。以后就会根据那些 import 声明去查找剩余的代码。
可是这些文件不能直接被浏览器所用,这些文件会被解析成叫作模块记录的数据结构。
以后,这个模块记录将会被转变成一个模块实例。一个模块实例是由两部分组成:代码和状态。
代码是这一列指令的基础。它就像该如何去作的引导。可是只凭它你并不能作什么。你须要材料才可以去使用这些引导。
什么是状态?状态给你提供了材料!在任什么时候候,状态都会为你提供这些变量真实的值。固然这些变量都仅仅只是做为内存中存储这些值的别名而已(引用)。
模块实例将代码(一系列的引导)和状态组合起来(全部变量在内存中的值)。
咱们须要的是每一个模块拥有本身的模块实例。模块的加载过程是经过入口文件,找到整个模块实例的关系表。
对于 ES modules 来讲,这个过程须要三步:
人们都说 ES modules 是异步的。你彻底能够将它想成异步的,由于整个流程被分红三个不一样的阶段——加载,实例化以及求值——还有,这些步骤都是被分开执行的。
这就意味着,这个规则是一种异步的并且不从属于 CommonJS。我将在稍后解释它,在 CommonJS 中,一个模块的依赖是在模块加载以后才马上进行加载、实例化、求值的,中间不会有任何的打断(也就是同步)。
不管如何,这些步骤自己并不必定是异步的。它们能够被同步处理。这就依赖于加载的过程取决于什么?那是由于并非全部的东西都尊崇于 ES modules 规范。这实际上是两部分工做,从属于不一样的规范。
ES module 规范阐述了你应该如何将这些文件解析成模块记录,以及你应该如何去实例化和进行求值。可是,它没有说明如何去首先得到这些文件。
获取这些文件有相应的加载器,在不一样的说明中,加载器都被明肯定义了。对于浏览器,它的规范是HTML spec。可是你能够在不一样平台使用不一样的加载器。
加载器一样明确指出了控制模块应该如何被加载。这被称做 ES 模块方法 —— ParseModule
,Module.Instantiate
,以及Module.Evaluate
。这就像JS 引擎操纵的木偶同样。
如今咱们来一块儿探寻每一步到底发生了什么。
构建阶段每个模块发生了三件事。
加载器将会尽量的去找到文件而后去下载它。首先要去找到入口文件。在 HTML 中,你应该经过 script 标签告诉加载器入口文件在哪。
可是你应该如何查找到下一个模块化文件呢——那些 main.js 直接依赖的模块?
这个时候 import 声明就登场了,import 声明中有一部分叫作模块声明,它告诉了加载器能够在依次找到下一个模块。
关于模块声明有一点须要注意的是:在浏览器端和 Node 端有不一样的处理方式。每个宿主环境有它本身的方法去解释用来模块声明的字符串。为了完成这个,模块声明使用了一种叫作模块解释的算法去区分不一样的宿主环境。目前来讲,一些能在 Node 端运行的模块声明方法并不能在浏览器端执行,可是咱们有为了修复这个而在作的事情。
除非等到这个问题被修复,浏览器只能接受 URLs 做为模块声明。它们将从这个 URL 去加载这个模块文件。可是对于整个图而言,这并非一个同步行为。你没法知道哪个依赖你须要去获取直到你把整个文件都解析完成。以及你只有等获取到文件才能开始解析它。
这就意味着咱们必须去解析这个文件经过一层一层的解析这个依赖关系。而后查明全部的依赖关系,最后找到而且加载这些依赖。
若是主线程在等待每个文件下载,那么其余的任务将会排在主线程事件队列的后面。
持续的阻塞主线程就会像这样让你的应用在使用这些模块时变得很是的慢。这就是 ES modules 规范将这个算法拆分到多个阶段任务的缘由之一。在进行实例化以前把它的构建拆分到它本身的阶段而后容许浏览器去获取文件和理清依赖关系表。
ES modules 和 CommonJS modules 之间的区别之一就是将模块声明算法拆分到各个阶段去执行。
CommonJS 可以比 ES modules 的不一样是,经过文件系统去加载文件,要比从网上下载文件要花的时间少得多。这就意味着,Node 将会阻塞主线程当它正在加载文件的时候。只要文件加载完成,它就会去实例化而且去作求值操做(这也就是 CommonJS 不会在各个独立阶段去作的缘由)。这一样说明了,当你在返回模块实例以前,你就会遍历整个依赖关系树而后去完成加载、实例化以及对各个依赖进行求值的操做。
CommonJS 带来的一些影响,我会在稍后作更多的解释。在使用 CommonJS 的 Node 中你能够去使用变量进行模块声明。在你查找下一个模块以前,你将执行完这个模块全部的代码(直到经过require
去返回这个声明)。这就意味着你的这些变量将会在你去处理模块解析时被赋值。
可是在 ES modules 中,你将在执行模块解析和进行求值操做前就创建好整个模块依赖关系图表。这也就是说在你的模块声明时,你不能去使用这些变量,由于这些变量那时还并无被赋值。
可是有的时候咱们有很是须要去使用变量做为模块声明,举个例子,你可能会存在的一种状况是须要根据代码的执行效果来决定你须要引入哪一个模块。
为了能在 ES modules 这么去作,因而就存在一种叫作动态引入的提议。就像这样,你能够像这样去作引入声明import(`${path}/foo.js`)
。
这种经过import()
去加载任意文件的方法是把它做为每个单独的依赖图表的入口。这种动态引入模块会开始一个新的被单独处理的图。
即便如此,有一点要注意的是,对于任意模块而言全部的这些图都共享同一个模块实例。这是由于加载器会缓存这些模块实例。对于每个模块而言都存在于一个特殊的做用域内,这里面仅仅只会存在一个模块实例。
显然,这会减小引擎的工做量。举个例子,目标模块文件只会被加载一次即便此时有多个模块文件都依赖于它。(这就是缓存模块的缘由,咱们将看到的只是另外一次的求值过程而已)
加载器是经过一个叫作模块映射集合的东西来管理这个缓存。每个全局做用域经过栈来保存这些独立的模块映射集合。
当加载器准备去获取一个 URL 的时候,它会将这个 URL 放入模块映射中,而后对当前正在获取的文件作一个标记。而后它将发送一个请求(状态为 fetching),紧接着开始准备开始获取下一个文件。
<img src="http://o8gh1m5pi.bkt.clouddn.com/18-4-15/64202072.jpg"/ height="300px">
那当其余模块也依赖这个一样的文件时会发生什么呢?加载器将会在模块映射集合中去遍历这个 URL,若是它发现这个文件正在被获取,那么加载器会直接查找下一个 URL 去。
可是模块映射集合并不会去保存已经被获取过的文件的栈。接下来咱们会看到,模块映射集合对于模块而言一样也会被做为一个缓存。
如今咱们已经获取到了这个文件,咱们须要将它解析为一条模块记录。这会帮助浏览器知道这些模块不同的部分。
一旦这条模块记录被建立,它将会被放置到模块映射集合内。这就意味着,不管什么时候它在这被请求,加载器都会从映射集合中录取它。
在编译过程当中有一个看似微不足道的细节,可是它却有着重大的影响。全部的模块被解析后都会被当作在顶部有use strict
。还有另外两个细节。用例子来讲明吧,await
关键词会被预先储备到模块代码的最顶部,以及顶级做用域中this
是undefined
。
这种不一样的解析方式被称做“解析目标”。若是你解析相同的文件,可是目标不一样,你将会获得不一样的结果。所以,在开始解析你要解析的文件类型以前,你须要知道它是不是一个模块。
在浏览器中,这将很是的简单,你只须要在 script 标签中设置type="module"
。这就会高速浏览器,这个文件将被当作模块进行解析。以及只有模块才能被引用,浏览器知道任意引入都是模块。
可是在 Node 端,你不会使用到 HTML 标签,因此你没办法去使用type
属性。社区为此想出了一个解决办法,对于这类文件使用了mjs
的扩展名。经过这个扩展名告诉 Node,“这是一个模块”。你能够看出人们把这个视为解析目标的信号。这个讨论仍在进行中,如今还不清楚最后 Node 社区会采用哪一种信号。
不管哪一种方式,加载器将会决定是否将一个文件当作模块去处理。若是这是一个模块而且存在引用,那么它将会再次进行刚才的过程,直到全部的文件都被获取到,解析完。
下一步就是将这个模块实例化而且将全部的实例连接起来。
就像我以前所说的,一个实例是由代码和状态结合起来的。状态存在于内存中,因此实例化的步骤实际上是将全部的内容链接到内存中。
首先,JS 引擎会建立一条模块环境的记录。它会为这条模块记录管理变量。而后它在内存中的相关区域找到全部导出的值。这条模块环境记录将会跟踪内存中与每一个导出相关联的区域。
直到进行求值操做的时候这些内存区域才会被填充真实的值。对于这个规则,有一条警告:全部被导出的函数声明将会在这个阶段被初始化。这将会让求值过程变得更容易。
在实例化模块的过程,引擎将会采用深度优前后续遍历的算法。意思就是引擎一直往下直到图的最底部——也就是依赖关系的最底部(不依赖于其它了),而后才会去设置它们的导出值。
引擎完成了这个模块下全部导出的串联——模块依赖的全部导出。而后它就会返回顶部而后将这个模块全部的引入串联起来。
要注意的是导出和引入在内存中同一块区域。将全部导出都串联起来的前提是保证全部的引用能和与它对应的导出匹配(译者注:这也说明了 ES mdules 中的 import 属于引用)。
这不一样于 CommonJS 的模块化。在 CommonJS 中整个导出的对象是导出的一个复制。这就意味着,全部的值(比方说数字)都是导出值的复制。
这同时也说明,导出的模块若是在以后发生改变,那个引入该模块的模块并不会发现这个改变。
与此彻底相反的是,ES modules 使用的是活跃绑定,全部的模块引入和导出的全是指向相同的内存区域。意思就是说,一旦当模块被导出的值发生了改变,那么引入该模块的模块也会受到影响。
模块自己能够对导出的值作出变化,可是去引入它们的模块禁止去对这些值进行修改。话虽如此,可是若是一个模块引入的是一个对象,是能够去修改这个对象上的值的。
使用活跃绑定的缘由是,你能够将全部的模块串联起来,而不须要执行任何的代码。这将有助于你去使用我接下来要讲的循环依赖。
在这一步的最后,咱们已经成功对模块进行了实例化而且将内存中引入和导出的值串联起来。
如今,咱们能够开始对代码进行求值而且给它们在内存中的值进行赋值。
最后一步是对内存中的相关区域进行填充。JS 引擎是经过执行顶层代码去完成这件事的——在函数外的代码。
除了对内存中相关进行填充外,对代码进行求值也会形成反作用。好比说,模块可能会去调用一个服务。
因为潜在的反作用,你只须要对模块进行一次求值。与发生实例化时产生的连接不一样,在这里相同的结果能够被屡次使用。求值的结果也会随着你求值次数的不一样而产生不一样的结果。
这就是咱们去使用模块映射集合的缘由。模块映射集合缓存规范的 URL ,因此每个模块只存在一条对应的模块记录。这就保证了每个模块只被执行一次。和实例化的过程同样,它一样采用的是深度优前后序遍历的方法。
那么,咱们以前谈到的循环依赖呢?
在循环依赖中,你最终在图中是一个循环。一般来讲,这是一个比较长的循环。可是为了去解释这个问题,我将只会人为的去设计一个较短的循环去举个例子。
让咱们来看看在 CommonJS 的模块中是如何作的,首先,那个 main 模块会执行 require 声明。而后就去加载 counter 模块。
这个 counter 模块将会从导出的模块中去尝试获取 message,可是它在 main 模块中还并无被求值,因而它会返回 undefined。JS 引擎将会在内存中为它分配一个空间,而后将其赋值为 undefined。
求值操做会一直持续到 counter 模块顶层代码的末尾。咱们想知道最后是否可以获得 message 的值(在 main.js 进行求值操做以后),因而咱们设置一个 timeout, 而后对 main.js 进行求值。
message 这个变量将会被初始化而且被添加到内存中去。可是这二者并无任何关系,它仍在被 require 的模块中是 undefined。
若是导出的值被活跃绑定处理,counter 模块将在最后获得正确的值。当 timeout 被执行的时候,main.js 的求值操做已经被完成并且内存中的区域也被填充了真实的值。
去支持循环依赖是 ES modules去这么设计的缘由之一。正是这三个阶段让这一切变得可能。
随着 Firefox 60 在今年五月早期发布,全部的主流浏览器都将默认支持 ES modules。Node 也将会支持这种方式,工做组正在尝试去让 CommonJS 和 ES modules 进行兼容。
这就意味着你将能够去使用 script 标签 加上type=module
,去使用引入和导出。不管如何,愈来愈多的模块特性将会可使用。动态引入的提案已经明确进入 Stage 3 阶段,同时import.meta提案将会让 Node.js 支持这种写法。[解决模块问题的提案](module resolution proposal)也将平滑的同时支持浏览器和 Node.js。因此大家期待一下将来模块化的工做会作的愈来愈好。 翻译原文