ES modules
带来了JavaScript官方、标准的模块系统,给我们的开发带了很多方便。
许多JavaScript开发人员都知道ES模块一直存在争议, 但是很少的人知道ES modules
是如何工作的。
现阶段基本都是模块化开发应用,所以我们在代码中经常会写如下代码,但是我们是否了解为什么会出现模块化这个概念已经模块化的原理
import a from 'xxx'
import b from 'xxx'
今天我来讲解一下,ES modules
解决了什么问题以及它和目前的其他模块系统(CommonJs
)的区别
es模块解决了什么
思考一下我们平时写代码的时候,Javascript
是如何管理变量的,比如:将值复制到一个变量,对变量进行操作,结合2个变量生成一个新的变量。
因为我们程序大部分都是在操作变量,所以如何组织这些变量将对我们的代码维护产生重大的影响 -- 我们如何去管理我们的代码
我们都知道Javascript存在着一些作用域(scope
), 当我们的应用很小的时候,只需要管理少量的变量是非常容易了,因为Javascript
提供了我们函数作用域,函数不能访问其他函数内部定义的变量,这样我们就可以只需要考虑单独的函数内部的局部变量。
但是函数作用域也有一些缺点,它是很难去在不同的函数之间共享变量
那我们如何确实想要在作用域范围之外共享变量怎么办? 由于Javascript
寻找变量的方式,会向上级作用域去寻找,所以一般的做法就是把变量放到上一级作用域。比如:全局作用上存放变量共享。
在使用JQuery
的时候, 在我们加载JQuery
插件之前,我们必须要确认JQuery
已经存在在全局作用域上了
所以我们经常会听到需要把jQuery的导入放入到最前面,不然会出现问题, 这样脚本就有了依赖顺序,但是这个顺序是要靠我们手动去维护, 这样会出现几个问题。
<script src="jQuery" />
<script src="a.js" />
<script src="b.js" />
- 所有的脚本都要按照一定的顺序摆放,你需要确保没有人去移动这个顺序,如果有人移动了这个顺序,在运行过程中,当这个函数去寻找jQuery就会抛出一个错误,找不到jQuery对象。
这让我们必须非常小心的管理我们的代码,这种依赖关系让我们想要删掉旧代码或者脚本成为了一个轮盘游戏,你不知道会发生什么,这些不同的代码部分之间相互的依赖是隐形的,任何函数都可以获取全局作用域下面的内容,所以我们不知道那个函数依赖于哪一个脚本。
- 由于这些变量都是放在全局作用域下,任何脚本都可以修改,如果遇到恶意的脚本执行,修改了全局变量,无法准确的定位到底是哪一个脚本修改了
模块是如何解决的
模块化给了我们更好的方式去组织一些变量和函数。使用模块化,你可以将一些有关联的变量和函数组合在一起。
模块内的函数和变量,可以通过导入和导出让模块之间相互共享变量,这不同于函数作用域, 模块作用域可以明确的提供模块中的变量,函数,calss等到其他模块。
当你想提供一些变量给其他模块的时候,它可以通过export
, 一旦你使用了export
, 其他模块可以明确地说它们依赖于该变量,类或函数。
因为这个明确的依赖关系,你如果删掉了某一个模块,你可以明确的知道哪些其他模块会受到影响。
由于我们现在可以导出和导入变量在不同的模块中,所以它是非常容易的想到我们可以把我们的代码切分成很多独立的模块。然后我们可以结合这些独立的模块(就像乐高一样)去创建来自于同一系列的模块的不同应用。
由于模块是如此的有用,所以Javascript
花费了将近10年的时间,制定出了标准化的模块系统,现在我们有二种模块系统可以使用。CommonJS(CJS)是在Node中使用,ESM(EcmaScript modules)是Javascript的规范,现在浏览器大部分已经支持了ES module, 高版本的Node也提供了支持。
接下来,我们讲一下ES module是如何工作的。
ES模块是如何工作的
当我们使用模块进行开发的时候,一般我们会脑袋里面会想一个模块的依赖关系图, 不同的依赖项之间的联系来自于我们使用了import
声明导入
import导入语句可以让浏览器明确的知道哪些代码是需要被加载的,你给一个文件作为模块依赖图(modules graph)的顶部,通过import语句可以顺着找到其他一系列的模块。
但是浏览器对于文件本身是不需要的,他需要去解析(parse)这些依赖文件转换成Module Records
的数据结构,这样浏览器就知道文件里面发生了什么
将文件解析成模块记录Module Records
后,模块需要转换成模块实例,一个实例主要包含2个事情:the code and state (指令和状态)
the code
是一组指令构成。 它看起来像一个食谱,它本身不能做什么事情,需要一些原材料配合这个食谱才可以使用
state
提供原材料。state是某一个时间点所有变量的实际值,当然,这些变量只是内存中值得昵称(key)
所以,模块实例将代码(指令列表)与状态(所以变量的值)组合在一起
主要步骤
ESM的模块加载主要是分为三步,这里简单的总结,之后一一展开
- Constuction -- 查找(find),下载(download)所以文件并解析(parse)到模块记录中
- Instantiation -- 在内存中寻找一块内存来存放所有导出的变量(但是由于还没有执行,所以没有填充值),然后让export和import都指向这些内存块,这个过程叫做链接(
live bindings
) - Evaluation -- 真正执行代码,在内存块中填入变量的实际值
我们经常说ES modules
是异步的。你现在应该明白正是因为一个工作被分成了3个阶段,Constuction, instantiating, and evaluating
,每一个阶段都是独立没有关联。通过这三步之后我们就可以得到一个模块图谱(Module Graph
)
ES modules
规范确实引入了CommonJS中不存在的一种异步,之后再详细说,在CommonJS中,一个模块及其下面的依赖项被一次加载,实例化和求值,而三者之间没有任何中断。
虽然ES modules
是异步的,但是这三步本身不一定要异步,他们可以以同步的方式完成,这取决于你正在加载的内容。
ES module
规范讲解了我们如何解析(parse)文件为模块记录(module records
)以及如何实例化和求值模块,但是它没有说我们如何去下载这个模块
这个模块的下载在不同的宿主机上是不同的表现,在浏览器端,是按照HTML规范。
接下来我们来细讲每一个步骤
构建(Constuction)
在构建阶段,每个模块发生三件事
Find
: 找出从何处下载包含模块的文件fetching
: 获取这个文件(通过url或者从文件系统中下载)parse
: 解析这个文件生成module record
查找文件并获取(Find and fetching
)
宿主机负责找到入口文件并下载。 首先它需要去找到入口文件,在HTML中,我们通过script标签告诉宿主机去哪里去寻找这个文件。
但是我们如果去寻找下一组模块呢? -- 入口文件(main.js
)直接依赖的模块呢?
这就是我们ES module
引入 import语句的原因。 import 语句一部分被叫做模块标识符(module specifier),它告诉加载程序在哪里可以寻找下一个模块
关于模块标识符需要注意的一点:在不同的宿主环境(浏览器和Node)需要进行不同的处理, 每个宿主机都有自己的方式解释模块标识符。为此,它使用一种称为模块解析算法的模块,该算法在平台之间有所不同。目前,有些模块标识符是工作在Node端不工作在浏览器端。但是我们可以通过一些工具处理它。
现阶段,浏览器只接受URLs作为模块标识符, 它将下载这个模块通过URL。但是加载所有模块不是同时发生的,因为在解析文件之前,我们不知道模块需要获取那些依赖项,并且在获取文件之前,我们也无法解析该文件
这就意味着,我们必须逐层遍历树,解析一个文件,然后找出其依赖项,然后查找并加载这些依赖项
由于在浏览器端,下载文件需要花费很长的时间,如果主线程去等待每一个文件去下载,将会有很多的任务堵塞主进程。如果主线程被堵塞,整个运用将会非常的慢。这是ES 规范将加载算法分成多个阶段的原因之一。将Constuction
分成自己阶段,使得浏览器可以在开始实例化的同步工作之前获取文件并解析模块图。
这种将模块算法分为多步是ES模块和CommonJS模块之间的主要区别之一
CommonJS解析模块是不同的,因为从文件系统加载文件比通过网络下载文件要快得多。这就意味着,Node能给在加载文件的时候堵塞主线程,由于这个文件已经加载了,就可以直接去实例化和执行(CommonJS中没有区分这个)。这也就意味着,在返回模块实例之前遍历整棵树,加载和实例化,执行所有的依赖项
CommonJS基于这个解析方式,所以在CommonJS中,我们能够使用变量作为模块标识符。因为在寻找下一个模块之前,你正在执行该模块的所有代码(直到require语句)。这意味着当你进行模块分析的时候,变量已经是一个具体的值了。
但是在ES modules中,你需要先建立整个模块图,然后再进行执行。这就说明你不能使用任何变量作为模块标识符,因为代码没有执行,变量的值没有确定。
但是有时候你确实想使用变量作为模块加载路径。比如:你可能需要根据代码的功能或在什么环境中运行来切换加载的不同模块。为了使这个成为可能,ES module提供了动态加载(import()
), 你可以使用import语句像这样import(
${path}/foo.js)
parse
现在我们已经可以下载获取到文件,我们需要将它解析成module record
, 这有助于浏览器了解模块之间的区别
一旦module record
被创建,它将被放入模块映射中module map
,这就意味着无论何时从此处请求,加载程序都可以直接返回map中的缓存。
在解析的过程中,有一点需要注意。所有的模块如果顶部有“use strict”, 它们将有一些不同,例如:await关键字会在顶部保留、this的值为undefined
在浏览器端,我们可以指定一个script标签的type属性为module(type=module
), 这个就是告诉浏览器应该作为一个模块去解析内容,由于模块只能引入模块,所以浏览器知道任何通过import 导入的都是模块,也是通过模块解析。
但是在Node端,我们不能使用HTML的标签,因此也不能指定一个type属性,我们只能通过文件后缀名.mjs
来告诉Node, 这个文件是一个ES模块文件,要使用ES module
的解析机制
无论是通过哪一种方式,加载程序都将确定是否将该文件解析为模块,如果它是一个模块并且有导入,那么它将重新开始该过程,知道提取了所有的文件解析成Module record
这样我们就完成了parse的步骤,在解析完成后,我们已经从只有一个入口文件变成了用了大量的模块记录。
接下来就是实例化模块,并将模块和实例链接在一起
实例化(Instantiation
)
上面我们也提到过,一个实例是包含一组指令(the code
)和状态(state
)组成, 状态被保存在内存中,所以实例化步骤就是将所有的事物连接到内存。
首先,JS引擎创建一个模块的上下文环境(module environment record
), 它管理module record
的变量,然后它会找到模块上下文中的所有export
的变量,然后将它和内存中某一个空间进行连接。
由于代码还没有执行,所以目前内存空间中还没有值,但是所有的函数都将提前声明了,这样可以给之后的执行减轻压力。
为了实例化Module Record
, JS引擎采用Depth First Post-order Traveral
深度优先后序进行遍历, JS引擎将会为每一个Module record
创建一个 Module environment record
模块环境上下文。
引擎遍历将深入直到依赖树末端没有任何依赖的文件,处理好每个模块的 export 与 内存连接,之后逐层回溯进行连接该模块的所有 import
请注意,export 和 import 都指向内存中的同一个区域。先连接 export ,保证了所有的 export 都可以被连接到对应的 import 上。
这个和CommonJS的模块不同,在CommonJS中,整个对象导出是将被复制,这就意味着导出的任何值(如数字)都是副本。这也就是说如果导出的模块中变量的值发生改变了,这个引入的值将不会改变。
和CommonJS相反, ES模块使用动态绑定(Live Bindings
),两个模块都指向内存的同一个位置,这就意味着当导出值的模块中修改值的时候,在导入的模块中能够立马呈现。(导入变量的模块无法修改导入的值,如果导入的是对象类型,那么可以修改其属性值)
之所以 ES Modules 采用 Live Bindings,是因为这将有助于做静态分析(不用执行 Code)以及规避一些问题,如循环依赖。
这也就是通常所见到的结论:CommonJS 模块导出是值的拷贝,而 ES Modules 是值的引用。
接下来我们开始执行代码,然后填充真实的值到内存中。
执行(Evaluation)
还记得我们 通过内存 连接好了所有 export 和 import 吗,但内存还尚未有值。
JS 引擎通过执行顶层代码(函数之外的代码),来向这些内存区添值。
除了填充内存中的值之外,执行代码可能会触发一些副作用,例如:一个模块可能会调用服务器。
由于这个潜在的副作用,所以我们一般都只想执行一次这个模块,这和实例化链接刚刚相反,实例化多次执行都是同一个结果,而执行代码可能多次执行得到不同的结果。
这也是为什么需要module map
进行模块映射,模块映射通过规范的URL缓存模块,因此每个模块只有一个模块记录。这样可以确保每个模块仅执行一次。与实例化一样,这都是深度优先的后遍历。
循环依赖
很多人好奇模块是怎么处理循环依赖的?
在循环依赖中,你有一个循环的图,在实际代码中可能是一个长的循环,但是现在我们为了介绍原理,就使用一个短的循环案例。
首先看看CommonJS是怎么处理的。首先,这个main.js
执行顶部的require()
语句,然后它将会加载这个counter.js
模块。
这个counter.js
模块将试图获取message
变量从导出的对象中,但是由于此时还没有执行main.js
模块,它将返回undefined
,然后JS引擎将在内存中为局部变量分配空间,并将其值设置为undefined
执行完counter.js
的顶部代码后,我们想看到之后是否可以正确的获取到message
的值(在main.js
执行后),因此我们设置了一个setTimeout
去获取值。
现在执行回到main.js
中,这个message
变量被初始化并且添加到内存中,但是由于CommonJS是值的拷贝,所以2者没有任何联系,在require
(counter.js
模块)模块中,依旧是返回undefined
如果导出是通过动态绑定live bindings
, 这个counter.js
模块最终将会获得正确的值,因为此时main.js
已经执行完并在内存中填充了正确的值。
支持循环依赖也是ES module背后设计的重要原因
参考文章和图片来源
- ES modules: A cartoon deep-dive
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!