注意:这篇文章讲的是正经的es module规范 及浏览器的实现!webpack项目中es module会被parse成commonjs,和这个没大关系!webpack
总结:web
ES模块加载的主要过程:算法
构造 —— 寻找,下载并解析全部文件成模块记录浏览器
实例化 —— 在内存中寻找位置存放全部导出的值(可是暂时还不要给他们填上具体的值)而后让导出和导入都指向这些内存中的位置。这个过程也叫作连接(linking)。缓存
求值 —— 执行编码并给实例化中所对应的内存的位置填充实际的值。服务器
ESmodules与CommonJS规范 modlue 实现的最大区别是: 网络
将构建阶段单独划分出来容许浏览器在处理同步的实例化阶段以前就可以下载文件而且构建模块依赖图。数据结构
CommonJS 不采用这用的方式是由于从文件系统中读取文件和从网络中下载文件比起来要快得多。这意味着 Node 能够在加载文件的时候阻塞主线程。而后既然文件以及加载完了,那么就顺其天然地实例化和求值(在 CommonJS 中不是分开的阶段)。这也意味着,在返回模块实例以前,须要遍历整棵模块依赖树并对任何模块依赖加载、实例化和求值。并发
原文:异步
(下文转)
编者按:本文由 Mactavish 翻译并发表于众成翻译
虽然花了近十年的标准化工做才走到这一步,ES 模块终于为 JavaScript 带来了正式的,标准化的模块系统。
漫长的等待终于要结束了,随着即将在五月发布的 Firefox 60 (目前尚处于 beta 版本中),全部的主流浏览器都即将支持 ES 模块,而且 Node 模块工做小组目前也正在为 Node.js 添加对 ES 模块的支持。同时,ES 模块对 WebAssembly 的支持也正在进行当中。
许多 JavaScript 的开发者都知道 ES 模块一直存在着一些争议,可是不多有人真正地知道 ES 模块的原理。
如今就让咱们来探索一下 ES 模块到底解决了什么问题以及它和其余模块系统的区别。
仔细想一想,使用 JavaScript 编码在于正确地管理变量,在于给变量赋值,或者给变量赋以数值或者合并两个变量并把它们赋值给另一个变量。
由于你的大多数代码都是在更改变量,如何组织这些变量将会对你的编码方式以及代码的维护产生重大的影响。
当一次只须要考虑几个变量的时候使得事情变得很是简单,JavaScript 有一个方式来帮助你实现这个目标,那就是 —— 做用域。由于做用域的存在,函数不能访问 定义在其余函数内部的变量。
这很棒。这意味着当你专一于实现一个函数的时候,你只须要专一于实现这个函数,而不须要担忧其余的函数会影响到你这个函数里的变量。
不过,它也有一个缺陷,它使得不一样的函数之间共享变量变得更加困难。
那么假如你的确想要在做用域以外共享你的变量呢?一般的作法是将它放在当前做用域之上,好比:全局做用域。
或许你还记得使用 jQuery 的那些日子,在你加载任何 jQuery 的插件以前,你必须确保 jQuery 已经存在于全局做用域内了。
这是可行的,可是会产生一些烦人的问题。
首先,你全部的 script 标签都必须放置于一个正确的顺序。那么你就必须很当心并确保这些脚本之间不会互相影响。
若是你确实不当心搞乱了顺序,那么在代码运行的时候,你的应用就会抛出异常。当函数寻找 jQuery 对象的存在 —— 也就是全局做用域之下,可是却找不到的时候,函数就会报错并中止执行。
这让代码维护变得棘手。移除旧的代码或者是 script 标签就像是玩赌场转盘同样。你没法预料到什么代码可能崩溃。代码之间的依赖关系变得隐蔽。任何函数均可以获取到全局做用域上的任何东西,因此你并无办法知道哪一个函数依赖于哪一个 script 标签。
其次,因为你的变量都存在于全局做用域上,全部处于这个做用域之上的代码均可以改变这些变量。恶意代码能够经过更改这些变量来让你的代码作并不是你本意的事情,或者非恶意的代码会不当心破坏你的变量。
模块为你提供了一个更加好的方式来组织这些变量和方法。有了模块,你能够将这些有意义的函数和变量组织在一块儿。
模块会将这些函数和变量放入一个模块做用域当中。模块做用域使得模块中的不一样函数可以共享这些变量。
可是不一样与函数做用域,模块做用域有一种方法可以使得其余的模块也能够访问这个模块的变量。他们能够显式地指定模块中的哪些变量,类或者是函数能够被其余模块访问。
当一些东西对其余模块可用的时候,这叫作 "导出(export)"。当模块的导出存在的时候,其余模块就可以显式地指定它们依赖于这个模块的某些变量,类或者函数。
由于存在这种显式的关系,你能够明确的指出当你去掉了另一个(导出),哪一个模块会崩溃掉。
一旦拥有了这种能在模块之间导出和导入变量的能力,把你的代码分割成更小而且可以互相之间独立工做的代码块就变得很容易了。 而后你就能够结合或者重组这些代码块,像组合乐高积木同样,来使用一样的模块建立不一样的应用。
正由于模块如此地有用,已经存在不少给 JavaScript 添加模块的尝试。目前,有两种模块系统被普遍地使用着。CommonJS(CJS) 曾经被 Node.js 所使用。ESM(ECMAScript 模块)是一个更新的模块系统,并加入到 JavaScript 的规范当中。浏览器已经支持 ES 模块了,Node.js 也正在添加对它的支持。
如今,就让咱们更加深刻地来看一下这个新的模块系统是如何运做的。
当使用模块来开发的时候,会创建一个模块模块依赖图。不一样依赖之间联系来自于你使用的任何 import 语句。
这些 import 语句是浏览器或者 Node 确切地知道你须要加载什么样的代码的关键之处。你须要提供一个文件来做为依赖图的入口。 从这个入口开始,根据这些 import 语句就能够找剩余所须要的代码。
可是浏览器并不能直接使用这些文件自己。它必需要通过解析并转换成一种叫作 "模块记录(Module Records)"的数据结构。只有这样,浏览器才能确切地知道这个文件里发生了什么。
在这以后,模块记录须要转变成模块实例。模块实例包含了两个要素:编码(code)和状态(state)
编码基本上就是一些系列的指令。它就像配方同样。可是只有配方自己,什么都作不了,因此还须要一些原材料来配合这些指令。
什么是状态?状态就提供了这些原材料。状态就是这些变量在任什么时候间点的具体值。固然,这些变量不过是内存中保存这些变量的容器的别名。
因此模块实例就结合了编码(一系列的指令)和状态(全部的变量的值)。
咱们须要的是每个模块的模块实例。模块加载的过程就是从入口文件开始最后获得整个模块实例的依赖图。
对于 ES 模块来讲,这个过程主要分三步来进行:
构造 —— 寻找,下载并解析全部文件成模块记录
实例化 —— 在内存中寻找位置存放全部导出的值(可是暂时还不要给他们填上具体的值)而后让导出和导入都指向这些内存中的位置。这个过程也叫作连接(linking)。
求值 —— 执行编码并给实例化中所对应的内存的位置填充实际的值。
人们说 ES 模块是异步的。你能够认为它是异步的由于实际的运做被分红了三个不一样的阶段 —— 加载,实例化以及求值,而这些阶段均可以分开完成。
这意味着规范确实引入了一种在 CommonJS 中没有的异步。稍后我会做更多的解释,可是在 CJS 中,一个模块下游的依赖关系是当即加载,实例化并求值的,不存在任何的间断。
可是,这些步骤不必定要是异步的,它们也能够以同步的方式完成。这取决于用什么来加载。这是由于不是全部的东西都是由 ES 模块规范来定义的。实际上有两部分工做,分别由不一样的规范来覆盖。
ES 模块规范 说明了应该如何将文件解析成模块记录,以及如何实例化和对模块求值。然而,它并无指明如何获取这个模块。
加载文件的是模块加载器(loader), 而加载器由不一样的规范来指定。对于浏览器来讲,这个规范就是 HTML 规范。可是,基于你使用的不一样的平台能够有不一样的加载器。
加载器同时还精准地控制着模块的加载方式。这些方法叫作 ES 模块方法 —— ParseModule, Module.Instantiate 以及 Module.Evaluate。这有点像一个提线木偶操做师操做着 JS 引擎。
如今让咱们更加详细地介绍每一步的过程。
对于每个模块,在构造过程都会经历这三个过程:
找到在哪里下载包含该模块的文件(也称做模块解析)
获取文件(经过 url 从文件系统中下载)
将这些文件解析成模块记录
加载器会处理查找和下载文件的过程,首先它须要找到入口文件。在 HTML 当中,你经过 script 标签来告诉加载器哪里去查找。
可是,它如何找到接下来的一堆模块呢 —— main.js 直接依赖的模块。
这就是 import 语句发挥做用的地方。import 语句的其中一部分叫作模块标识符,它会告诉加载器去哪寻找下一个模块。
关于模块标识符值得一提的地方是:有时候它们须要在浏览器和 Node 之间作不一样的处理。每一个宿主都有各自的方法来解析模块标识符的字符串。为了达到这个目的,它使用了名为模块解析算法的东西,而这个算法根据平台的不一样也有所不一样。目前来讲,一些在 Node 中可以正常解析的模块标识符没法在浏览器中正常解析,可是一个致力于修复它的工做正在进行中。
直到这个问题被修复以前,浏览器只接受 URL 做为模块标识符。它们会从模块标识符指定的 URL 中下载对应的模块文件。对于模块的依赖图的生成来讲,这一步不是同时进行的。由于在你解析模块文件以前,你没法明确该模块须要什么依赖,而且你没法在获取文件以前就解析它。
这意味着咱们必须一层一层地深刻模块的结构树,解析一个文件而后理清楚改模块的依赖,而后进一步获取并加载这些依赖。
若是主线程须要等等待每一个文件的下载,那么其余的下载任务就会在队列中等待。
这是由于在浏览器当中,下载的部分须要花费很长的时间。
基于这张 图表.
像这样阻塞主线程会使得那些使用模块来构建的 App 的速度变得很慢。这也是 ES 模块规范将实现算法划分红多个阶段的缘由之一。将构建阶段单独划分出来容许浏览器在处理同步的实例化阶段以前就可以下载文件而且构建模块依赖图。
这种实现方式 —— 将模块算法划分为不一样阶段,是 CommonJS 模块和 ES 模块的主要区别之一。
CommonJS 不采用这用的方式是由于从文件系统中读取文件和从网络中下载文件比起来要快得多。这意味着 Node 能够在加载文件的时候阻塞主线程。而后既然文件以及加载完了,那么就顺其天然地实例化和求值(在 CommonJS 中不是分开的阶段)。这也意味着,在返回模块实例以前,须要遍历整棵模块依赖树并对任何模块依赖加载、实例化和求值。
CommonJS 的实现方式有一些影响,以后我会进行解释。但值得注意的一点是,在使用 CommonJS 模块的 Node 环境中,你能够在模块标识符中使用变量。由于在查找下一个模块以前,会执行当前模块的全部代码(require 语句以前)。这意味着当 Node 进行模块解析的时候,模块标识符中的变量已经有值了。
可是对于 ES 模块来讲,咱们在执行任何求值计算以前,事先构建了整个模块依赖图。这意味着在模块标识符当中不能够存在变量,由于这些变量尚未具体的值。
可是有时候,在模块路径中使用变量很是有用。好比,你可能会根据代码的不一样条件或者当前运行环境的不一样来切换加载不一样的模块。
为了在 ES 模块中实现一样的效果,这里有一个叫作 dynamic import 的提案。有了它,你可使用相似 import(`${path}/foo.js`) 的语句
这个方法的原理在于任何使用 import() 加载的文件会被处理为一个分散的依赖图的入口。而被动态引入的模块创建了一个新的模块依赖图,这个模块依赖图的处理是分开进行的。
有一点须要注意 —— 同时处于两个依赖图中的任何一个模块将会共享同一个模块实例。这是由于模块加载器会对模块实例进行缓存。对于特定全局做用域中的每个模块,都只会拥有一个模块实例。
这意味着引擎的工做量更少。好比,一个模块文件只会获取一次,即便有多个模块同时依赖于它(这是咱们须要模块缓存的缘由之一,咱们会在求值的章节中介绍另一个缘由)。
加载器经过一个叫作 模块映射(module map) 的东西来管理模块缓存。每个全局环境都在一个单独的模块映射里跟踪其模块。
当加载器加载一个 URL 的时候,它会将这个 URL 放到模块映射里,而后记下当前处于加载状态。而后它会发出请求并开始处理下一个要加载的文件。
那么,若是另一个模块依赖了某个相同的文件呢?加载器会检查模块映射里的每个 URL,若是它发现已经处于加载状态了,那么加载器会跳过并处理下一个 URL。
可是,模块映射不只仅跟踪那些模块正在被加载,它还做为模块的缓存,咱们接下来会讲到。
如今咱们已经获取到了模块文件,咱们须要将它解析成模块记录。这可以帮助浏览器理解模块的各部分分别是什么。
一旦模块记录被建立,就会被放到模块映射里。这意味着,只要从外部请求对应的模块,模块加载器就能够从模块映射中拿到对应的模块记录。
在解析的过程当中,有一个细节看起来可能微不足道,可是实际上有很大的影响。全部的模块都被以有 "use strict" 在顶部的状况解析。还有一些其余细微的差异,好比,await 关键字被保留在模块的顶级代码中,以及 this 的值是 undefined
解析的不一样被称之为 解析目标(parsed goal),若是对同一个文件使用不一样的解析目标,那就会的到不一样的结果。因此你须要在解析开始以前就知道文件的类型,不管它是模块与否。
在浏览器当中很是简单,你只须要在 script 标签中加入 type=module。这告诉浏览器,当前文件须要以模块的方式来解析,而且由于只有模块才可以被导入,浏览器就可以知道当前文件里全部的导入也都是模块。
可是在 Node 当中,咱们不使用 HTML 标签,因此咱们没办法使用 type 属性。社区尝试解决这个问题的方法之一是 —— 使用 .mjs 扩展名。使用这个扩展名能告诉 Node,“当前这个文件是一个模块”。因此你能看到在谈论使用这个来做为解析目标的信号。关于这个方案的讨论还在不断地进行中,因此目前来讲 Node 社区最后到底会使用什么样的信号还不明确。
不管使用哪一种方式,加载器都会决定是否要将一个文件当作模块来解析。若是它是一个模块,而且有一些导入,那么加载器将会再次进行这个过程,直到全部的文件都被加载和解析。
当咱们完成以后,也就是加载的过程结束以后,你已经从原来只有一个入口文件的变成拥有一堆模块记录。
下一步就是实例化这些模块并将这些实例连接在一块儿。
像我以前所提到的,一个模块实例包含了编码和状态。这些状态存在于内存之中,所以实例化的步骤就是将模块的内容链接到内存之中。
首先,JS 引擎会建立一个模块环境记录。它为模块记录管理变量,而后引擎会为模块导出在内存中找到位置存放。模块环境变量会跟踪内存中的哪一个位置对应哪一个导出。
这些内存中的位置暂时尚未具体的值在里面。只有在求值以后才会为它们填充实际的值。对于这个规则有个要警戒的地方:任何导出的函数声明都会在这个阶段初始化。这对于求值来讲更加简单。
为了实例化模块整个依赖图,引擎会执行 "深度优前后序遍历",这意味着引擎会深刻到依赖图的底部 —— 到底部某个不依赖任何其余依赖的模块依赖,而后设置它们的导出。
当引擎完成连接模块下游的全部导出的时候,而后回到上一个级别连接那个来自模块全部的导入。
注意,导入和导出都指向内存中的同一个位置。连接全部的导出首先确保了全部的导入都可以正确地匹配这些导出。
这个过程和 CommonJS 模块是不一样的。在 CommonJS 当中,整个导出对象被复制到导出上。这意味着任何的导出值(好比数字)都是副本。
这也表示,若是导出模块发生以后发生了改变,导入模块并不会观测到这个变化。
与之相反,ES 模块使用了叫作 “活动绑定(live bindings)” 的机制。导入和导出模块都指向内存中的同一个位置。当导出模块改变了其中导出的某个值,这个变化也会迅速地显如今导入模块当中。
导出值的模块能够随时更改这些值,但导入模块不能更改其导入的值。也就是说,若是一个模块导入了一个对象,它能够改变对象身上的属性值。
使用活动绑定的缘由是能够在不运行任何代码的的状况下就将这些模块连接在一块儿。这一点对于循环依赖的模块的执行颇有帮助,接下来我会解释。
因此在这一步的最后,咱们将全部模块实例的导入和导出的变量在内存中的位置连接在一块儿。
如今咱们就能够开始对代码求值,而后给这些内存中的地址填充上实际的值。
最后一步就是对这些内存中的位置进行值的填充。JS 引擎经过执行顶层代码(函数以外的代码)来实现。
除了给内存里的空间填值以外,求值过程也存在着一些反作用,好比,一个模块可能会请求一个服务器。
由于潜在的反作用,你只想对模块求值一次。与实例化中发生的连接相反,它们屡次连接的结果都是一致的,但根据操做次数的不一样,求值所产生的结果可能不一样。
这也是须要模块映射的缘由之一。模块映射经过规范的 URL 来缓存模块,使得每一个模块只有一个模块记录。这保证了每一个模块只会执行一次,和实例化同样,这个过程也是以 “深度优前后续遍历” 的方式来执行。
那么咱们以前提到的循环引用的问题呢?
在循环依赖之中,最终会在依赖图中产生一个循环。一般来讲,这会是一个长循环,可是为了解释这个问题,我打算用一个模拟的短循环的例子。
首先咱们来看看在 CommonJS 模块中,循环依赖是怎么实现的。首先,main 模块会一直执行到 require 语句。而后它就会转而加载 counter 模块。
而后 counter 模块会尝试从导出对象上访问 message。但由于 message 在 main 模块中尚未被求值,会返回 undefined。JS 引擎将会为本地变量分配内存空间并设置其值为 undefined
求值过程将会继续在 counter 模块的的顶级代码中执行,并执行到底部。咱们想看看 message 最终是否会获取到正确的值(在 main.js 求值以后),因此咱们设置了一个 timeout。而后求值将在 main.js 中恢复。
message 变量将会初始化并被加到内存当中。可是由于 main 导出的 message 和 counter 中的 require 尚未关联,因此在引入的模块中(counter.js),message 仍然保持为 undefined
若是导出都是以 "活动绑定" 的方式处理的,那么 counter 模块最终将会获得正确的值。那么等到 timeout 运行的时候,main.js 求值已经结束而且填充了值。
支持这些循环依赖的背后是 ES 模块设计的重要原理。是这个“三步骤”的设计才使得循环依赖得以实现。
随着 Firefox 60 在五月早期的发布,全部主流浏览器都将默认支持 ES 模块了。Node 也添加了对其的支持,而且有一个致力于解决 CommonJS 和 ES 模块之间的兼容性的问题的工做小组在不断地努力。
这意味着,你将可使用 type=module 的方式来使用模块的导入和导出。然而,更多的模块特性也即将到来。处于 Stage 3 的提案 dynamic import 也在具体的进程中。import.meta 也是如此,它将支持 Node 上的一些用例。同时 module resolution 提案也会使得在浏览器和 Node.js 之间的差异变得更加平滑细微。因此咱们可以期待将来能够鞥更好地使用模块。
2018年5月6日