当我们说JavaScript编码时,讲的几乎是变量的管理。不外乎是变量的赋值,对变量增加数值,或者将两个变量加起来赋值给另外的变量。
大量的代码都是变量的改变,因此如何组织这些变量直接影响你编码的质量甚至也影响你能否更好的维护代码。
如果只是少量的变量,JavaScript中的scope
就可以一劳永逸的解决好这个问题。这个得益于scope
的工作方式,函数并不能访问其他函数中的变量。
这点很好,你在一个函数中编码时候,你不担心其他的函数会操作你的变量。
但它也有不好的一面,这样就很难在函数间共享变量。
如果你确实需要在scope
之外共享变量呢? 通常的做法是将其放到你的上一层……,譬如全局的global scope
中。
你可能还记得,在jQuery
时代,在你加载jQuery 插件之前,你得确保jQuery
是在global scope
中。
这样也行,但还是会有些让你恼火的问题。
首先,你所有的script标签都得保证正确的顺序,得保证任何一个不能把顺序弄错了。
一旦你弄错了顺序,你的app就会在运行时候报错。当函数在global scope
中拿不到期望的jQuery
变量时候就会报错,然后停止运行。
这样就会让代码维护变得很困难。移除旧代码就像玩轮盘游戏一样,你永远不知道什么时候会出错。不同模块间的依赖也模糊不清。任何函数都可以操作global scope
,你也就不知道哪个函数依赖哪个脚本。
另一个问题是,global scope
之内的任何代码都能改变其中的变量,恶意代码会让不怀好意的更改某些变量,或者甚至正常代码也可能会意外改变了某些变量。
模块是一个种更好管理这些变量和函数的方法。你可以将相关的变量和函数使用模块来组织到一起。
这样就将变量和函数放到了模块scope
中。其中的函数就可以在模块scope
共享变量。
和函数scope
不一样,模块将其中的变量分享给其他模块,而且模块可以决定分享哪些变量,类,或者函数。
这种分享叫做导出export
。一旦导出了,其他模块就可以生命自己依赖这个模块中的哪个变量,类或者函数。
如此精确的关系,当你删除了一方,自然很容易知道哪些地方会出错。
当你可以在模块间导入和导出变量的时候,你就可以更容易地将代码分成彼此独立的小块。然后你就可以像乐高一样,同样的模块集组合出各种不同的应用。
模块很有用,也有多次给JavaScript添加模块的尝试。现在还在使用还有两个。Node.js一直以来使用的CommonJS(CJS)
,以及最近才被添加到JavaScript标准中的ESM(EcmaScript modules)
,浏览器已经支持ESM
了,Node.js也在支持之中。
来让我们深入了解es模块的工作方式。
当你使用模块开发的时候,你会建立一个依赖图,其中不同依赖之间的连接就来自于你所使用的导入声明。
这些导入声明就是让浏览器或者Node知道需要加载哪些代码。你给定一个文件作为依赖图的入口,浏览器就会按照导入声明来依次加载代码。
但是浏览器不会使用文件本身。它需要将所有的文件解析为一种叫做模块记录Module Records
的数据结构才能让浏览器了解文件内容。
然后,模块记录需要转变为模块实例。一个实例结合了两个东西:规则code
和状态state
。
规则code
就是指令的集合。它就像是菜谱一样。但是对它本身来说,你需要新鲜的材料才能有用。
那么什么是状态state
呢?状态state
就提供的新鲜材料。状态state
就是变量在任意时刻的值。当然,变量实际上是内存中持有值的盒子的昵称。
因此模块实例就是规则(指令集合)和状态(所有的变量的值)的结合。
我们所需要的是每个模块的模块实例。那么模块加载就是从入口到得到全图模块实例的过程。
对于Es modules
模块来说,会有三个步骤。
一般说Es modules
是异步的。你可以将这个异步理解为它的工作非常了这三个步骤——加载,实例化,执行,而且这些阶段是可以单独分开完成。
这也意味着,上述的这种异步特性是CommonJS(CJS)
所没有的(下文解释),在一个CJS模块中,加载,实例化,执行是一次完成的,中间没有停顿。
然而,这些步骤并不是一定需要异步。他们也可以同步地来完成。这取决于谁在做加载。这是因为ES module 标准并没有控制所有的事情,工作的两部分由两个不同的规范所覆盖。
Es module 规范规定了如何解析文件为模块记录Module Records
,以及如何实例化和执行模块。但是,最开始的如何拿到文件并没有涉及。
文件是由加载器拉去文件,但是加载器又有着不同的规范。对浏览器来就是HTML规范。而且平台不同你也可以有不同的加载器。
加载器也是实际控制了模块是如何加载的。它执行ES模块的方法——ParseModule
, Module.Instantiate
, and Module.Evaluate
,它就像线绳一样控制这JS引擎这个木偶。
现在让我们更详尽地每个步骤。
Construction
每个模块在构建阶段会发生三件事情:
加载器负责超着和下载文件。首先它需要找到入口文件。在HTML中,开发者通过script 标签告诉加载器。
那它又怎么拿到下一批的模块呢——main.js
中直接依赖的文件。
这就是导入声明的用处了。导入声明中有一部分那个叫做模块标识符module specifier
。它告诉加载器哪里可以找到下一个模块。
模块标识符module specifier
还有一点需要注意的是:还需要处理浏览器和Node两种不同的情况。它们使用各自的模块分析算法去解释模块标识符上的字符串,而且不同平台还不一样。目前一些能够在Node中工作的模块标识符在浏览器中并不能工作,不过目前已在在着手修复这个问题了。
在这之前,浏览器只接受URl作为模块的标识符。它将会通过URL来加载模块文件。而且也不会一次性加载全模块图中的文件。因为你不解析文件你就没办法知道文件中的依赖……你也不能解析文件除非你拉取到了文件。
这也就意味着我们需要一层一层的经过这个依赖树:解析一个文件,得到所依赖的文件,然后再查找和加载这些依赖。
如果主线程等待每个文件的下载,那么就会有很多其他的任务悬挂在队列里了。
因为我们在浏览器中,下载部分通常会花费很长的是将。
如此的阻塞主线程会让我们的模块无法使用。这也是为什么ES module 规范中将算法分为几个阶段的原因之一。把模块构建分出去平台自己实现,也就容许浏览器能够拉取文件然后在同步实例化之前建立自己对模块图的理解。
这个实现——将算法分成几个步骤,是ES 模块和CommonJS
模块的主要区别。
CommonJS
能够不这么做的原因在于,从文件系统中加载文件花费的时间远远小于从网络下载。也就是Node可以在加载文件的时候阻塞主线程,而且一旦文件已经加载,直接实例化和执行也就很顺理成章(没有分成几个步骤),也就是在返回模块实例之前,已经实例化,执行全树的文件了。
CommonJS
的实现还有几点说明,我会在稍后说明。但是其中一点是Node的CommonJS
模块标识符中是容许使用变量的。因为它在寻找下一个模块的时候已经执行了次模块的所有代码,已经在模块分析之前拿到了变量的值。
但是对于Es modules
来说,在做任何代码执行之前你需要拿到全部的模块依赖图。也就是说在模块分析模块标识符的时候,变量还没有被赋值。
但是,对于某些情况模块路径中有变量是很用的。比如,你可能需要根据代码执行或者环境不同来加载不同的模块哦。
为了能够在Es modules
中实现这一点,就有了动态导入dynamic import
的提议。这样就可以使用,import(
${path}/foo.js)
这样的表述。
这样能够工作的原因在于,将import()
当成一个新的模块依赖图的入口。动态导入开启了一个新的模块依赖图,它也会被分开处理。
还有一点需要注意的是,不同模块图中共有的模块会共享同一个模块实例。这是因为加载器会缓存模块实例。对于同一个全局作用域下的每个模块只会有一个模块实例。
这也会减少引擎的工作。例如,被不同模块所依赖模块文件只会被拉取一次。这也是模块实例需要被缓存的原因,我们还会在执行阶段将这个问题。
加载器会使用叫模块地图module map
的东西来管理模块缓存。不同的全局环境使用各自的模块地图。
当加载器开始拉取一个URL时候,它会将这个URL放入地图并且标记为正在拉取文件。然后他会发起请求,进入下一个文件的拉取。
如果另外的模块依赖了同样的文件,加载器将会查看地图中的每个URL,如果它看到了fetching
的存在,它会直接进入下一个URL。
模块地图不是仅仅缓存了被拉取的文件,他还是用于模块的缓存,我们接下来来看。
当我们加载完文件后,我们需要将其解析为模块记录。这样才能帮助理解浏览器每个模块的不同。
一旦模块记录创建之后,它就会被放置在模块地图中,当它再次被需要的时候,加载器就直接从模块地图中直接取出。
解析还有一点值得注意的是,所有的模块会当作顶部有use strict
来解析。还有点不同的,模块的顶层中await
关键词不容许使用, this
的值为undefined
。
这种不同的解析叫做“解析球门”,同一文件,你是用不同的球门来解析,你会得到不同的结果。因此你需要在解析之前知道你的球门是什么——是否是模块。
在浏览器中这很简单,你只需要在script标签中使用type="module"
即可。
但是在Node中,你没有HTML标签能够使用,也就没有type
属性。社区中一个方法是使用.mjs
新的扩张,这些讨论在进行,社区也暂时未确定使用何种方式。
无论如何,加载器会决定是否按照模块来解析文件。如何它是一个模块而且还有依赖,它就会开始一遍遍的处理直到所有的文件被拉取和解析。
但我们做完加载的环节,你就会从一个入口文件得到一批的模块记录。
下一步就是实例化模块,然后将所有的实例连接起来。
就像上文提及的,一个实例包含了规则code
和状态state
。状态state
都在内存之中,因此实例化这步全都是往内存里写东西。
首先,JS引擎创建一个模块环境记录。来管理模块记录中的变量,然后它会找到所有导出在内存中的变量,模块环境记录就会跟踪内存中那个盒子对应哪个导出。
内存中盒子现在暂时还没有值。它们只有在执行之后才会被填充。在这里还有一点注意的是:任何被导出的函数声明初始化是在这个阶段。这样会让后续的执行更简单。
引擎采用深度优先后序遍历来实例化模块依赖图。这也意味着他会先找到图的底部——没有依赖其他模块——设置他们的导出。
引擎连接一个模块的导入和该模块所有依赖的导出。然后再和上一层依赖它的模块连接。
注意,导出和导入都处在内存中同样的位置。首先连接导出就能保证所有的导入都能够和它所配皮的导出相连。
这点和CommonJS
模块很不一样。在CommonJS
中,整个的导出都在会在导出时候被复制。这也意味这任何的值(例如数字)导出时候是复制的。
这也就是说如果导出方后面更改了这个值,导入它的模块将看不到变化。
相反,在Es modules
模块系统中使用动态绑定的方案。导入和导出的模块指向内存中同样的地址。这样导出方更改了值,Es modules
中导入方是可以看到变化的。
模块导出方可以随时改变导出值,但是模块导入饭不可以改变他们导入的值。这样,如果一个模块导入了一个对象,它可以改变这个对象上属性的值。
之所以使用动态绑定的原因在于你后续可以在不运行任何代码的情况下连接所有的模块。这点也可以有助于环形依赖的执行,这点我会后续解释。
因此在这个步骤结束,我们得到了所有的模块实例和连接到一起的导入/导出变量。
然后我们就可以开始执行代码,向内存中的地址填值了。
最后一步就是JS引擎执行顶层的代码——函数之外代码,来填写内存中的这些盒子。
而且,填充这些内存中的盒子,执行代码的时候也会触发一些副作用。例如,一个模块可能会发起一个服务端请求。
因为潜在的副作用,你只希望模块执行一次。但是和实例化连接过程多次进行结果严格一直不同,每次的执行都会有不同的结果。
这也是我们为什么有模块地图的原因。模块地图通过唯一的URL只为模块添加一条模块记录。这就保证了每个模块只执行一次。就像实例化,执行也是通过深度优先后续遍历。
那么就像环形依赖又是怎么样的呢?
在环形依赖中,你会得到一个循环的图。一边可能会比较长。但是为了好解释,我们认为构造一个简短的例子。
让我们看下CommonJS
模块是怎么处理的,首先main模块执行它的模块require
声明,然后许加载counter模块。
counter模块会尝试读取导入的message
变量,但是mian模块尚未执行,它会返回undefined。JS引擎会为本地变量分配一块内存并设为undefined。
继续执行counter模块的顶层代码。我们通过设置一个超时来看看我们到底能不能拿到message
变量的正确值(等到main.js执行后),接下来开始main.js
的执行。
message
变量将会被初始化并添加到内存。但是因为其中并没有连接。它仍会在counter模块中保持undefined。
如果我们导出使用动态绑定的话。counter模块最终会得到正确的值。当到超时回调时候,main.js
的执行就会完成值也会被添加。
Es modules
中三个步骤的设计是其能够支持环形依赖的最大原因。
Es modules
的现状如何?Firefox 60之后,全部主流浏览器都默认支持Es modules
了。Node也在开始支持了,有工作小组,已经在解决CommonJS
和Es modules
差异的问题了。
也即是,你可以开始在script标签中添加type=module
来使用原声的es模块了。然而一些模块的特性还未支持。动态导入提议还暂时在规范的Stage 3 (翻译时间:2019-01-17),还有import.meta
将会对Node有用处,并且模块的分析方案module resolution proposal
也会有助于抹平浏览器和Node之间的差异。因此你可以期望Es modules
未来可以更加好用。
略……