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