深入理解ES模块系统

1,005 阅读8分钟

在模块出现以前,大型web程序处理依赖时,需要向将各个组件挂载到全局作用域进行共享,这存在严重的安全隐患,以及维护困难的问题。

因为全局变量是可以被任意访问和修改的,恶意代码可以通过修改全局变量来改变程序的行为

同时必须维护 script 标签引入文件的顺序,否则非常容易引起错误 —— 当在某个脚本内访问不存在的对象时 —- 通常因为这个脚本比依赖脚本的顺序提前了,就会抛出错误导致崩溃。

模块系统如何解决上述问题

模块系统提供了一种作用域 —— 模块作用域,以此来隔离各个模块之间的变量和方法,同时提供了 export 和 import 的机制,来实现模块之间变量和方法的共享。当今活跃的模块方案(用于为javascript提供模块系统)主要有两种:CommonJS和ESM。CommonJS 由于历史的原因,主要用于 nodejs 中,而ESM已经被大多数现代浏览器原生支持了。

ESM模块系统是如何工作的

  1. construction 查找、下载和解析所有的文件到 module records中
  2. instantiation 也被称为 linking,生成 module instance,为所有模块中的 export 和 import 分配内存,并将 import 和与之相应的 export 链接起来
  3. evaluation 运行模块 instance 中的 code,使 export 具备实际的值

07_3_phases-500x184.png

以上三个步骤可以分开进行,因此ES系统模块可以一种异步的方式来工作,这与CommomJS的同步工作方式非常不同。 事实上,ES处理模块的方式是不是异步,取决于如何获取模块文件,这也是ES模块标准中没有进行说明的部分 —— ES模块标准中说明了应当如何解析文件为module records,以及如何 instantiate和evaluate模块,但没有说明如何获取模块文件。

获取模块是loader的职责,而loader并不是ES模块标准的一部分,loader属于其他的标准。对浏览器环境来说,loader被定义在在HTML标准中,你可以根据不同的平台来使用不同的loader。

07_loader_vs_es-500x286.png

Construction

这个步骤主要是从入口文件开始遍历 import 声明,获取模块的地址以及下载、解析模块文件,最后得到一张由 module record 组成的 module graph。

resolution

这一步分为两个部分:找到入口文件,找到依赖文件

module specifier: 用来确定模块加载路径的字符串

08_script_entry-500x188.png

09_module_specifier-500x105.png.webp

入口文件从html中解析得到,而依赖文件则是从每个模块的文件中解析关键语法“import”来得到的。

module specifier是 import声明中的一部分,用来告知loader应该去哪里寻找这个模块,在不同的宿主环境下,loader对module specifier的处理方式是不同的。

浏览器目前仍然只支持URL作为module specifier,也就是以网络请求的方式,通过get特定路径的url来获取模块,于是一个严重的问题出现了:网络请求的不可控使得模块系统变得效率低下。

原因如下:如果你不 parse 一个模块,你就不知道这个模块依赖了哪些模块,而你又必须要在 parse 之前 fetch 一个模块的文件到本地,因此,当依赖图非常庞大时,浏览器将会在获取模块文件这个过程中消耗大量的时间。

10_construction-500x302.png

如果主线程需要等待整个每个文件都被下载完成,那么大量的工作都会被阻塞。因此,ES 模块标准将 construction 过程单独定义,这意味着浏览器可以在对模块执行之前,就先行将模块的依赖关系图构建完成。 这种算法上的分离,是ES模块系统与CommonJS模块系统的关键区别之一。

CommonJS 由于运行在 node 环境下,模块文件是从本地文件系统加载的,一般来说都比较快,效率上的问题几乎可以忽略,因此 CommonJS 模块的工作方式是同步、阻塞式、逐层加载和执行的。

12_cjs_require-500x298.png

如上图所示,模块在加载的过程中就会将所有的依赖模块加载完成,并同步完成模块代码的执行,因此,node 环境下的CommonJS模块支持在require方法中传入变量,因为在执行到require之前,变量就已经被赋值了(程序正常的情况下),这在ES 模块中是不可能的,因为在模块完全解析之前不会实际执行任何的代码

TC39dynamic import 提案https://github.com/tc39/proposal-dynamic-import,对import中的变量提出了新的处理方式,即对动态引入的模块创建一个新的模块图谱

14dynamic_import_graph-500x389.png

值得注意的是,在loader的cache机制下,新的模块图谱并不会导致模块文件被重复加载和初始化,当loader开始fetch一个模块文件的时候,会在内部的module map中对这个url进行标记,表示这个文件正在下载,然后发送fetch请求,然后立刻开始下一个文件的下载过程。

注意:webpack在预处理动态import语法的时候,对包含变量的模板字符串的处理方式是,将可确定的所有文件都打包到结果中,因此,为import传入一个变量作为module specifier是行不通的,必须要有前缀目录让webpack能够进行分析

Parse

一旦我们下载好了模块文件,就会将文件内容解析,并生成 module records,这使得浏览器能够知道模块的不同组成部分是什么。

一旦module record解析完成,就会被放入缓存数据 module map 中

Pasted Graphic.png

小细节:

  • 模块中的代码都会在’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

Pasted Graphic 1.png

完成当前层级的export解析后,引擎会回到上一层级开始处理模块,并将模块中的import和已经处理完的export链接起来

注意:指向同一个模块成员的export和import在内存中是同一份数据,先将export找出来保证了每一个import都能和对应的export链接起来,同时这也意味着在引入其他模块的成员数据后可以对数据进行修改(注意,无法修改引入模块本身,但可以修改属性),这些修改会反应在所有引入了被修改模块成员的模块中。 Pasted Graphic 3.png

与CommonJS模块非常不同的一点是,require进来的模块是对模块文件中定义的对象的一份拷贝,也就是说,如果原本的模块修改了内部成员的值,在已经引入过该模块的模块中是看不到数据的变化的。

Pasted Graphic 4.png

Pasted Graphic 5.png

ES模块这种将import和export绑定到同一内存数据的机制被称为 live binding,这种机制的目的是为了在不执行任何实际代码的情况下完成import和export的链接工作,并且更加便于在执行阶段处理循环依赖的问题

Evaluation

最后就是执行模块代码阶段了,这个阶段完成后,所有的export才会拥有开发者在代码中为其赋予的意义。而执行的顺序和instantiation阶段一样,以深度优先的后序遍历的方式来完成。

在CommonJS中,由于require的结果和export不是指向同一份数据,那么,当存在循环依赖的时候,比如A依赖B,而B依赖A,如果B在evaluation阶段修改了A中引入的值,A是无法得到这个修改后的值的。ES module的设计意图有很大一部分就是为了解决这个问题。

参考文献:hacks.mozilla.org/2018/03/es-…