详解ES模块系统

1,082 阅读17分钟

ES模块系统带给了JavaScript官方标准化的模块系统,对于JavaScript模块系统的标准化经历了近10年的时间。但是对着Firefox 60版本的发布,所有主流浏览器已经全面支持ES模块,随后Node.js也逐步开始支持ES模块。同时,ES模块与WebAssembly集成的也在计划之中。

很多JavaScript开发者都在讨论着ES模块,但是有不少开发者并不实际上真正理解了ES模块系统的原理。下面我们就来看看ES模块系统解决的工程问题以及它与其他模块系统到底有何区别。

要解决什么问题

提到模块化,在JavaScript中一般指的是对变量的管理。赋值给变量,对变量进行运算或者将运算结果赋值给另一变量,这些都与模块化有关。

image.png


正因为你的代码与你对变量的管理息息相关,如何组织管理各种变量将直接对你编写代码以及所写的代码的可维护性产生重大影响。JavaScript中的作用域可以确保一次仅仅处理部分相关的变量,这样可能会比较轻松点。也正是由于JavaScript作用域的问题,普通函数无法获取到其他函数中定义的变量(非闭包的情况)。

image.png


JavaScript中的函数作用域确实很好,它意味着在一个函数中不用担心别的函数中定义的变量对这个函数中的变量的干扰。同样也存在缺陷,例如:这种隔离会让函数之间共享变量变得更困难。一种常用的解决的跨函数作用域共享变量的方法是将需共享的变量提升到外层作用内或者放入全局作用域中(在浏览器环境是全局作用域是window对象)。

在jQuery时代就广泛应用了这种模式:在加载jQuery插件之前需要先全包全局作用域中的jQuery对象存在,否则jQuery将无法正常运行。

image.png


这种方案确实可能正常运作,但是存在一些令人困扰的问题:

  1. 所有的有依赖关系的 script 标签需要谨慎的处理其放置,以防止顺讯被打乱导致代码不能按预期运作;
  2. 万一 script  在代码运行期间被打乱,应用可能会抛出错误。当插件函数在查找全局的jQuery方法时没有找的,其会抛出错误并停止执行(具体的可能会与插件内部错误处理有关)。

image.png


这会使得移除代码变得非常不安全,因为你并不知道删除的代码会不会被其他代码依赖,会不会破坏其他代码的逻辑。不同部分的代码之间的依赖关系也变得晦涩难懂,任何函数都可以获取到任何全局的属性或方法,最后导致无法确定哪个函数依赖与哪个 script 标签,代码最终变得难以维护。

另一个问题是所有的属性都放在全局的 window 对象上面,任何模块都可获取和修改全局的属性和方法,这会导致某一部分代码的功能和数据被其他部分代码有意或无意地破坏。

如何解决上述问题

模块是一种更好组织变量和函数的方法。通过模块系统,你可以将相互关联的变量或函数分组组织在一起,组成一个独立的模块级的作用域。以模块级的作用域来不同模块之间共享数据和方法。

与函数作用不同的是,模块作用域可以通过模块的 导出 功能将自身模块的变量或者方法提供给其他模块使用,这一过程叫做其他模块依赖于该模块导出的那个变量/类/函数。

image.png


正因为这种模块之间的关系是很明确的,可以很明显的知道删除部分模块将会对其他模块产生的影响。一旦可以在不同模块之间 导入 导出 变量,那么将之前的杂乱的代码拆分成小的模块将会十分容易。最后可以想搭建乐高积木一样,通过一些最最基本的模块来创建各式各样的应用。

由于模块系统对JavaScript代码的组织产生了重要影响,产生了两种主流的模块系统规范:CommonJS(CJS)ESM(ECMAScript modules)。前者是前期Node.js主要遵从的模块规范;后者是最新的模块系统规范,现在已被主流浏览实现,当然最新的Node.js也已支持此规范。

下面就看看新的模块系统到底是如何工作的。

ES模块运作原理

解析模块记录

ES模块系统会根据导入语句来确定模块的依赖关系,并生成一份连接不同模块依赖的 依赖关系图 。现代浏览和Node.js环境可以通过导入语句分析需要加载的代码模块。给 依赖关系图 一个入口文件,就可以通过入口文件处的导入语句来解析所导入的文件模块。

image.png

其实浏览器本身并不知道如何使用导入语句导入的文件,因此需要将导入的文件解析成一种特定的数据结构以供浏览器来识别,这种数据结构叫做 模块记录 。

image.png

完成解析导入的文件为模块记录后,还需要将其转换成带有 模块描述(code)  和 模块状态(state) 的模块实例。模块描述记录着该模块的模块组成信息,模块状态则记录着该模块的所有变量。

也就是说: 模块实例 = 模块描述(code) + 模块状态(state)

image.png

模块实例是ES模块系统的核心,模块的处理主要就是通过模块入口文件来生成一份各个模块的完整实例关系图。

三个核心处理步骤

模块处理分为以下三步:

  1. 构建——查找和下载导入的模块文件然后解析成模块记录
  2. 实例化——分配内存空间来存放模块所有导出的变量,这时候内存中并没有分配变量的值,然后将对应导出与导入指向同一内存空间,这一步也叫做 连接
  3. 执行JS——JS引擎运行JS代码得到变量的值然后保存到上一步分配的内存中

image.png

为什么说ES模块异步的
模块的处理分为三个阶段:构建/实例化/执行JS,而这三个阶段完全可以单独执行。而CommonJS规范中模块的加载流程是加载/实例化/执行JS这三步是连续进行的,执行过程不能暂停或跳过。

其实ES模块规范自身不必要一定采用异步机制,同步执行的方式同样也可以完成上面模块处理的三个步骤。考虑到ES模块规范并没有规定文件的获取方式,是服务器端(Node.js)同步获取还是在客户端异步获取,ES模块规范统一采用异步机制来处理模块。

模块中导入文件的获取是通过 模块加载器 来获取,模块加载器控制着模块是如何被加载的,它是ES模块提供的方法,主要方法包含: ParseModule Module.Instantiate 和 Module.Evaluate 。

image.png

步骤一:构建

构建步骤主要完成:

  1. 计算出从何处下载模块文件
  2. 从网络或者本地文件系统获取模块文件
  3. 解析文件为模块记录

获取模块文件

ECMASCRIPT针对浏览器和Node.js环境分别对模块有不同的实现方式。浏览器端采用 <script type="module" src="..."></script> 来实现ES模块的入口文件标识。

image.png

在入口文件中的导入语句中包含 模块标识 ,这是一个模块文件的路径,模块加载器可以根据这个路径查找到需要加载的模块文件。

image.png

某些情况下在浏览器环境和Node.js环境下模块标识的处理方式可能不完全一样,每种宿主环境都不同的模块标识解析算法。因此部分模块标识在Node.js中可用而在浏览器中不可用,现已有修复此问题的提案

在上面说的模块加载器的问题修复之前,浏览器环境中模块修饰符只支持传入URL字符串。因此目前浏览器环境下ES模块的解析流程为: 从入口文件分析导入的模块 => 浏览器从模块标识指向的URL地址下载模块文件 => 下载完成文件后开始解析为模块记录 => 然后开始下载模块记录导入的其他模块文件 依次循环下去,直到全部导入的文件下载并解析完成才会生成完整的模块依赖关系图。

image.png

浏览器异步文件导致的问题

如果按照上面的解析流程浏览器环境中运作时需要下载大量的模块文件,而下载文件对于浏览器环境天生就是异步过程,在浏览器主线程下载这些模块文件时,大量的其他任务将会堆积在任务队列中。

image.png


这会使得浏览器主线程阻塞,同时ES模块解析过程也会变得非常缓慢。 出于这些原因,ES模块规范将模块解析阶段拆分成了三个不同阶段,使得浏览器环境下可以在同步的实例化开始之前先下载完所有模块文件并分析出模块依赖关系图。

CommonJS与ES Module构建过程的区别

CommonJS运行在Node.js环境中,模块文件的下载是从本地的文件系统读取的,这个过程远比从服务器下载文件快很多。获取文件的过程会阻塞主线程,一旦文件获取完成即开始实例化和执行JS步骤(这两步是同时进行),然后模块的实例。

image.png

CommonJS模块解析时实例化和执行JS时同时进行,所以在分析模块中的require导入语句时JS代码中的变量都已经获取到运行时的值,因此require导入语句的模块标识中可以使用JS变量,这个特性叫做 模块动态导入 。

而ES Module为了解决上面说的浏览器解析模块的性能问题采用了预先分析模块依赖关系图的技术——模块静态分析,执行步骤在此时还没开始,JS变量都未取得运行时的值,所以import导入语句中的模块修饰符不用使用JS变量。

image.png

ES模块动态导入

CommonJS中这种可以根据JS变量来动态导入模块的特性在开发中有很重要的应用,例如:可以在逻辑语句中根据Node.js环境变量来加载不同的模块,以此来判断开发环境和生产环境如何构建应用。目前ES Module本身还不支持 模块动态导入 的特性,相应的动态导入提案已进入stage-4阶段。

动态导入提案的实现原理是:在入口文件处进行模块静态分析时遇到了动态导入声明则开启一个独立的静态代码分析来解析动态导入模块标识指向的模块,返回的是一个Promise对象;这个过程也是可以嵌套的,即动态导入的模块可以再进行另外的动态导入。

image.png

模块实例的缓存

为了优化模块解析性能,每一个模块都有一个唯一的实例保存在全局作用域下的,这个保存着所有模块实例的数据结构叫做 模块映射 。当模块加载器从URL加载一个模块文件时,模块映射会URL作为key来记录这个模块做加载状态为fetching,然后继续开始加载下一个模块文件。模块映射在完成解析实例化步骤后还会缓存模块返回的实例,即使是动态模块导入中返回的模块实例也会在这里进行唯一实例的缓存。

image.png

步骤二:解析

下载完模块文件后,需要解析模块文件为模块记录以便浏览器识别模块文件的内容。模块记录文件创建完成后,会被保存在模块映射中,在模块加载器处理任何其他模块时可以通过模块映射中的缓存来取得已经解析好的模块记录。

image.png

image.png

在ES模块中模块的解析采用 严格模式 (use strict),顶层作用域中的 await 是保留关键字,并且this是 undefined 。

在解析文件时候采用的不同解析方式称为 解析目标 ,对于同一文件采用不同解析目标解析时将得到不同的结果,因此在解析文件之前还需要告诉ES模块如何解析文件——是否按模块来解析。

在浏览器环境中,通过 <script type="module" src="..."></script> 来告诉浏览器用ES模块来解析src指向的文件,由于在ES模块中只有模块文件才可以被引入,所以上面script标签指向的文件中的导入语句导入的模块均是采用模块来解析。

image.png

在Node.js环境中无法使用 script 标签和 type="module" 属性,从Node.js V8.5版本开始支持了ES模块,同时采用 .mjs 文件扩展名来标识一个文件为ES模块。

在完成模块文件解析后,入口文件中就指向了全部的模块记录,接下来就需要实例化这些模块以及将各个实例连接起来。

步骤三:实例化

前面已经提到过,一个模块的实例由 模块描述(code)  和 模块状态(state) 组成,模块状态直接绑定到构建阶段开辟的内存空间。

首先,JS引擎会创建一个模块环境记录(上下文)来记录模块记录中的各种变量,并且来找出之前exports导出的变量和所开辟内存空间的地址,然后将导出的变量绑定到对应的内存空间地址,最后将其对应关系记录到模块环境记录中。

此时内存空间中的变量还没有取得运行时的值,变量的值将在下一步执行JS中取得。需要注意的是:所有导出的函数的定义将在这一步骤中完成

ES模块采用 深度优先顺序遍历 来遍历模块依赖关系图中每一个模块记录。就是说先从入口文件的第一个导入语句开始一层层往更深层查找,直到最后一个没有导入语句的模块为止,打包好这个模块的导出变量,然后回到上一级的模块继续这个步骤

image.png

完成一个模块所依赖的一个模块的导出变量的打包后,将回到导出变量所在模块的上一级模块,将这个上一级模块中的导入变量进行打包。这也就保证了上级模块导入的变量和它依赖的模块导出的相应变量指向了同一个内存空间

image.png

这一导入导出动态绑定特性与CommonJS模块系统完全不同,在CommonJS中导入的变量是对导出这个变量的模块中的值的赋值,也就是说导入的变量与对应导出的变量指向了不同的内存空间。自然,导出这个变量个模块在后续代码中对那个导出变量的更新不会改变导入这个变量时得到的值。

image.png


在ES模块中使用了 动态绑定 的特性,导出变量的模块与导入变量的模块所使用的变量都指向了同一个内存地址,因此导出模块中对相应变量更改将会更新导入模块中导入的相应变量。导入模块不能更改导入的变量的值,导出模块可能改变导出的变量来间接的更新导入模块对应变量的值。如果导入的是对象的话,导入模块可能更新这个对象的属性。这个动态绑定的特性可以解决下面将提到的模块循环依赖的问题。

image.png


完成了模块实例化后,入口文件已经知道了所有的模块实例以及所有被打包好的导入导出变量,可以进行接下来的执行步骤了。

步骤四:执行

最后一个步骤就是执行代码,JS引擎执行函数之外的顶层作用域中的代码逻辑,将最终得到的变量值填充到实例化时关联的内存地址上。

image.png

处理副作用问题

上面执行的代码可能会有副作用,例如:代码访问了服务器接口,或者返回结果与调用次数有关等情况。为了解决这些副作用,通过前面产生的模块映射来确保同一个模块的代码只会被执行一次。

处理循环依赖问题

一个模块A引入的另一个模块B中又引入了这个模块A,这种 A=>B=>A 的环形依赖关系叫做循环依赖。环形依赖通常不是这么直接,我们为了说明问题简化依赖关系如下:

image.png

以下面这个图所表示的循环依赖场景来说明ES模块是如何处理循环依赖的问题的。

image.png


JS从上往下执行,当进入到counter.js模块时,模块main.js中的message通过实例化过程绑定的内存地址处拿到的值是 undefined ,这时候counter.js模块中的message自然也是 undefined 的,接下来使用s setTimeout 来模块来异步打印message,打印会在main.js同步代码执行完成之后才输出。

image.png


现在JS的执行回到main.js模块,这时候main.js模块导出了message变量,会将message对应的内存地址处的值更新为 Eval complete 。现在同步的JS代码执行完毕,JS引擎开始检查异步任务队列,发现counter.js模块中定义了一个打印message变量的事件,因此就执行打印message,这时候message是指向的是内存的值已经更新为 Eval complete ,所以打印值是这个而不是之前的 undefined 。


image.png



而CommonJS模块处理上面问题的步骤则是这样的:
发现main.js文件依赖与counter.js,于是就是处理counter.js这个模块,这时候发现又依赖于main.js模块,由于模块实例记录中发现存在main.js模块不再跳回去处理main.js模块,继续处理当前模块,这里时候message从main.js还未导出,所以counter.js模块的message变量指向的内存中存的是 undefined 。接下来同样想任务队列中添加打印事件,事件暂时还未执行。

同样回到main.js模块继续执行,这时候才从main.js中导出message变量,这时候main.js模块中的message变量指向的是一个新的内存地址值为 Eval complete ,这个内存地址并不是counter.js中message指向的内存地址。

image.png


同步代码执行完成后,执行任务队列中的打印事件,打印的是counter.js中message指向的内存地址,值为 undefined 。