46

本文参考 https://hacks.mozilla.org/201...,建议大家读原文。

ES6发布了官方的,标准化的Module特性,这一特性花了整整10年的时间。但是,在这之前,大家也都在模块化地编写JS代码。比如在server端的NodeJS,它是对CommonJS的一个实现;Require.js则是可以在浏览器使用,它是对AMD的一个实现。

ES6官方化了模块,使得在浏览器端不再需要引入额外的库来实现模块化的编程(当然浏览器的支持与否,这里暂不讨论)。ES Module的使用也很简单,相关语法也很少,核心是import和export。但是,对于ES module到底是如何工作的,它又和之前的CommonJS和AMD有什么差别呢?这是接下来将要讨论的内容。

一:没有模块化的编程存在什么问题?

编写JS代码,主要是对于对变量的操作:给变量赋值或者变量之间进行各种运算。正因为大部分代码都是对变量的操作,所以如何组织代码里面的变量对于如何写好代码和代码维护就显得至关重要了。

当只有少量的变量需要考虑的时候,JavaScript提供了“scope(作用域)”来帮助你。因为在JavaScript里面,一个function不能访问定义在别的function里面的变量。

但是,这同时也带来一个问题,假如functionA想要使用functionB的变量怎么办呢?一个通用的办法就是把functionB的变量放到functionA的上一层作用域。典型的就是jQuery时代,如果要使用jQuery的API,先要保证jQuery在全局作用域。
但是这样做的问题也很多:

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设计的一个重要基础。正是前面的“三个阶段”使得这一切成为可能。


nanaistaken
586 声望43 粉丝