本文参考 https://hacks.mozilla.org/201...,建议你们读原文。html
ES6发布了官方的,标准化的Module特性,这一特性花了整整10年的时间。可是,在这以前,你们也都在模块化地编写JS代码。好比在server端的NodeJS,它是对CommonJS的一个实现;Require.js则是能够在浏览器使用,它是对AMD的一个实现。git
ES6官方化了模块,使得在浏览器端再也不须要引入额外的库来实现模块化的编程(固然浏览器的支持与否,这里暂不讨论)。ES Module的使用也很简单,相关语法也不多,核心是import和export。可是,对于ES module究竟是如何工做的,它又和以前的CommonJS和AMD有什么差异呢?这是接下来将要讨论的内容。github
一:没有模块化的编程存在什么问题?web
编写JS代码,主要是对于对变量的操做:给变量赋值或者变量之间进行各类运算。正由于大部分代码都是对变量的操做,因此如何组织代码里面的变量对于如何写好代码和代码维护就显得相当重要了。算法
当只有少许的变量须要考虑的时候,JavaScript提供了“scope(做用域)”来帮助你。由于在JavaScript里面,一个function不能访问定义在别的function里面的变量。编程
可是,这同时也带来一个问题,假如functionA想要使用functionB的变量怎么办呢?一个通用的办法就是把functionB的变量放到functionA的上一层做用域。典型的就是jQuery时代,若是要使用jQuery的API,先要保证jQuery在全局做用域。
可是这样作的问题也不少:api
1: 全部的script标签必须保证正确的顺序,这使得代码的维护变得异常艰难。 2: 全局做用域被污染。
二:模块化编程如何解决上面提到的问题?浏览器
模块,把相关的变量和function组织到一块儿,造成一个所谓的module scope(模块做用域)。在这个做用域里面的变量和function之间彼此是可见的。缓存
与function不一样的是,一个模块能够决定本身内部的哪些变量,类,或者function能够被其余模块可见,这个决定咱们叫作“export(导出)”。而其余的模块也就能够选择性地使用这个模块导出的内容,咱们经过“import(导入)”来实现。网络
一旦有了导入和导出,咱们就能够把咱们的程序按照指责划分为一个个模块,大的模块能够继续划分为更小的模块,最终这些模块组合到一块儿,搭建起了咱们整个程序,就像乐高同样。
三:ES Module的工做原理之Module Instances
当你在模块化编程的时候,你就会建立一棵依赖树。不一样依赖之间的连接来源于你使用的每一条"import"语句。
就是经过这些"import"语句,浏览器和Node才知道它们到底要加载哪些代码。你给浏览器或者Node一个依赖树的入口文件,从这个入口文件开始,浏览器或者Node就沿着每一条"import"语句找到下面的代码。
可是,浏览器却使用不了这些文件。全部的文件都必需要转变为一系列被叫作“Module Records(模块记录)的数据结构,这样浏览器才能明白这些文件的内容。
在这以后,module record须要被转化为“module instance(模快实例)”。一个module instance包含2种东西:code和state。
code就是一系列的操做指令,就像菜单同样。可是,光有菜单,并不能做出菜,你还须要原材料。而state就是原材料。State就是变量在每个特意时间点的值。固然,这些变量只是内存里面一个个保存着值的小盒子的小名而已。
而咱们真正须要的就是每个模块都有一个module instance。模块的加载就是从这个入口文件开始,最后获得包含全部module instance的完整图像。
四:Module Instances的产生步骤
对于,ES Module来讲,这须要经历三个步骤:
1: Construction(构造)- 找到,下载全部的文件而且解析为module records。 2: Instantiation(实例化)- 在内存里找到全部的“盒子”,把全部导出的变量放进去(可是暂时还不求值)。而后,让导出和导入都指向内存里面的这些盒子。这叫作“linking(连接)”。 3: Evaluation(求值)- 执行代码,获得变量的值而后放到这些内存的“盒子”里。
你们都说ES Module是异步的。你能够认为它是异步的,由于这些工做被分红了三个不一样的步骤 - loading(下载),instantiating(实例化)和evaluating(求值) - 而且这些步骤能够单独完成。
这意味着ES Module规范采用了一种在CommonJS里面不存在的异步机制。在CommonJS里面,对于一个模块和它底下的依赖来讲,下载,实例化,和求值都是一次性完成的,步骤相互之间没有任何停顿。
然而,这并不意味这这些步骤必须是异步的,它们也能够同步完成。这依赖于“loading(下载)”是由谁去作的。由于,并非全部的东西都由ES module规范控制。事实上,确实有两部分的工做是由别的规范负责的。
ES module规范 陈述了你应该怎样把文件解析为module records,和怎样初始化模块以及求值。然而,它却没有说在最开始要怎样获得这些文件。
是loader(下载器)去获取到了文件。而loader对于不一样的规范来讲是特定的。对于浏览器来讲,这个规范是HTML 规范。你能够根据你所使用的平台来获得不一样的loader。
loader也控制着模块如何加载。它会调用ES module的方法--ParseModule, Module.Instantiate,和Module.Evaluate。loader就像傀儡师,操纵着JS引擎的线。
如今让咱们来具体聊一聊每个步骤。
五:Module Instances的产生步骤之Construction
对于每个模块来讲,在这一步会经历如下几个步骤
1: 弄清楚去哪里下载包含模块的文件(又叫“ module resolution(模块识别)”) 2: 获取文件(经过从一个URL下载或者从文件系统加载) 3: 把文件解析为module record(模块记录)
step1: Finding the file and fetching it 找到文件并获取文件
loader会负责找到文件并下载。首先,须要找到入口文件,在HTML文件里,咱们经过使用<script>标签告诉loader哪里去找到入口文件。
可是,loader如何找到接下来的一系列模块 - 也就是main.js所直接依赖的哪些模块呢?这就轮到import语句登场了。import语句的某一部分又被叫作“模块说明符”。它告诉loader在哪儿能够找到下一个模块。
关于“模块说明符”,有一点须要说明:某些时候,不一样的浏览器和Node之间,须要不一样的处理方式。每个平台都有它们本身的方法去诠释“模块说明符”字符串。而这经过“模块识别算法”完成,不一样的平台不同。就目前来讲,一些在Node环境工做的模块识别符在浏览器里面并不工做,可是这一状况正在被处理修复。
而在修复以前,浏览器只接受URL做为模块标识符。浏览器会从那个URL下载模块文件。可是,对于整个依赖图来讲,在同一时间是不可能的。由于直到解析了这个文件,你才知道这个模块须要哪些依赖。。。可是,你又不能解析这个文件除非你获取了它。
这意味着,要解析一个文件,咱们必须一层一层地遍历这颗依赖树,理清楚他全部的依赖,而后找到而且下载这些依赖。可是,假如主线程一直在等待这些文件下载,那么大量的其余的任务就被卡在队列里面。这是由于,在浏览器里面进行下载工做,会耗费大量的时间。
像这样阻塞主线程,会致使使用了模块的app太慢了,这也是ES module规范把算法分割成多个步骤的其中一个缘由。把construction(构建)单独划分到一个步骤,这就容许浏览器能够在进入到instantiating(实例化)的一系列同步工做以前能够先获取文件而且创建模块之间的依赖树。
把这个算法分割到不一样的步骤--正是ES Module和CommonJS module之间的其中一个关键区别。
CommonJS能够作不一样于ES Module的处理,是由于从文件系统里面加载文件比从网络上下载文件要花少得多的时间。这就意味着,Node能够在加载文件的时候阻塞主线程。又由于文件已经加载好了,那么实例化和求值(这两步在CommomJS里面是没有分开的)也显得颇有道理。这意味着,在你返回这个模块以前,其依赖树上全部的依赖都完成了loading(加载),instantiating(实例化)和evaluating(求值)。
CommonJS的方法会带来一些后果,后面会解释。可是,其中有一点是在Node里面的CommomJS module, 你能够在模块说明符里面使用变量
。在你寻找下一个模块以前,你会执行完本模块的全部代码。这就意味着当你去作模块识别的时候,这个变量已经有值了。
可是,在ES Module里面,你是在任何求值以前先创建了完整的依赖树。这说明,你不能在模块说明符里面使用变量,由于这个变量目前尚未值。
可是动态模块,在实际生产中又是有用的。因此有一个提议叫作动态导入,能够用来知足相似这样的需求:import(
${path}/foo.js).
动态导入的工做原理是,任何使用import()
来导入的文件,都会做为一个入口文件从而建立一棵单独的依赖树,被单独处理。
但有一点须要注意的是 - 任何同时存在于两棵依赖树的模块都指向同一个模块实例。这是由于loader把模块实例缓存起来了。对于每个模块来讲,在一个特定的全局做用域内,只会有一个模版实例。
这对JS引擎来讲,就意味着更少的工做量。举个例子,不管多少模块依赖着某一个模块,可是这个模块文件都只会被获取一次。loader使用module map来管理这些缓存,每个全局做用域使用独立的module map来管理各自的缓存。
当loader经过一个URL去获取文件的时候,它会把这个URL放入module map而且作上“正在获取”的标志。而后它发出请求,进而继续下一个文件的获取工做。
当别的模块也依赖同一个文件的时候,会发生什么呢?Loader会查询module map里面的每个URL,若是它看到这个URL有“正在获取“的标志,那它就无论了,继续下一个URL的处理。
module map不仅是看哪一个文件正在被下载,它同时也管理这模块的缓存,这就是下面的内容。
step2: Parsing
如今咱们已经获取到了文件,咱们须要把它解析为一个module record。这有助于浏览器理解模块的不一样之处是什么。
一旦module record建立完成,它就会被放到module map里面去。这意味着不管什么时候被请求,loader均可以从module map里面提取它。
在解析的时候,有一个看起来琐碎可是却会产生巨大影响的细节:全部的模块都是在至关于在文件顶部使用了“use strict
”(严格模式)下被解析的。除此以外,也还有其余的一些不一样,例如:关键字await
被保留在模块的最高层的代码里;this
的值是undefined
。
不一样的解析方法被称做“解析目标”。假如你用不一样的解析目标解析同一个文件,你将会获得不一样的解析结果。由于,在解析以前,你须要知道将要被解析的文件是不是模块。
在浏览器里面,这十分简单。你只须要给<script>标签加一个type="module"
。这就告诉了浏览器这个文件须要被当成是一个模块来解析。由于只有模块才能够被导入,因此浏览器知道导入的文件也是模块。
可是Node不使用HTML相关的标签,因此没法使用type来表示。而在Node里面是经过文件的扩展名".mjs"来代表这是一个ES Module的。
无论是哪一种方式,最终都是loader来决定这个文件是否看成一个模块来解析。假如它是一个module或者有import
,那就会开始这个进程,直到全部的文件被下载和解析。
这一步骤就结束了。在加载进程结束以后,咱们就从拥有一个入口文件到最后拥有一系列的module record。
下一步就是实例化这些模块,而且把全部的实例连接起来。
六:Module Instances的产生步骤之Instantiation
如我以前提过的那样,一个实例结合了code和state。state存在于内存中,所以实例化这一步就是关于怎样把东西连接到内存里面的。
首先,JS引擎建立了一个“模块环境记录(module environment record)”。它管理着module record的变量,而后它在内存里面找到全部导出(export)的变量的“盒子”。module environment record会一直监控着内存里面的哪一个盒子和哪一个export是相关联的。
这些内存里面的盒子尚未得到它们的值,只有在求值这一步骤完成以后,真正的值才会被填充进去。可是这里有个小小的警告:任何导出的function定义,都是在这一步初始化的,这使得求值变得相对简单一些。
为了实例化模块图(module graph),JS引擎会作一个所谓的“深度优前后序遍历”的操做。意思就是说,JS引擎会先走到模块图的最底层--找到不依赖任何其余模块的那些模块,而且设置好它们的导出(export)。
当JS引擎完成一个模块的全部导出的连接,它就会返回上一个层级去设置来自于这个模块的导入(import)。须要注意的是,导出和导入都是指向同一片内存地址。先连接导出保证了全部的导入都能找到对应的导出。
这和CommonJS的模块不一样。在CommonJS,导入的对象是基于导出拷贝的。这就意味着导出的任何的数值(例如数字)都是拷贝。这就意味着,若是导出模块在以后修改了一些值,导入的模块并不会被同步到这些修改。
于此相反的是,ES module使用所谓的“实时绑定”,导出的模块和导入的模块都指向同一段内存地址。若是,导出模块修改了一个值,那么这个修改会在导入模块里面也获得体现。
导出值的模块能够在任什么时候间修改这些值,可是导入模块却不能修改它们导入的值。意思就是,若是一个模块导出了一个对象(object),那它能够修改这个对象的属性值。
“实时绑定”的好处是,不须要跑任何的代码,就能够连接起全部的模块。这有助于当存在循环依赖状况下的求值。
在这一步的最后,咱们使得全部的模块实例导出/导入的变量的内存地址连接起来了。
接下来,咱们就开始对代码求值,而且把获得的值填入对应的内存地址中。
七:Module Instances的产生步骤之Evaluation
最后一步是把值都填入内存地址中。JS引擎经过执行最上层的代码-也就是function之外的代码,来实现这一目的。
除了往内存地址里面填值,对代码求值有可能也会触发反作用。举个例子,一个模块有可能会向server作请求。由于这个反作用,你只想求模块求值一次。和在实例化阶段的连接不管执行多少次都会获得同一个结果不一样,求值会根据你进行了多少次求值操做而获得不一样的结果。
这也是为何须要module map。Module map根据URL来缓存模块,由于每个模块都只有一个module record,这也保证了每个模块只会被执行一次。和实例化同样,求值也是按照深度优先倒序的规则来的。
在一个循环依赖的状况下,最终会在依赖树里获得一个环,为了仅仅是说明问题,这里就用一个最简单的例子:
咱们先来看看CommonJS,它是怎么工做的。首先,main模块会执行到require语句,而后进入到counter模块。Counter模块尝试去从访问导出的对象里面的message变量。可是,由于这个变量尚未在main模块里面被求值,因此会返回undefined。JS引擎会在内存里面为这个本地变量开辟一段地址并把它的值设置为undefined。
求值一直继续到counter模块的最底部。咱们想知道最终是否能获得message的值(也就是main模块求值以后),因而咱们设置了一个timeout。而后,一样的求值过程在main模块从新开始。
message变量会被初始化而且放到内存中。可是,由于这二者之间已经没有任何连接,因此在counter模块里,message变量会保持为undefined。
假如这个导出是用“实时绑定”处理的,counter模块最终就能获得正确的值。到timeout执行的时候,main模块的求值就完成了而且获得最终的值。
支持循环依赖,是ES module设计的一个重要基础。正是前面的“三个阶段”使得这一切成为可能。