likes
comments
collection
share

通过动画深入理解ES Module

作者站长头像
站长
· 阅读数 24

ES 模块为 JavaScript 带来了一个官方的、标准化的模块系统。

许多 JavaScript 开发人员都知道 ES 模块的存在。但很少有人真正了解 ES 模块是如何工作的。

让我们看看 ES 模块解决了哪些问题,以及它们与其他模块系统中的模块有何不同。

模块解决了什么问题?

仔细想想,JavaScript 的编码其实就是管理变量。这都是关于为变量赋值,或为变量添加数字,或将两个变量组合在一起并将它们放入另一个变量中。

通过动画深入理解ES Module

因为你的大部分代码只是关于改变变量,你如何组织这些变量将对你的编码能力产生很大的影响……以及你对代码的维护能力。

一次只考虑几个变量会使事情变得更容易。JavaScript 有一种方法可以帮助您做到这一点,称为作用域。由于作用域在 JavaScript 中的工作方式,函数无法访问在其他函数中定义的变量。

通过动画深入理解ES Module

这很好。这意味着当你在处理一个功能时,你可以只考虑那个功能。不必担心其他函数可能会对你的变量做什么。

不过,它也有一个缺点。它确实使在不同函数之间共享变量变得困难。

如果你确实想在范围之外共享变量怎么办?处理此问题的常用方法是将其放在你上层的作用域内……例如,在全局作用域内。

你可能还记得 jQuery 时代的这一点。在加载任何 jQuery 插件之前,你必须确保 jQuery 在全局范围内。

通过动画深入理解ES Module

这可行,但它们会导致一些烦人的问题。

首先,你的所有脚本标签都需要按正确的顺序排列。然后你必须小心确保没有人搞乱这个顺序。

如果你弄乱了该顺序,那么在运行过程中,你的应用程序将引发错误。当函数去它期望的地方——在全局上——寻找 jQuery 并且没有找到它时,它会抛出一个错误并停止执行。

通过动画深入理解ES Module

这使得维护代码变得很棘手。它使删除旧代码或脚本标签成为轮盘游戏。你不知道什么会破坏。代码的这些不同部分之间的依赖关系是隐含的。任何函数都可以抓取全局上的任何内容,因此你不知道哪些函数依赖于哪些脚本。

第二个问题是,由于这些变量位于全局作用域内,因此该全局范围内的代码的每一部分都可以更改该变量。恶意代码可以故意更改该变量以使你的代码执行你不希望它做的事情,或者非恶意代码可能会意外破坏你的变量。

模块如何提供帮助?

模块为你提供了一种更好的方式来组织这些变量和函数。使用模块,你可以将有意义的变量和函数组合在一起。

这会将这些函数和变量放入模块范围内。模块作用域可用于在模块中的函数之间共享变量。

但与函数作用域不同,模块作用域有一种方法可以使它们的变量也可用于其他模块。他们可以明确说明模块中的哪些变量、类或函数应该可用。

当某些东西可供其他模块使用时,它被称为导出。一旦你有一个导出,其他模块可以明确地说它们依赖于那个变量、类或函数。

通过动画深入理解ES Module

因为这是一种明确的关系,所以你可以知道如果删除另一个模块会破坏哪些模块。

一旦你能够在模块之间导出和导入变量,就可以更轻松地将代码分解成可以彼此独立工作的小块。然后你可以组合和重新组合这些块,有点像乐高积木,从同一组模块创建所有不同类型的应用程序。

由于模块非常有用,因此已经多次尝试将模块功能添加到 JavaScript。今天有两个模块系统正在积极使用。CommonJS (CJS) 是 Node.js 历史上使用的。ESM(EcmaScript 模块)是一个较新的系统,已添加到 JavaScript 规范中。浏览器已经支持 ES 模块,Node 高版本)也已经支持。

让我们深入了解一下这个ES模块系统是如何工作的。

ES 模块的工作原理

当你使用模块进行开发时,你会构建一个依赖关系图。不同依赖项之间的连接来自你使用的任何导入语句。

这些导入语句是让浏览器或 Node 如何确切知道它需要加载哪些代码的方式。你给它一个文件以用作程序打包的入口点。从那里它只是跟随任何导入语句来查找依赖的代码。

通过动画深入理解ES Module

但是文件本身并不是浏览器可以使用的。它需要解析所有这些文件以将它们转换为称为模块记录(Module Record)的数据结构。这样,它实际上知道文件中发生了什么。

通过动画深入理解ES Module

之后,需要将模块记录转化为模块实例。一个实例结合了两件事:代码状态

代码基本上是一组指令。这就像一个如何制作美食的食谱。但就其本身而言,你不能使用代码做任何事情。你需要食物原材料才能按照这些说明使用。

什么是状态?也就是食物原材料。状态是变量在任何时间点的实际值。当然,这些变量只是内存中保存值的字段昵称。

所以模块实例将代码(指令列表)状态(所有变量的值)结合起来。

通过动画深入理解ES Module

我们需要的是每个模块的模块实例。模块加载的过程是从这个入口点文件到拥有完整的模块实例图。

对于 ES 模块内部原理,发生在三个步骤中。

  1. 构建 — 查找、下载并将所有文件解析为模块记录。
  2. 实例化——会在内存中实现开辟空间来准备放置所有导出的值(但不要用值填充它们)。然后让导出和导入都指向内存地址。这称为链接。
  3. 评估——运行代码以用变量的实际值保存到内存中去。

通过动画深入理解ES Module

人们谈论 ES 模块是异步的。你可以将其视为异步,因为工作分为这三个不同的阶段——加载、实例化和评估——并且这些阶段可以单独完成。

这意味着规范确实引入了一种 CommonJS 中不存在的异步。稍后我会详细解释,但在 CJS 中,模块及其下的依赖项是一次性加载、实例化和评估的,中间没有任何中断。

但是,这些步骤本身不一定是异步的。它们可以以同步的方式完成。这取决于加载的内容。那是因为并非一切都受 ES 模块规范控制。实际上有两种工作模式,它们由不同的规范覆盖。

ES 模块规范说明了如何将文件解析为模块记录,以及如何实例化和评估该模块。但是,它并没有说明如何首先获取文件。

它是获取文件的加载器。并且加载器是在不同的规范中指定的。对于浏览器,该规范是HTML 规范。但是你可以根据你使用的平台有不同的加载器。

通过动画深入理解ES Module

加载器还精确控制地模块的加载方式。它调用 ES 模块方法 -  ParseModuleModule.InstantiateModule.Evaluate. 这有点像控制 JS 引擎字符串的木偶师。

通过动画深入理解ES Module

现在让我们更详细地介绍每个步骤。

建造

在构建阶段,每个模块都会发生三件事。

  1. 找出从哪里下载包含模块的文件(又名模块解析
  2. 获取文件(通过从 URL 下载或从文件系统加载)
  3. 将文件解析为模块记录

查找文件并获取它

加载器将负责查找文件并下载它。首先它需要找到入口点文件。在 HTML 中,你使用脚本标记告诉加载程序在哪里找到它。

通过动画深入理解ES Module

但是它如何找到下一组模块——main.js直接依赖的模块?

这就是导入语句的用武之地。导入语句的一部分称为模块说明符。它告诉加载器在哪里可以找到每个下一个模块。

通过动画深入理解ES Module

关于模块说明符需要注意的一件事:它们有时需要在浏览器和 Node.js 之间进行不同的处理。每个主机都有自己的解释模块说明符字符串的方式。为此,它使用了一种称为模块解析算法的东西,该算法因平台而异。目前,一些在 Node 中工作的模块说明符在浏览器中无法工作,但正在努力解决这个问题

在修复之前,浏览器只接受 URL 作为模块说明符。他们将从该 URL 加载模块文件。但这不会同时发生在整个依赖图表中。在解析文件之前,你不知道模块需要获取哪些依赖项……并且在获取文件之前无法解析文件。

这意味着我们必须逐层遍历树,解析一个文件,然后找出它的依赖关系,然后找到并加载这些依赖关系。

通过动画深入理解ES Module

如果主线程要等待这些文件中的每一个下载完成,许多其他任务就会堆积在它的队列中。

那是因为当你在浏览器中工作时,下载部分需要很长时间。

 

通过动画深入理解ES Module

根据这张图表

像这样阻塞主线程会使使用模块的应用程序太慢而无法使用。这是 ES 模块规范将算法拆分为多个阶段的原因之一。将构造拆分为自己的阶段允许浏览器在进行同步的实例化工作之前获取文件并建立他们在模块图的之间的关系。

这种方法——将算法分成多个阶段——是 ES 模块和 CommonJS 模块之间的主要区别之一。

CommonJS 可以做不同的事情,因为从文件系统加载文件所花费的时间比通过 Internet 下载要少得多。这意味着 Node 可以在加载文件时阻塞主线程。而且由于文件已经加载,所以只实例化和评估(在 CommonJS 中不是单独的阶段)是有意义的。这也意味着在返回模块实例之前,你正在遍历整个树,加载、实例化和评估任何依赖项。

通过动画深入理解ES Module

CommonJS 方法有一些含义,稍后我将详细解释这些含义。但这意味着在带有 CommonJS 模块的 Node 中,你可以在模块说明符中使用变量。require在查找下一个模块之前,你正在执行此模块中的所有代码(直到语句)。这意味着当你进行模块解析时,该变量将具有一个值。

但是使用 ES 模块,你需要预先构建整个模块图……在你进行任何评估之前。这意味着你的模块说明符中不能有变量,因为这些变量还没有值。

通过动画深入理解ES Module

但有时将变量用于模块路径确实很有用。例如,你可能希望根据代码正在执行的操作或运行的环境来切换加载的模块。

为了使 ES 模块成为可能,有一个名为动态导入的提议。有了它,你可以使用类似的导入语句import(`${path}/foo.js`)

其工作方式是使用加载的任何文件import()都被处理为单独程序的入口点。动态导入的模块会启动一个新的图,该图是单独处理的。

通过动画深入理解ES Module

不过需要注意的一点是——这两个图中的任何模块都将共享一个模块实例。这是因为加载器缓存了模块实例。对于特定全局范围内的每个模块,将只有一个模块实例。

这意味着CPU的工作量更少。例如,即使有多个模块依赖,模块文件也只会被获取一次。(这是缓存模块的一个原因。我们将在评估部分看到另一个。)

加载器使用称为模块映射的东西来管理这个缓存。每个全局都在单独的模块映射中跟踪其模块。

当加载器去获取一个 URL 时,它会将该 URL 放入模块映射中并记下它当前正在获取该文件。然后它将发出请求并继续开始获取下一个文件。

通过动画深入理解ES Module

如果另一个模块依赖于同一个文件会发生什么?加载器将在模块映射中查找每个 URL。如果它fetching在那里看到,它将继续前进到下一个 URL。

但是模块映射不只是跟踪正在获取的文件。模块映射还用作模块的缓存,我们将在接下来看到。

解析

现在我们已经获取了这个文件,我们需要将它解析成一个模块记录。这有助于浏览器了解模块的不同部分是什么。

通过动画深入理解ES Module

一旦创建了模块记录,它就会被放置在模块映射中。这意味着无论何时从这里请求它,加载程序都可以从该地图中拉出它。

通过动画深入理解ES Module

解析中有一个细节看似微不足道,但实际上具有相当大的影响。所有模块都被解析,就好像它们"use strict"在顶部一样。还有其他一些细微的差别。例如,关键字await在模块的顶层代码中保留,其this值为undefined.

这种不同的解析方式称为解析目标。如果你解析同一个文件但使用不同的目标,你最终会得到不同的结果。所以你想在开始解析之前知道你正在解析什么类型的文件——它是否是一个模块。

在浏览器中,这很容易。你只需type="module"添加脚本标签。这告诉浏览器这个文件应该被解析为一个模块。由于只能导入模块,因此浏览器也知道任何导入都是模块。

通过动画深入理解ES Module

但是在 Node 中,你不使用 HTML 标记,因此你没有使用type属性的选项。社区尝试解决此问题的一种方法是使用 .mjs扩展。使用该扩展名告诉 Node,“这个文件是一个模块”。你会看到人们将此作为解析目标的信号。讨论目前正在进行中,因此尚不清楚 Node 社区最终将决定使用什么信号。

无论哪种方式,加载器都会确定是否将文件解析为模块。如果它是一个模块并且有导入,它将重新开始该过程,直到所有文件都被提取和解析。

我们完成了!在加载过程结束时,您已经从只有一个入口点文件变为拥有一堆模块记录。

通过动画深入理解ES Module

下一步是实例化这个模块并将所有实例链接在一起。

实例化

就像我之前提到的,一个实例将代码与状态结合起来。该状态存在于内存中,因此实例化步骤就是将事物连接到内存。

首先,JS引擎创建一个模块环境记录。同时管理模块记录的变量。然后它会在内存中找到所有导出的空间。模块环境记录将跟踪内存中的哪个空间与每个导出相关联。

内存中的这些空间还没有得到它们的值。只有在评估之后才会填写它们的实际值。这个规则有一个警告:任何导出的函数声明都在这个阶段初始化。这使评估变得更容易。

为了实例化模块图,引擎将执行所谓的深度优先后序遍历。这意味着它将下降到图表的底部——到达底部不依赖任何其他东西的依赖项——并设置它们的导出。

通过动画深入理解ES Module

引擎完成了模块下所有导出的连接——模块所依赖的所有导出。然后它回到一个级别以连接来自该模块的导入。

请注意,导出和导入都指向内存中的同一位置。首先连接出口可以保证所有进口都可以连接到匹配的出口。

通过动画深入理解ES Module

这与 CommonJS 模块不同。在 CommonJS 中,整个导出对象在导出时被复制。这意味着导出的任何值(如数字)都是副本。

这意味着如果导出模块稍后更改该值,则导入模块不会看到该更改。

通过动画深入理解ES Module

相比之下,ES 模块使用称为实时绑定的东西。两个模块都指向内存中的相同位置。这意味着当导出模块更改值时,该更改将显示在导入模块中。

导出值的模块可以随时更改这些值,但导入模块不能更改其导入的值。话虽如此,如果模块导入一个对象,它可以更改该对象上的属性值。

通过动画深入理解ES Module

像这样进行实时绑定的原因是,你可以在不运行任何代码的情况下连接所有模块。当你有循环依赖时,这有助于评估,我将在下面解释。

所以在这一步结束时,我们已经连接了导出/导入变量的所有实例和内存位置。

现在我们可以开始评估代码并用它们的值填充这些内存位置。

评估

最后一步是在内存中填写这些框。JS 引擎通过执行顶层代码——函数之外的代码来做到这一点。

除了在内存中填充这些框外,评估代码还可以触发副作用。例如,一个模块可能会调用一个服务器。

通过动画深入理解ES Module

由于潜在的副作用,您只想评估该模块一次。与实例化中发生的链接相反,可以多次执行相同的结果,评估可能有不同的结果,具体取决于您执行的次数。

这是拥有模块图的原因之一。模块映射通过规范 URL 缓存模块,以便每个模块只有一个模块记录。这确保每个模块只执行一次。就像实例化一样,这是作为深度优先的后序遍历完成的。

我们之前谈到的那些周期呢?

在循环依赖中,你最终会在依赖图中出现循环。通常,这是一个很长的循环。但是为了解释这个问题,我将使用一个带有短循环的人为示例。

通过动画深入理解ES Module

让我们看看这将如何与 CommonJS 模块一起工作。首先,主模块将执行到 require 语句。然后它将去加载计数器模块。

通过动画深入理解ES Module

然后计数器模块将尝试message从导出对象进行访问。但由于尚未在主模块中对其进行评估,因此这将返回未定义。JS 引擎会在内存中为局部变量分配空间,并将值设置为 undefined。

通过动画深入理解ES Module

评估一直持续到计数器模块顶层代码的末尾。我们想看看我们最终是否会得到正确的 message 值(在评估 main.js 之后),所以我们设置了一个超时。然后继续评估main.js

通过动画深入理解ES Module

消息变量将被初始化并添加到内存中。但由于两者之间没有联系,它将在所需模块中保持未定义。

通过动画深入理解ES Module

如果使用实时绑定处理导出,则计数器模块最终会看到正确的值。到超时运行时,main.js的评估将完成并填写该值。

支持这些循环是 ES 模块设计背后的一大理由。正是这种三相设计使它们成为可能。

ES 模块的状态如何?

所有主流浏览器都后续将默认支持 ES 模块。Node 也在增加支持,有一个工作组致力于解决 CommonJS 和 ES 模块之间的兼容性问题。

这意味着将能够将脚本标签与 一起使用type=module,并使用导入和导出。动态导入提案处于规范过程的第 3 阶段,import.meta有助于支持 Node.js 用例,模块解析提案也将有助于消除浏览器和 Node.js 之间的差异。因此,可以期望在未来使用模块会变得更好。

译自ES modules: A cartoon deep-dive

转载自:https://juejin.cn/post/7157970279498137614
评论
请登录