深入理解ES模块系统
在模块出现以前,大型web程序处理依赖时,需要向将各个组件挂载到全局作用域进行共享,这存在严重的安全隐患,以及维护困难的问题。
因为全局变量是可以被任意访问和修改的,恶意代码可以通过修改全局变量来改变程序的行为
同时必须维护 script 标签引入文件的顺序,否则非常容易引起错误 —— 当在某个脚本内访问不存在的对象时 —- 通常因为这个脚本比依赖脚本的顺序提前了,就会抛出错误导致崩溃。
模块系统如何解决上述问题
模块系统提供了一种作用域 —— 模块作用域,以此来隔离各个模块之间的变量和方法,同时提供了 export 和 import 的机制,来实现模块之间变量和方法的共享。当今活跃的模块方案(用于为javascript提供模块系统)主要有两种:CommonJS和ESM。CommonJS 由于历史的原因,主要用于 nodejs 中,而ESM已经被大多数现代浏览器原生支持了。
ESM模块系统是如何工作的
- construction 查找、下载和解析所有的文件到 module records中
- instantiation 也被称为 linking,生成 module instance,为所有模块中的 export 和 import 分配内存,并将 import 和与之相应的 export 链接起来
- evaluation 运行模块 instance 中的 code,使 export 具备实际的值
以上三个步骤可以分开进行,因此ES系统模块可以一种异步的方式来工作,这与CommomJS的同步工作方式非常不同。 事实上,ES处理模块的方式是不是异步,取决于如何获取模块文件,这也是ES模块标准中没有进行说明的部分 —— ES模块标准中说明了应当如何解析文件为module records,以及如何 instantiate和evaluate模块,但没有说明如何获取模块文件。
获取模块是loader的职责,而loader并不是ES模块标准的一部分,loader属于其他的标准。对浏览器环境来说,loader被定义在在HTML标准中,你可以根据不同的平台来使用不同的loader。
Construction
这个步骤主要是从入口文件开始遍历 import 声明,获取模块的地址以及下载、解析模块文件,最后得到一张由 module record 组成的 module graph。
resolution
这一步分为两个部分:找到入口文件,找到依赖文件
module specifier: 用来确定模块加载路径的字符串
入口文件从html中解析得到,而依赖文件则是从每个模块的文件中解析关键语法“import”来得到的。
module specifier是 import声明中的一部分,用来告知loader应该去哪里寻找这个模块,在不同的宿主环境下,loader对module specifier的处理方式是不同的。
浏览器目前仍然只支持URL作为module specifier,也就是以网络请求的方式,通过get特定路径的url来获取模块,于是一个严重的问题出现了:网络请求的不可控使得模块系统变得效率低下。
原因如下:如果你不 parse 一个模块,你就不知道这个模块依赖了哪些模块,而你又必须要在 parse 之前 fetch 一个模块的文件到本地,因此,当依赖图非常庞大时,浏览器将会在获取模块文件这个过程中消耗大量的时间。
如果主线程需要等待整个每个文件都被下载完成,那么大量的工作都会被阻塞。因此,ES 模块标准将 construction 过程单独定义,这意味着浏览器可以在对模块执行之前,就先行将模块的依赖关系图构建完成。 这种算法上的分离,是ES模块系统与CommonJS模块系统的关键区别之一。
CommonJS 由于运行在 node 环境下,模块文件是从本地文件系统加载的,一般来说都比较快,效率上的问题几乎可以忽略,因此 CommonJS 模块的工作方式是同步、阻塞式、逐层加载和执行的。
如上图所示,模块在加载的过程中就会将所有的依赖模块加载完成,并同步完成模块代码的执行,因此,node 环境下的CommonJS模块支持在require方法中传入变量,因为在执行到require之前,变量就已经被赋值了(程序正常的情况下),这在ES 模块中是不可能的,因为在模块完全解析之前不会实际执行任何的代码
TC39的 dynamic import 提案https://github.com/tc39/proposal-dynamic-import,对import中的变量提出了新的处理方式,即对动态引入的模块创建一个新的模块图谱
值得注意的是,在loader的cache机制下,新的模块图谱并不会导致模块文件被重复加载和初始化,当loader开始fetch一个模块文件的时候,会在内部的module map中对这个url进行标记,表示这个文件正在下载,然后发送fetch请求,然后立刻开始下一个文件的下载过程。
注意:webpack在预处理动态import语法的时候,对包含变量的模板字符串的处理方式是,将可确定的所有文件都打包到结果中,因此,为import传入一个变量作为module specifier是行不通的,必须要有前缀目录让webpack能够进行分析
Parse
一旦我们下载好了模块文件,就会将文件内容解析,并生成 module records,这使得浏览器能够知道模块的不同组成部分是什么。
一旦module record解析完成,就会被放入缓存数据 module map 中
小细节:
- 模块中的代码都会在’use strict’即严格模式下执行
- 模块作用域下的await是保留字
- 模块中的this是undefined
parse goal: 不同的解析方式,就是不同的 parse goal,对应着不同的解析结果,而module instance只是解析结果的一种。在浏览器中,从模块的入口文件开始,所有被import的文件都被当做模块处理。而在node环境下,无法通过html标签的属性type=“module”来声明某个文件应当被作为模块解析,社区中为了解决这一问题,将文件名后缀改为mjs来达到相同的目的,这是当前的事实标准
Instantiation
前面说过,instantiation的作用是生成 module instance ,并将module record中众多的import和export链接起来。
而module instance是由code和state组成的。state存在于内存中,instantiation的任务就是将这些内存中的数据连接起来。
首先,JS引擎会创建一个 module environment record,这个数据结构管理着 module record 中的变量,然后将为模块中所有的 export 申请一个内存地址,简称box来索引,而 module enviroment record 就记录了模块中box和exports的对应关系。
这些box并不会马上被填充数据 —— 这需要在evaluation阶段完成。但是 —— 任何export导出的函数声明都会在这个阶段被初始化,这是为了简化evaluation阶段的复杂度。
为了将module graph中所有的import和export链接起来,模块引擎会对module graph进行 深度优先的后续遍历。
也就是说,先找到module graph中的最末端一层的module record(不依赖任何模块的模块),然后往 state 中写入这个模块的export
完成当前层级的export解析后,引擎会回到上一层级开始处理模块,并将模块中的import和已经处理完的export链接起来
注意:指向同一个模块成员的export和import在内存中是同一份数据,先将export找出来保证了每一个import都能和对应的export链接起来,同时这也意味着在引入其他模块的成员数据后可以对数据进行修改(注意,无法修改引入模块本身,但可以修改属性),这些修改会反应在所有引入了被修改模块成员的模块中。
与CommonJS模块非常不同的一点是,require进来的模块是对模块文件中定义的对象的一份拷贝,也就是说,如果原本的模块修改了内部成员的值,在已经引入过该模块的模块中是看不到数据的变化的。
ES模块这种将import和export绑定到同一内存数据的机制被称为 live binding,这种机制的目的是为了在不执行任何实际代码的情况下完成import和export的链接工作,并且更加便于在执行阶段处理循环依赖的问题
Evaluation
最后就是执行模块代码阶段了,这个阶段完成后,所有的export才会拥有开发者在代码中为其赋予的意义。而执行的顺序和instantiation阶段一样,以深度优先的后序遍历的方式来完成。
在CommonJS中,由于require的结果和export不是指向同一份数据,那么,当存在循环依赖的时候,比如A依赖B,而B依赖A,如果B在evaluation阶段修改了A中引入的值,A是无法得到这个修改后的值的。ES module的设计意图有很大一部分就是为了解决这个问题。
转载自:https://juejin.cn/post/7206667711077613629