14 min read

对 ES Modules 的一点理解

长久以来,在前端工程里我们不断的书写 ✍️ 类似的文件头部,引入我们需要的模块,无论是自己编写的组件还是引入公共库。

image

在之前很长一段时间,这类机制在我看来仿佛黑箱一样,对其背后发生感到了好奇

在逐渐理解模块化之后,如果你也对此有过我曾经同样的感受,我想接下来的内容能让你减少一些困惑

Motivation

我们可能已经习惯书写了各式各样的模块引入方式,但让我们先忘记这些

如果一切还原到最初的时期,仅仅是一些零碎的变量组成的文件

比如 a.jsb.js

image

那么现在我们需要一点更好玩的东西了,我们需要让 两个文件同时使用公共的第三方变量

最好的例子就是 jQuery

image

可以看到 a.jsb.js 都使用了 jQuery 的变量

那如果我们将 jQuery 删掉,或者改变书写的顺序,比如 <script> 放在 引入 a.jsb.js之后

image

使用 jQuery 的地方将抛出一个错误,并停止继续执行下去

我们这个简单的例子可能有点简陋,不足以说明在类似情景所遇到的困难。维护老代码成了关于作用域的运气游戏或者经历抽丝剥茧才能确定变量被哪些文件引用,才能安全的修改

模块化 提供给我们更好的方式组织变量,拆分/组织不同含义的模块,并且可以明确何时何地引用了哪些变量,显式的关系更容易排查问题,以及可以让引擎去做一些语义分析

How ES modules work

ES Modules 是 浏览器原生支持的模块系统

而在之前,常用的是 CommonJS 和基于 AMD 的其他模块系统 如 RequireJS

以及 Webpack 自己实现的 模块化系统,在后面我们会再次提及这个

ES Modules 的关键字是 importexport

ES Moudles 模块加载将包含三个主要的阶段,这里简单提要一下

  1. Construction — 查找、下载并解析所有文件到模块记录中。
  2. Instantiation — 在内存中寻找一块区域来存储所有导出的变量(但还没有填充值)。然后让 export 和 import 都指向这些内存块。这个过程叫做链接(linking)
  3. Evaluation — 运行代码,在内存块中填入变量的实际值

这三个阶段可以分开完成,意味着异步的

最后的产物将被称作 Module Graph 模块图谱

我们用一个简单的例子来说明ES Moudles 加载模块其背后大致发生了什么

Construction

Find 查找

通常我们会有一个主文件 main.js 作为一切的开始

image

然后通过 import 语句 去 引入 其他模块所 导出 的内容

image

import 语句中的一部分称为 Module Specifier。它告诉 Loader 在哪里可以找到引入的模块。

image

注意不同宿主环境下,不同的 Loader 实现可能不尽相同,这将导致在 Node 中习惯使用的方式换在浏览器上可能发生错误

比如在 CommonJS 中 或者 利用 Webpack 进行 bundled,我们通常习惯这样书写

import moment from 'moment';

通过仅仅指定包名称,背后就能通过查询算法找到包的主程序

这个方式的主要优点是可以轻松地在整个生态系统中进行协调。任何人都可以编写一个模块,并使用包的众所周知的包名称,然后让 Node.js 运行时 或 其构建时工具 负责将其转换为磁盘上的实际文件

而在浏览器端,目前无法直接这样使用 包名称

但在未来或许可以

详细请参阅 import-maps

在此之前,在 浏览器 只能使用 URL 作为 Module Specifier ,也就是使用 URL 去加载模块

Download 下载

而有个问题也随之而来,浏览器在解析文件前并不知道文件依赖哪些模块,当然获取文件之前更无法解析文件,这将导致整个解析依赖关系的流程是阻塞的,

image

如果浏览器按这样进行解析,那么模块系统会慢到无法使用

CommonJS 是在 Node 环境下诞生的,从文件系统加载文件比在 Internet 上下载的时间要少得多,这意味着 Node 可以在加载文件时阻塞主线程。而且既然文件已经加载了,直接实例化和求值(在 CommonJS 中并不区分这两个阶段)

所以 ES Modules 规范 将 Construction 这个步骤单独分离出来,使浏览器在执行同步的初始化过程前可以自行获取并下载文件以及解析

补充:ES Modules 规范 没有规定浏览器如何获取(查找/下载)文件,只定义了该如何解析文件(解析文件到 Module Record

这也暗示了 ES Modules 可以被称为异步的原因之一

Parse 解析

刚才已经提及了很多关于解析的内容,实际上解析文件是助于浏览器了解模块内的构成,而我们把它解析出来的模块构成表 称为 Module Record 模块记录

image

模块记录包含了当前模块的 AST,引用了哪些模块的变量,以前一些特定属性和方法

模块记录被创建后将被记录在称作 Module Map 的表中

image

被记录后,如果再有对相同 URL 的请求,Loader 将直接采用 Module MapURL 对应的 Module Record

当解析完全部文件后,我们将得到很多 Module Record

  • 另外提及一下不同方式的 Parse 被称为 Parse Goal ,使用不同方式 Parse Goal 解析相同的 file ,得到的产物也是不一样的,所以我们会在 script 标签 添加 type = "module" 来告诉浏览器这个文件将是一个模块

  • 但在 Node 中,是没有 HTML 标签的,所以需要其他的方式来辨别,社区目前的主流解决方式是修改文件的后缀为 .mjs ,来告诉 Node 这将是一个模块。不过还没有标准化,而且还存在很多兼容问题

Construction过程不会实例化和执行任何的代码,在进行任何求值之前,需要事先构建整个模块图,这也是被称为 静态解析 的原因。

正因为此,Module Specifier 也无法使用变量,因为在解析时并没有执行任何代码,也就没有任何状态

CommonJS 的话,在查找下一个模块之前,执行了此模块中的所有代码(直至 require 语句)。这意味着当进行 模块解析 时,变量会有值。

image

ES Moudle 为了支持在模块路径中使用变量,有一个提案实现它:动态导入

image

原理是 任何通过 import() 加载的模块,将称为独立的依赖图入口,也就是说脱离之前的依赖图独自处理(就像 main.js

好了,从普通的主入口文件,到现在我们有了很多 Module Record,接下里可以进行 实例化

Instantiation

为了实例化 Module Record ,引擎将采用 Depth First Post-order Traversal 深度优先后序进行遍历,JS 引擎将会为每一个 Module Record 创建一个 Module Environment Record 模块环境记录,它将管理 Module Record 对应的变量,并为所有 export 分配内存空间(这个阶段中并未求值)

这个阶段中,export 导出的如果是函数,将会被初始化(函数具有提升作用),这有利于下一阶段求值

image

引擎遍历将深入直到依赖树末端没有任何依赖的文件,处理好每个模块的 export 与 内存连接,之后逐层回溯进行连接该模块的所有 import

请注意,export 和 import 都指向内存中的同一个区域。先连接 export ,保证了所有的 export 都可以被连接到对应的 import 上。

image

ES Modules 的这种连接方式被称为 Live Bindings 动态绑定,模块进行 import 将是指向内存中的一块区域。这意味着导出的模块内将可以修改被引用的变量,其修改将会反应在导入该变量的模块中(导入变量的模块无法修改导入的值,如果导入的是对象类型,那么可以修改其属性值)

之所以 ES Modules 采用 Live Bindings,是因为这将有助于做静态分析(不用执行 Code)以及规避一些问题,如循环依赖。

CommonJS 导出的是 copy 后的 export 对象,这意味着如果导出模块稍后更改该值,则导入模块并不会看到该更改。

这也就是通常所见到的结论:CommonJS 模块导出是值的拷贝,而 ES Modules 是值的引用。

Evaluation

还记得我们 通过内存 连接好了所有 export 和 import 吗,但内存还尚未有值

JS 引擎通过执行顶层代码(函数之外的代码),来向这些内存区添值

image

Instantiation 阶段一样,也是通过 深度优先后序 进行遍历,并且通过 Module Map,保证模块只会被执行一次

也解释了 ES Modules 是先执行末端依赖的模块

所以这个阶段可以理解为 执行代码

另外还有个困惑很多人的问题:ES Modules 如何处理循环依赖?

其实在 ES Modules 下不需要关心是否存在循环依赖,在代码运行的时候,从内存空间中读取该导出值,因为 ES Modules 是采用动态绑定的方式

CommonJS 也有其解决方式

Node.js Doc 中关于 Cycle 有着一句解读

In order to prevent an infinite loop, an unfinished copy of the a.js exports object is returned to the b.js module.

若模块被循环依赖,将导出未完成的 Copy 值,也就是导出当前执行完成的导出值

Webpack Modules

你是否想过为什么在使用 Webpack 打包的前端工程里,并不用多去考虑各个模块加载方式的局限处,反而可以直接使用 ES Modules ,甚至混用 ES ModulesCommonJS,在浏览器中运行代码。

其实 Webpack 处理 ES Modules/CommonJS,是基于自己实现的 webpack_require,来抹平各个模块规范的差异。

详细的内容将在另一篇文章里详细阐述

Webpack 如何处理不同的模块加载方式

  • Webpack 打包模块
  • Webpack 异步加载模块

Reference