wolful/Learning

[译]深入研究-ES modules(漫画版)

wolful opened this issue · 0 comments

原文地址

   虽然花了将近10年的标准化工作,但是ES模块最终还是为JS带来了一个正式的、标准化的模块系统。
   随着Firefox 60在5月份的发布(目前处于beta版),所有主要浏览器将支持ES模块。并且,Node模块工作组目前也正在致力于向Node.js添加ES模块支持。同时,将ES模块集成进入WebAssembly也在进行当中。
   许多JavaScript开发人员知道ES模块一直备受争议,但很少有人真正理解ES模块是如何工作的。
所以,让我们看一下ES模块是为了解决哪些问题,以及它们与其他模块系统中的模块的区别。

1. 模块是为了解决什么问题?

当你仔细想想的时候,你会发现JavaScript中的coding都是关于管理变量的。它的全部内容是将变量赋值给变量,或者将变量添加到变量,或者将两个变量组合在一起,并将它们放入另一个变量中。
variables
   因为你的代码大部分都是关于改变变量的,如何组织这些变量会对你能怎样编码以及你能怎样维护代码有很大影响。
   如果一次只考虑维护几个变量会相对简单,因为JavaScript的作用域可以帮助你做到:作用域的存在,函数不能访问其他函数中定义的变量。

scope

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

   不过,它也有不利的一面。那就是很难在不同的函数之间共享变量

    如果您想在作用域之外共享变量,该怎么办?处理这个问题的一种常见方法是把它放在共同的上层作用域里。例如全局作用域。

你可能会想起JQuery时代,在加载任何jQuery插件之前,必须确保jQuery处于全局范围内。

jquery

这样就可以运行了,但存在一些令人烦恼的问题。

    第一个问题是,所有的脚本标签需要按正确的顺序排列。 然后,你必须小心,以确保没有哪个依赖顺序没注意打乱了这个排列。

如果你弄乱了这个顺序,那么在运行过程中,app就会出错。当函数寻找jQuery时,期望它在全局上,但是全局并没有找到jQuery,那程序将抛出错误并停止执行。

scope error

这使得维护代码棘手,去除旧代码或脚本标签像是轮盘游戏一样危险。这些代码的不同部分之间的关系是隐含的,你不知道什么东西会破环依赖。任何函数都可以获取对全局的东西,所以你不知道哪个函数依赖哪些script。

    第二个问题是,因为这些变量是在全局作用域中,其中所有的代码都能改变这些变量。 恶意代码也可以更改变量,以使你的代码做了一些非你意图的事,或者非恶意代码也可能会不小心和你的变量冲突。

2. 模块有什么作用?

    模块为我们提供了更好的方法来组织这些变量和函数。在模块中,你可以把相同意义的变量和函数分组在一起。将这些函数和变量放入模块作用域,它可以在模块中的函数之间共享变量。

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

   这些其他模块共用的东西被称为export,一旦有了export,其他模块就可以明确地表示它们依赖于该变量、类或函数。

share modules

相比全局变量的隐式调用,模块引用是一个显式关系,如果移除某个模块,可以知道哪些模块会break。

    此外,一旦有了在模块之间导出和导入变量的能力,它就更容易将代码拆分成可以相互独立工作的小块。然后可以像乐高块一样组合和重组这些块,可以利用同一模块中创建有不同类型的应用程序。(译者注:总结来说优势是显式调用和独立划分)

    由于模块非常好用,各宿主环境曾经多次尝试将模块功能添加到JavaScript中。直至今天,有两个模块系统被广泛地使用。一个是CommonJS(CJS), CJS是Node.js至今一直使用的模块系统。另一个是ESM(ECMAScript模块),它是一种新的系统,已经被添加到JavaScript规范中。目前浏览器已经支持ES模块,Node也正在支持中。

今天我们深入研究这个新的模块系统(ESM)是如何工作的。

3. ES modules的原理

当用模块开发时,会建立一个依赖关系图。不同依赖关系之间的连接来源于import语句。

这些语句让浏览器或Node环境确切地知道需要加载什么代码。你可以用一个文件作为依赖图的入口,然后从那里逐步找到其余引入的代码。

graph

但是文件本身并不是浏览器可以运行的东西,需要解析所有这些文件,并将它们转换为称为Module Records的数据结构,浏览器才实际上知道如何运行这些文件。

records

以上工作完成之后,需要将Module Records转换为拥有代码和状态的模块实例。

其中,代码基本上是一组指令。这就像是如何制作东西的方法。但你不能用它本身来做任何事情,因为它需要原材料。
状态,就是原材料。状态是变量在任何时间点的实际值。当然,这些变量只是内存中保存值的昵称。

因此,模块实例实际上就是将代码(指令列表)与状态(所有变量的值)结合起来的产物。

instance

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

对于ES模块,这个过程有三个步骤。

  1. 构造-查找、下载和解析所有文件到Module Records
  2. 实例化-在内存中查找位置以将所有export的values放入(但不填充值)。然后使导出和导入都指向内存中的这些位置。这叫做链接。
  3. 运行-运行代码以填充变量的实际值。

process

大家都知道ESM是异步的,因为你可以把它分解成三个相互独立的阶段-loading, instantiating, evaluating。

    这样看来,ESM引入了一种和CommonJS中不同的异步。在CJS中,模块和后面的依赖项都在被加载、实例化的同时进行evaluating,它们之间是同步进行的。

    然而,ESM的这个三个步骤本身并不一定是异步的,它们可以同步地完成。这取决于loading的是什么,因为不是所有的东西都是由ESM规范控制的。实际上这些工作有两个部分,它们都被不同的规格所覆盖。

    ESM规范说明如何将文件解析为模块记录,以及如何对该模块进行实例化(instantiate)和运行(evaluate)。然而,它并没有说明如何获得文件。

   获取文件的是加载器(loader)。并且它在不同的规范中各有不同。对于浏览器来说,该规范是HTML规范。但是不同的平台会有对应不同的加载器。

spec

加载器还精确地控制模块加载的方式。它调用了ESM方法-----ParseModule, Module.Instantiate, 和Module.Evaluate。就像一个操纵JS引擎的傀儡手。

puppeteer

下面详细介绍每一个步骤。

3.1 Construction(构造)

每个模块在构建阶段都会经历三个步骤。

  • 从哪里下载包含模块的文件(又称module resolution)

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

  • 将文件解析为Module records(模块记录)

a. 查找文件并获取文件

加载器将负责查找文件并下载它。首先需要找到entry文件。在HTML中,可以用script tag来告诉加载器在哪里找到它。

scriptentry

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

这是导入语句的起源。导入语句的一部分称为module specifiers(模块说明符)。 它告诉loader哪里可以找到下一个模块。

module_specifier

    模块说明符有一点需要注意:它们在浏览器和Node之间处理各有不同。每个平台都有自己解释模块说明符字符串的方式。为了做到这一点,都使用了一种叫做模块分解算法的东西,它在平台之间是不同的。目前,在Node中工作的一些模块说明符在浏览器中不起作用,但是正在解决这个问题

    除非上述情况修复,不然浏览器还是只接受URL作为模块说明符。他们将从该URL加载模块文件。但是不会同时下载整张依赖图上的文件,因为除非parse到某文件,否则你不能发现它依赖的哪些文件是需要加载的。

    这意味着我们必须逐层遍历树,parse一个个文件,然后计算其依赖关系,然后查找并加载这些依赖项。

findmap

如果主线程要等待每个文件加载结束,那么很多其他任务将堆积在其队列中。

这是为什么使用浏览器时,下载部分需要很长时间的原因。

cpu_time

像这样阻塞主线程会使app速度太慢而无法使用。这也是ES模块标准将算法分为多个阶段的原因之一。 将construction(构造)分解到其自己的阶段可以允许浏览器在进行同步实例化(instantiating)之前加载文件并建立模块依赖图。

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

CommonJS因为从文件系统加载文件比在Internet上下载需要更少的时间,所以CommonJS可以和ES模块不同。这意味着Node可以在加载文件时阻塞主线程。 而且由于文件已经被加载,因此只需实例化和评估(在CommonJS中instantiate和evaluate不是分离的阶段)。这也意味着在返回模块实例之前,会遍历整棵树,加载,实例化和运行任何依赖关系。

CommonJS的这种特性有些隐晦的地方,后面会介绍到。但是有一件事是在CommonJS标准中,在模块符号(module specifier)里可以使用变量。你可以执行在require下一个依赖前的任何代码。这意味着模块解析时变量将会有一个值。

但是在ES模块中,在运行任何代码前就已经提前构建了依赖图。这也意味着你的module specifier中不能有变量,因为这些变量没有值。

有时候利用变量来控制模块路径非常有用。例如,你可能希望根据环境和代码运行来控制加载不同的依赖。

为了让ES modules也有这种动态加载功能,有个dynamic import的计划,它可以支持import(${path}/foo.js)这种方法的import.

它的内部原理是所有被import()加载的文件都作为另外一个分离依赖图的entry。这种动态加载的module重新开始一个分离的、新的依赖图(graph)。

dynamic_import

还有一个需要注意的是两个依赖图里共享同一个模块实例。这是因为loader会缓存模块实例。对特定全局作用域的每个模块都只会有一个模块实例。

这可以减轻引擎的负荷。例如,尽管多个模块依赖它,它也只会加载一次(这只是缓存模块的原因之一,其他原因将在下面执行部分解释)

loader通过一个module map来维护这个缓存列表。每个全局在单独的map中对应其模块。

当loader开始fetch某个模块前,会把这个URL记录在map中,同时记录它正在fetching,然后发送请求后开始转向加载下一个模块。

loader_module

如果其他模块同样依赖该模块该怎么办?loader会先看module map,如果它看到fetching,会转向加载接下来的一个模块。

但是module map不仅仅记录哪些模块被加载了,它还充当模块的缓存。下面会介绍到。

b. Parsing(解析)

到目前为止,我们已经加载了文件,现在需要将它们解析成一个module record。这帮助了浏览器理解模块不同的部分是什么。

module_record

一旦module record创建了,它就会被放置在module map中。无论何时请求出去,loader会把它pull进map。

module_map

在parsing中,有一个看起来微不足道但实际上会有大影响的细节。所有模块如果在顶部有'use strict',它都会被解析。但是有一点轻微的不同,例如,await在模块最外层代码是保留字,而且thisundefined.

不同的parsing被成为一个'parse goal'。如果用不同的goals解析同一文件,最终将获得不同的结果。所以你在开始解析之前希望知道它是否是一个module。

在浏览器中这个很简单。在script标签上设置type=module就可以了,这相当于告诉浏览器这个文件需要被作为module解析,而且一旦有import的文件,浏览器都知道是module。

parse_goal

但是在Node中,由于不使用HTML标签,所以你没有type这个属性的配置。社区尝试用.mjs这个后缀来解决这个问题,告诉Node这个文件是一个module。人们在谈论这个Node作为parse goal的一种标记,这个讨论目前还在继续,还不清楚Node社区最终决定使用哪种标记来区分。

无论哪种方式,loader最终将决定是否将文件作为module解析。如果它是一个module并且有import,会开始一个直到所有文件都被加载和解析的过程。

在加载过程完成后,入口文件将变成一堆module records。

construction

下一个步骤是将这些模块实例化并链接所有其他的实例。

3.2 Instantiation(实例化)

本文前面提到,一个实例是code和state的结合。状态存在内存中,所以实例化的步骤基本上都是关于将内容写到内存上。

首先,js引擎创建一个module环境记录,用来为module record管理变量,然后为所有的exports找到内存中块(box)。module 环境记录将会跟踪内存中每个export关联的内存块。

这些内存块目前还没有值,只有在运行之后才会有真实的值来填充。有一个预告是:所有export的function声明是在这个阶段初始化的。这将让运行更加简单。

为了实例化模块依赖图,引擎将进行深度优先后序遍历。这意味着它将到依赖图的最底部,从底部不依赖任何模块的模块开始设置它们的export。

live_bindings

引擎完成一个模块下所有的exports写入后,然后回到某一层级然后开始在内存上写入import。

需要注意的是export和import都指向内存中同一个位置(注:这就是ESmodule的值引用),先写exports是为了保证import都能链接到match的export。

live_bingdins

CommonJS模块同ES不同,在CommonJS中,所有的export object是复制的,这意味着所有export的值都是复制的。

cjs_variable

相比之下,ES模块使用的是动态绑定,export和import都指向内存中同一个位置。也就是说,如果export模块改变了某个值,这个改变也会表现在import模块里。

export的模块能在任何时间改变它的值,但是import模块无法改变它们import的值。如果一个模块import一个object,它可以改变那个对象的属性值。

live_bindings

使用动态绑定的理由是你可以在不运行任何代码的情况下把所有模块链接起来。下文还会解释它对循环引用的帮助。

在实例化这个步骤最后,我们已经有所有的实例,链接export/import变量的内存地址。

现在我们开始运行代码并为内存地址填充它们的值。

3.3 Evaluation(运行)

最后一个步骤是往内存块中填入值。JS引擎通过执行除在函数之外的顶层代码来填充值。

除了往内存中填值以外,运行代码还可能引起副反应,例如,一个模块可能可能会请求server.

execute_ing

因为它潜在的副作用,你希望module仅执行一次。与实例化中的linking不同,linking运行执行多次还是会保持绝对相同的结果,但evaluation会根据运行次数的不同会有不同的结果。

这就是存在module map的原因之一。module map通过URL缓存了module,所以一个module只有一个module record,就保证了每个module只执行一次。和实例化一样,这是一个深度优先后续遍历的过程。

如果有上面提到的循环依赖怎么办?

在一个循环引用中,最终在图中会存在一个环。通常(实际代码中)是一个很长的环,为了解释这个问题,我们人为地使用一个简单的环来代替。

cjs_cycle

让我们看看commonjs模块是怎么运行循环依赖的。首先,顶部模块先执行require语句,然后加载counter模块.

cyclic_graph

counter模块尝试通过export object来获取message,但是message在顶部模块中还没有执行,所以它将返回undefined。JS引擎会为本地变量在内存中分配一个地址并设置值为undefined

cjs_variable

Evaluation在counter模块中继续往下,我们希望看到message最终能否获得正确的值,所以在最后设置了一个timeout。然后回到执行main.js

之后message被初始化并被添加到内存中,但是由于两者之间没有连接了,所以counter中对应的还是undefined

cjs_variable

如果export是动态绑定的,counter(message)最终会获得正确的值,到timeout执行的时候main.js的执行会为填充这个值。

支持这种循环依赖是设计ES modules前一个巨大的初衷。正是这种分三阶段(实例化、链接、执行)才使得其成为可能。

4. ES modules目前处在什么状态?

随着五月初Firefox 60的发布,几乎所有主流浏览器都支持ES modules了. Node也正在支持,一个工作组宣称弄清楚了CommonJS和ES module之间的兼容性问题。

这意味着,你既可以使用有type=module的script标签,也可以使用import上和exports。然而,更多的模块功能还尚未到来,dynamic import proposal(动态导入)功能还在标准化过程中的 Stage 3, 它作为import.meta也将帮助支持Node.js里面的一些特性,module resolution proposal (模块解析项目)将帮助抹平浏览器和Node之间的差异。所以,你可以预计到未来使用模块将会越来越好。

5. 感谢

感谢所有对本文提供反馈、编写或讨论的人,包括Axel Rauschmayer, Bradley Farias, Dave Herman, Domenic Denicola, Havi Hoffman, Jason Weathersby, JF Bastien, Jon Coppeard, Luke Wagner, Myles Borins, Till Schneidereit, Tobias Koppers, and Yehuda Katz,同样感谢WebAssembly community group的成员,Node modules工作组以及TC39.

6. 关于Lin Clark(作者)

Lin是Mozilla开发者关系团队的一个工程师,她捣鼓JavaScript、WebAssembly
、Rust以及Servo,有时候也画一些代码的漫画。


总结

  • module是用来管理变量的
  • ESM包括了三个步骤:Construction(构造:加载+解析) - Instantiation(实例化) - Evaluation(运行)
  • 加载这部分的是HTML规范,非ES的规范
  • ESM目前module specifiers只支持URL
  • ESM不是CJS那种运行时加载的,因为一边执行一边加载,会造成web的延时非常高,先下载然后再实例化和执行可以避免一个流程阻塞造成后续完全不可用
  • 因为ESM是编译是动态绑定的,所以取值上和CJS大有不同。ESM是值引用,可以在export里更改,在import中不可改普通类型的值,但是可以更改对象的值
  • Module Records是浏览器才能理解的数据结构,最后会转换成带有代码和状态的module实例
  • Module Map 用来缓存模块加载过程和Module Records,同时一个module只能对应一个Module Records,并且只执行一次

参考