ES modules: A cartoon deep-dive

ES模块为JavaScript提供了官方的模块系统,解决了变量管理和作用域问题,允许模块间安全地导出和导入。它们通过异步加载和实例化过程提高了性能,消除了全局作用域的污染和依赖性管理的挑战。目前,所有主流浏览器都支持ES模块,Node.js也在逐步添加支持。
摘要由CSDN通过智能技术生成

ES模块为JavaScript带来了官方的、标准化的模块系统。不过,它花了一段时间才走到这一步——将近10年的标准化工作。

但等待即将结束。随着5月份Firefox 60的发布(目前处于测试版),所有主流浏览器都将支持ES模块,Node模块工作组目前正在努力将ES模块支持添加到Node.js中。WebAssembly的ES模块集成也正在进行中。

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

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

模块解决了什么问题?

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

因为你的很多代码都是关于改变变量的,你如何组织这些变量将会对你的代码写得有多好有很大的影响,以及你如何维护你的代码。

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

这很好。这意味着当你研究一个函数时,你可以只考虑这个函数。你不用担心其他函数会对你的变量做什么。

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

如果您确实希望在作用域之外共享变量,该怎么办?一种常见的处理方法是将它放在你上面的作用域中……例如,在全局作用域中。

在jQuery时代,您可能还记得这一点。在加载任何jQuery插件之前,必须确保jQuery处于全局作用域中。

这是可行的,但会产生一些烦人的问题。

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

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

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

第二个问题是,因为这些变量在全局作用域中,所以在全局作用域中的每一部分代码都可以更改变量。恶意代码可以故意更改该变量,使您的代码做一些您不希望它做的事情,或者非恶意代码可能只是意外地破坏您的变量。

模块如何提供帮助?

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

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

但与函数作用域不同的是,模块作用域有一种方法可以让其他模块也可以使用它们的变量。它们可以显式地指出模块中的哪些变量、类或函数应该可用。

当某些东西对其他模块可用时,它被称为导出。一旦有了导出,其他模块就可以显式地表示它们依赖于该变量、类或函数。

因为这是一个显式的关系,所以如果您删除了另一个模块,您可以知道哪些模块将被破坏。

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

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

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

ES模块如何工作

当您使用模块进行开发时,您将建立一个依赖关系图。不同依赖项之间的连接来自您使用的任何import语句。

这些导入语句是浏览器或Node如何确切地知道它需要加载什么代码的。您给它一个文件作为图的入口点。从这里开始,它只需跟随任何import语句来查找其余的代码。

但是浏览器不能使用文件本身。它需要解析所有这些文件,将它们转换为称为Module Records的数据结构。这样,它实际上知道文件中发生了什么。

在此之后,需要将模块记录转换为模块实例。实例结合了两件事:代码和状态。

代码基本上是一组指令。这就像是制作某样东西的食谱。但就其本身而言,您不能使用代码来做任何事情。你需要原料来使用这些说明。

什么是状态?国家给你原材料。状态是变量在任何时间点的实际值。当然,这些变量只是内存中保存值的盒子的别名。

因此,模块实例将代码(指令列表)与状态(所有变量的值)结合在一起。

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

对于ES模块,这分为三个步骤。

  1. 构造——查找、下载并将所有文件解析为模块记录。

  1. 实例化-在内存中找到可以放置所有导出值的盒子(但还没有用值填充它们)。然后使导出和导入都指向内存中的这些盒子。这就是所谓的连接。

  1. 计算—运行代码,用变量的实际值填充方框。

人们谈论ES模块是异步的。您可以认为它是异步的,因为工作分为三个不同的阶段——加载、实例化和求值——而且这些阶段可以分别完成。

这意味着规范引入了一种CommonJS中没有的异步。稍后我将详细解释,但在CJS中,模块及其下面的依赖项是同时加载、实例化和求值的,中间没有任何中断。

但是,这些步骤本身不一定是异步的。它们可以以同步的方式完成。这取决于装载的是什么。这是因为并不是所有的事情都由ES模块规范控制。实际上有两部分工作,它们由不同的规范覆盖。

ES模块规范规定了如何将文件解析为模块记录,以及如何实例化和计算该模块。然而,它并没有说明如何首先获取这些文件。

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

加载器还精确地控制如何加载模块。它调用ES模块方法——ParseModule, module。实例化和Module.Evaluate。这有点像一个木偶师控制JS引擎的字符串。

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

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

1、找出从哪里下载包含模块的文件(又名模块解析)

2、获取文件(通过从URL下载或从文件系统加载)

3、将文件解析为一个模块记录

查找文件并获取它

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

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

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

关于模块说明符需要注意的一点是:它们有时需要在浏览器和Node之间以不同的方式处理。每个主机都有自己解释模块说明符字符串的方式。为了做到这一点,它使用了一种称为模块解析算法的东西,该算法在不同平台之间有所不同。目前,一些在Node中工作的模块说明符在浏览器中不能工作,但正在进行修复工作。

在这个问题解决之前,浏览器只接受url作为模块说明符。它们将从该URL加载模块文件。但这不会同时发生在整幅图上。在解析文件之前,你不知道模块需要你获取哪些依赖项……而在获取文件之前,你无法解析文件。

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

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

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

像这样阻塞主线程会使使用模块的应用程序变得太慢而无法使用。这就是ES模块规范将算法划分为多个阶段的原因之一。将构造分解到自己的阶段允许浏览器在开始实例化的同步工作之前获取文件并建立对模块图的理解。

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

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

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

但是使用ES模块,你在做任何评估之前就建立了整个模块图。这意味着在模块说明符中不能有变量,因为这些变量还没有值。

但是有时候使用变量来表示模块路径是非常有用的。例如,您可能希望根据代码正在做什么或运行在什么环境中来切换加载哪个模块。

为了使ES模块实现这一点,有一个称为动态导入的建议。有了它,你可以使用import(' ${path}/foo.js ')这样的导入语句。

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

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

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

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

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

如果另一个模块依赖于同一个文件会发生什么?加载器将在模块映射中查找每个URL。如果它在那里看到抓取,它就会移动到下一个URL。

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

解析

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

一旦创建了模块记录,它就被放置在模块映射中。这意味着无论何时它被请求,加载器都可以从那个映射中拉出它。

解析中有一个细节看起来微不足道,但实际上意义重大。所有模块都被解析,就好像它们在顶部有“use strict”一样。还有其他细微的区别。例如,关键字await在模块的顶级代码中保留,并且它的值是未定义的。

这种不同的解析方式称为“解析目标”。如果解析相同的文件,但使用不同的目标,最终将得到不同的结果。因此,在开始解析之前,您想知道您要解析的是哪种文件—它是否是模块。

在浏览器中,这非常简单。您只需在脚本标记上输入type="module"。这告诉浏览器这个文件应该被解析为一个模块。因为只有模块可以导入,所以浏览器知道任何导入都是模块。

但是在Node中,您不使用HTML标记,因此您不能选择使用类型属性。社区尝试解决这个问题的一种方法是使用.mjs扩展名。使用该扩展名告诉Node,“这个文件是一个模块”。您将看到人们将其视为解析目标的信号。目前讨论还在进行中,所以还不清楚Node社区最终会决定使用什么信号。

无论采用哪种方式,加载器都将决定是否将文件解析为模块。如果它是一个模块,并且有导入,那么它将重新开始这个过程,直到获取和解析所有文件。

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

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

实例化

如前所述,实例将代码与状态结合在一起。该状态存在于内存中,因此实例化步骤就是将事物连接到内存中。

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

内存中的这些盒子还不能得到它们的值。只有在计算之后,它们的实际值才会被填写。此规则有一个警告:任何导出的函数声明都在此阶段初始化。这使得评估更加容易。

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

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

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

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

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

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

导出值的模块可以在任何时候更改这些值,但导入模块不能更改其导入的值。也就是说,如果一个模块导入了一个对象,它可以改变该对象上的属性值。

使用这样的动态绑定的原因是,您可以在不运行任何代码的情况下连接所有模块。当您有循环依赖时,这有助于计算,我将在下面解释。

因此,在这一步的最后,我们将导出/导入变量的所有实例和内存位置连接起来。

现在,我们可以开始计算代码并将它们的值填充到这些内存位置。

评估

最后一步是在内存中填充这些方框。JS引擎通过执行顶层代码(函数之外的代码)来实现这一点。

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

由于潜在的副作用,您只需要评估模块一次。与实例化中发生的链接相反,可以多次执行完全相同的结果,求值可以根据执行次数获得不同的结果。

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

我们之前讲过的那些周期呢?

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

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

然后counter模块将尝试访问来自导出对象的消息。但是因为这个还没有在主模块中求值,所以它将返回undefined。JS引擎将在内存中为局部变量分配空间,并将值设置为undefined。

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

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

如果使用活动绑定处理导出,计数器模块最终将看到正确的值。到超时运行时,main.js的求值已经完成并填充了值。

支持这些周期是ES模块设计背后的一个重要原理。正是这种三阶段设计使它们成为可能。

ES模块的状态如何?

随着五月初Firefox 60的发布,所有主流浏览器都将默认支持ES模块。Node也增加了支持,有一个工作组专门解决CommonJS和ES模块之间的兼容性问题。

这意味着您将能够使用带有type=module的脚本标记,并使用导入和导出。然而,更多的模块特性还在后面。动态导入建议处于规范过程的第3阶段,导入也是如此。这将有助于支持Node.js用例,模块解决方案也将有助于消除浏览器和Node.js之间的差异。因此,您可以期待将来使用模块的工作变得更好。

文章来源: https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值