ES模块将正式的标准化模块系统引入JavaScript。但是,花了近十年的标准化工作才能到达这里。
但是等待几乎结束了。随着5月份Firefox 60的发布(目前处于beta版),所有主要的浏览器都将支持ES模块,而Node模块工作组目前正在努力为Node.js添加ES模块支持。而且,用于WebAssembly的ES模块集成也在进行中。
许多JavaScript开发人员都知道ES模块一直存在争议。但是实际上很少有人了解ES模块的工作方式。
让我们看一下ES模块要解决的问题以及它们与其他模块系统中的模块有何不同。
模块可以解决什么问题?
你可以认为使用JavaScript进行编码就完全是管理变量。这都是关于为变量分配值,或向变量添加数字,或将两个变量组合在一起并将其放入另一个变量。
因为您的代码太多,只是为了更改变量,所以组织这些变量的方式将对代码的编写方式以及维护代码的方式产生很大的影响。
一次只需考虑几个变量即可使事情变得容易。JavaScript有一种帮助您完成此任务的方法,称为范围。由于作用域在JavaScript中的工作方式,因此函数无法访问其他函数中定义的变量。
很好 这意味着在处理一个功能时,您只需考虑一个功能即可。您不必担心其他函数会对您的变量产生什么影响。
不过,它也有一个缺点。确实很难在不同功能之间共享变量。
如果您确实想在范围之外共享变量怎么办?一种常见的处理方法是将其放在您之上的范围内,例如全局范围内。
您可能还记得jQuery时代的情况。在加载任何jQuery插件之前,必须确保jQuery在全局范围内。
这行得通,但它们是一些令人讨厌的问题。
首先,所有脚本标签都必须以正确的顺序排列。然后,您必须小心确保没有人弄乱该命令。
如果您确实弄乱了该顺序,则在运行过程中,您的应用程序将引发错误。当该函数在全局范围内寻找期望的jQuery时,如果找不到它,它将抛出错误并停止执行。
这使得维护代码变得棘手。它使删除旧代码或脚本标签成为轮盘游戏。您不知道会发生什么。代码的这些不同部分之间的依赖关系是隐式的。任何函数都可以捕获全局的所有内容,因此您不知道哪个函数取决于哪个脚本。
第二个问题是,因为这些变量在全局范围内,所以该全局范围内的代码的每个部分都可以更改该变量。恶意代码可以有意更改该变量,以使您的代码执行您不希望这样做的事情,或者非恶意代码可能会无意间破坏了您的变量。
模块如何提供帮助?
模块为您提供了更好的方式来组织这些变量和函数。使用模块,您可以将有意义的变量和函数组合在一起。
这会将这些函数和变量放入模块范围。模块作用域可用于在模块中的功能之间共享变量。
但是与函数作用域不同,模块作用域具有一种使其变量也可用于其他模块的方式。他们可以明确地说出模块中的哪些变量,类或函数应该可用。
当其他模块可以使用某些东西时,这称为导出。导出后,其他模块可以明确地说它们依赖于该变量,类或函数。
因为这是一种明确的关系,所以您可以判断如果删除另一个模块,哪个模块将中断。
一旦能够在模块之间导出和导入变量,就可以更轻松地将代码分解为可以相互独立工作的小块。然后,您可以组合并重组这些块(类似于Lego块),以从同一组模块创建所有不同种类的应用程序。
由于模块是如此有用,因此曾多次尝试将模块功能添加到JavaScript。今天,有两个模块系统正在积极使用中。Node.js历史上一直使用CommonJS(CJS)。ESM(EcmaScript模块)是一个更新的系统,已添加到JavaScript规范中。浏览器已经支持ES模块,并且Node正在添加支持。
让我们深入了解这个新的模块系统是如何工作的。
ES模块如何工作
当您使用模块进行开发时,您将建立一个依赖关系图。不同依赖项之间的连接来自您使用的任何导入语句。
这些import语句是浏览器或Node确切知道其需要加载哪些代码的方式。您给它一个文件,以用作图形的入口点。从那里开始,它紧随任何import语句以查找其余代码。
但是文件本身不是浏览器可以使用的东西。它需要解析所有这些文件,以将它们转换为称为模块记录的数据结构。这样,它实际上知道文件中正在发生什么。
之后,需要将模块记录转换为模块实例。实例结合了两件事:代码和状态。
该代码基本上是一组指令。这就像如何做某事的食谱。但是,就其本身而言,您不能使用该代码执行任何操作。您需要原材料才能与这些说明一起使用。
什么是状态?国家给你那些原材料。状态是变量在任何时间点的实际值。当然,这些变量只是内存中保存值的框的昵称。
因此,模块实例将代码(指令列表)与状态(所有变量的值)组合在一起。
我们需要的是每个模块的模块实例。模块加载的过程正在从该入口点文件变为具有完整的模块实例图。
对于ES模块,此过程分为三个步骤。
- 构造—查找,下载所有文件并将其解析为模块记录。
- 实例化—查找内存中的框以放置所有导出的值(但尚未用值填充它们)。然后使导出和导入都指向内存中的那些框。这称为链接。
- 评估—运行代码以将变量的实际值填充到框中。
人们谈论ES模块是异步的。您可以将其视为异步的,因为工作分为三个不同的阶段(加载,实例化和评估),并且这些阶段可以分别完成。
这意味着规范确实引入了CommonJS中不存在的一种异步。我将在后面解释,但是在CJS中,一个模块及其下面的依赖项一次全部被加载,实例化和评估,而中间没有任何中断。
但是,这些步骤本身不一定是异步的。它们可以以同步方式完成。这取决于正在执行的加载。这是因为并非所有内容都由ES模块规范控制。实际上有两部分工作,涵盖了不同的规格。
在ES模块规范说,你应该如何解析文件到模块的记录,你应该如何实例化和评估模块。但是,它没有说明如何首先获取文件。
读取文件的是加载程序。加载程序是在其他规范中指定的。对于浏览器,该规范是HTML规范。但是您可以根据所使用的平台使用不同的装载程序。
加载程序还可以精确控制模块的加载方式。它调用ES模块的方法- ParseModule
,Module.Instantiate
和Module.Evaluate
。有点像操纵JS引擎的字符串的控制器。
现在,让我们详细介绍每个步骤。
建造
在构建阶段,每个模块发生三件事。
- 找出从哪里下载包含模块的文件(又称模块分辨率)
- 提取文件(通过从URL下载文件或从文件系统加载文件)
- 将文件解析为模块记录
查找文件并获取
加载程序将负责查找文件并下载。首先,它需要找到入口点文件。在HTML中,您可以通过脚本标记告诉加载程序在哪里找到它。
但是,如何找到下一组模块-main.js
直接依赖的模块呢?
这就是导入语句的来源。导入语句的一部分称为模块说明符。它告诉加载程序可以在哪里找到每个下一个模块。
关于模块说明符的一件事:在浏览器和Node之间有时需要对它们进行不同的处理。每个主机都有自己的解释模块说明符字符串的方式。为此,它使用一种称为模块解析算法的方法,该算法在平台之间有所不同。当前,某些可在Node中工作的模块说明符将无法在浏览器中工作,但仍在进行修复。
在此之前,浏览器仅接受URL作为模块说明符。他们将从该URL加载模块文件。但这不会同时出现在整个图形上。在解析文件之前,您不知道模块需要获取哪些依赖项,并且在获取文件之前,您无法解析该文件。
这意味着我们必须逐层遍历树,解析一个文件,然后找出其依赖项,然后查找并加载这些依赖项。
如果主线程要等待这些文件中的每一个下载,则许多其他任务将堆积在其队列中。
那是因为当您在浏览器中工作时,下载部分会花费很长时间。
这样阻塞主线程会使使用模块的应用程序使用起来太慢。这是ES模块规范将算法分为多个阶段的原因之一。将构造分为自己的阶段,使浏览器可以在开始实例化的同步工作之前获取文件并增强对模块图的理解。
这种方法(将算法分为多个阶段)是ES模块和CommonJS模块之间的主要区别之一。
CommonJS可以做不同的事情,因为从文件系统加载文件比在Internet上下载花费的时间少得多。这意味着Node可以在加载文件时阻止主线程。并且由于文件已经加载,因此仅实例化和求值(在CommonJS中不是独立的阶段)是有意义的。这也意味着在返回模块实例之前,您要遍历整棵树,加载,实例化和评估任何依赖项。
CommonJS方法有一些含义,我将在后面详细解释。但是,这意味着一件事,就是在带有CommonJS模块的Node中,可以在模块说明符中使用变量。require
在寻找下一个模块之前,您正在执行该模块中的所有代码(直到语句)。这意味着当您进行模块解析时,变量将具有一个值。
但是,使用ES模块时,您需要在进行任何评估之前预先建立整个模块图。这意味着您不能在模块说明符中包含变量,因为这些变量尚无值。
但是有时将变量用于模块路径确实很有用。例如,您可能要根据代码在做什么或在什么环境中运行来切换要加载的模块。
为了使ES模块成为可能,有一个建议叫做动态导入。借助它,您可以使用类似的导入语句import(
${path}/foo.js)
。
这种工作方式是将使用加载的任何文件import()
作为单独图形的入口点进行处理。动态导入的模块将启动一个新图,该图将被单独处理。
不过要注意一件事–这两个图中的任何模块都将共享一个模块实例。这是因为加载程序会缓存模块实例。对于特定全局范围内的每个模块,将只有一个模块实例。
这意味着发动机的工作量更少。例如,这意味着即使多个模块依赖于该模块文件,该模块文件也只会被提取一次。(这是缓存模块的一个原因。我们将在评估部分中看到另一个原因。)
加载程序使用称为模块映射的内容来管理此缓存。每个全局变量在单独的模块图中跟踪其模块。
当加载程序获取URL时,它将将该URL放入模块映射中,并记下当前正在获取文件。然后它将发出请求并继续以开始获取下一个文件。
如果另一个模块依赖于同一文件会怎样?加载程序将在模块映射中查找每个URL。如果在其中看到fetching
,它将继续前进到下一个URL。
但是模块图不仅跟踪正在获取的文件。模块映射还充当模块的缓存,我们将在后面看到。
解析中
现在我们已经获取了该文件,我们需要将其解析为模块记录。这有助于浏览器了解模块的不同部分。
创建模块记录后,将其放置在模块图中。这意味着无论何时从此处请求,加载程序都可以将其从该映射中拉出。
解析中有一个细节看似微不足道,但实际上有很大的含义。解析所有模块,就像它们"use strict"
位于顶部一样。也有其他细微的差异。例如,关键字await
是在模块的顶级代码中保留的,并且this
is的值undefined
。
这种不同的解析方式称为“解析目标”。如果您解析相同的文件但使用不同的目标,那么最终将得到不同的结果。因此,您想在开始解析之前就知道要解析的文件类型-是否是模块。
在浏览器中,这非常简单。您只需放入type="module"
script标签。这告诉浏览器应将此文件解析为模块。而且由于只能导入模块,因此浏览器知道任何导入也是模块。
但是在Node中,您不使用HTML标记,因此无法选择使用type
属性。社区尝试解决此问题的一种方法是使用 .mjs
扩展。使用该扩展名告诉Node,“此文件是一个模块”。您会看到人们将其视为解析目标的信号。目前讨论仍在进行中,因此尚不清楚Node社区最终将决定使用什么信号。
无论哪种方式,加载程序都将确定是否将文件解析为模块。如果它是一个模块并且有导入,则它将重新开始该过程,直到提取并解析了所有文件。
我们完成了!在加载过程结束时,您已经从只有入口点文件变成了拥有大量模块记录。
下一步是实例化此模块,并将所有实例链接在一起。
实例化
就像我之前提到的,实例将代码与状态结合在一起。该状态存在于内存中,因此实例化步骤就是将所有事物连接到内存。
首先,JS引擎创建一个模块环境记录。这将管理模块记录的变量。然后,它会在内存中找到所有导出的框。模块环境记录将跟踪与每个导出关联的内存中的哪个框。
内存中的这些框尚无法获取其值。只有在评估之后,它们的实际值才会被填写。该规则有一个警告:在此阶段中初始化所有导出的函数声明。这使评估工作变得更加容易。
为了实例化模块图,引擎将进行深度优先的后顺序遍历。这意味着它将下降到图表的底部-底部的不依赖于其他任何东西的依赖项-并设置其导出。
引擎完成了模块下面所有出口的接线-该模块所依赖的所有出口。然后,它返回一个级别,以连接从该模块导入的内容。
请注意,导出和导入都指向内存中的同一位置。首先连接出口,可以确保所有进口都可以连接到匹配的出口。
这不同于CommonJS模块。在CommonJS中,整个导出对象在导出时被复制。这意味着导出的任何值(如数字)都是副本。
这意味着,如果导出模块以后更改了该值,则导入模块将看不到该更改。
相反,ES模块使用称为实时绑定的东西。两个模块都指向内存中的相同位置。这意味着,当导出模块更改值时,该更改将显示在导入模块中。
导出值的模块可以随时更改这些值,但是导入模块不能更改其导入的值。话虽如此,如果模块导入了一个对象,则它可以更改该对象上的属性值。
之所以拥有这样的实时绑定,是因为您可以在不运行任何代码的情况下连接所有模块。当您具有循环依赖性时,这将有助于评估,如下所述。
因此,在此步骤结束时,我们已为导出/导入的变量连接了所有实例和存储位置。
现在我们可以开始评估代码,并使用它们的值填充这些内存位置。
评估
最后一步是将这些框填充到内存中。JS引擎通过执行顶级代码(函数外部的代码)来实现此目的。
除了仅在内存中填充这些框外,评估代码还可能触发副作用。例如,一个模块可能会调用服务器。
由于存在潜在的副作用,您只需要评估该模块一次。与实例化中发生的链接可以完全相同的结果执行多次相反,评估可以根据您执行多少次而得出不同的结果。
这是拥有模块映射的原因之一。模块映射通过规范的URL缓存模块,因此每个模块只有一个模块记录。这样可以确保每个模块仅执行一次。与实例化一样,这是作为深度优先的后遍历来完成的。
那我们之前讨论过的那些周期呢?
在循环依赖性中,您最终在图形中产生了一个循环。通常,这是一个漫长的循环。但是为了解释这个问题,我将使用一个简短的循环的人为例子。
让我们看一下如何将其与CommonJS模块一起使用。首先,主模块将执行直到require语句。然后它将去加载计数器模块。
然后,计数器模块将尝试message
从导出对象进行访问。但是由于尚未在主模块中对此进行评估,因此它将返回undefined。JS引擎将在内存中为局部变量分配空间,并将其值设置为undefined。
评估一直持续到计数器模块顶级代码的末尾。我们想看看我们是否最终将获得正确的消息值(在评估main.js之后),因此我们设置了超时时间。然后评估在上恢复main.js
。
消息变量将被初始化并添加到内存中。但是由于两者之间没有连接,因此在所需的模块中它将保持未定义状态。
如果使用实时绑定处理导出,则计数器模块最终将看到正确的值。到超时运行时,main.js
的评估就已经完成并填写了值。
支持这些循环是ES模块设计背后的重要理由。正是这种三相设计使它们成为可能。
ES模块的状态如何?
随着5月初Firefox 60的发布,默认情况下,所有主流浏览器都将支持ES模块。Node还增加了支持,该工作组致力于解决CommonJS和ES模块之间的兼容性问题。
这意味着您将能够将script标签与一起使用type=module
,并使用导入和导出。但是,更多的模块功能尚未出现。该动态导入的提案是在第3阶段在规范过程中,由于是import.meta这将有助于支持Node.js的使用情况,以及模块解决方案也将有助于平滑过度浏览器和Node.js的差异 因此,您可以期望与模块的合作在将来会变得更好。
参考
ES modules: A cartoon deep-dive