52. 精读《图解 ES 模块》

2022-03-14 16:12:45 浏览数 (1)

精读《图解 ES 模块》

ES 模块为 JavaScript 开发者带来了官方并且标准化的模块系统。模块标准化来之不易,用了近 10 年的时间。漫长的等待就要宣告结束了。随着五月份(2018)即将发布的 Firefox 60,几乎所有的主流浏览器都将支持 ES 模块,并且 Node 模块工作组也正尝试将 ES 模块支持到 Node 环境。本期精读文章和大家一起了解 ES 模块,讨论它能够解决的问题以及与其他模块系统间的差别。

1. 引言

精读文章主要讨论了下面几点:

  • 模块旨在解决那些问题;
  • 模块为开发者带来哪些;
  • ES 模块化的工作机制;
  • ES 模块化的现状;

2. 内容概要

模块旨在解决那些问题

JavaScript 开发可以简单地抽象成维护变量,赋值和计算操作。大量的代码在用于操作变量,开发者需要懂得如何去组织和维护这些变量。JavaScript 提供了一种方式,即函数作用域。在一个函数内只需要考虑这个函数的变量问题。不必去担心其他函数会操作这些变量。当然,随之带来的问题是,变量无法共享,无法在不同的函数之间相互共享变量。如果想要在作用域外共享变量,只能通过外层作用域,或者全局作用域。

jQuery 时代,只要 $ 变量在全局作用域下,就可以加载任何的插件,不过它本身存在问题的。

首先,要保障 script 标签的顺序。如果顺序错乱,应用将会抛错。比如函数用到了全局作用域的 $ 函数,但没有找到,就会抛错了。

这就使得维护代码变得很复杂。移除旧代码会像轮盘赌游戏一样,无法预料将会发生什么。不同部分代码之间存在隐形的依赖。所有函数都可以访问全局变量,根本无法知道哪个函数属于哪个脚本。

还有,存储在全局的变量可以被任何作用域中的代码修改。代码可能遭到恶意的修改。

模块为开发者带来哪些

模块提供了更好的方式来组织变量和函数,把相关的变量和函数组织到一起。具体就是将这些函数和变量放到一个模块作用域内,实现在模块间共享变量。

与函数作用域不同的是,模块内部的变量实现了在其他模块内共享。而且可以指定哪些变量、类或者函数可以共享。

在其他模块中共享,被称为 export。这就出现了模块间的依赖,是一种很明确的关系,当移除一个模块时可以准确的知道哪些模块会出错。

一旦有了模块间导出和引用变量的能力,我们就可以将代码打成小包。然后就可以像乐高玩具那样组合,再组合。使用小模块就可以创建出各类应用。

模块非常有用,这也就出现了很多种类的 JavaScript 模块。目前存在两种主流的模块系统。CJS 是 Nodejs 遗留下来的。ESM 是一个 JavaScript 的新规范。浏览器已经支持了 ESM,并且 Node 也在添加支持。

ES 模块化的工作机制

模块化开发会将依赖构建为树形结构。通过 import 语句通知浏览器或者 Node 去加载相关的代码。这些依赖树会有一个根节点作为入口文件,从入口可以找到依赖的其他代码。

在浏览器环境下这些文件需要被转化为一种叫做『模块记录』的数据结构。紧接着,模块记录需要被转化为模块实例。每个实例包含了两个东西:代码和状态。

代码就像是指令集。如果仅通过代码并不能做什么,还需要一些原始的材料来应用这些指令。状态就提供了原始的材料。状态其实就是这些变量的值。当然,这些变量仅仅是内存中存储值的别名。

模块将代码和状态结合到一起。

从入口文件到完整的模块树形实例,主要经过了下面三个步骤:

  1. 构建:查找,下载,然后将所有的文件转化为模块记录。
  2. 安装:将所有导出的变量放到内存中,此时的变量并没有被赋值。然后将导出和导入变量全部放到内存中。我们称之为链接。
  3. 赋值:执行代码,将变量值添加到内存中。

之所以说 ES 模块是异步的,正是因为 ES 模块将这三个步骤划分开。实际上在 CJS 中模块和相关的依赖都是一次完成加载,安装和赋值的。

ES 模块需要借助模块加载器来实现这三步。加载器在不同的平台下有不同的规范,浏览器端就是 HTML 规范。

1. 构建
确认从哪里加载文件所包含的模块,查找加载文件

加载器比较关心的是查找并且下载到文件。首先需要找到入口文件。在 HTML 中通过一个 script 标签。

但是接下来要如何找到模块直接依赖的文件树呢?

这就是 import 语句出场的时候了,它可以通知加载器去哪里找到其他的模块。

模块规范需要注意的一件事就是:它们有时候需要处理浏览器和 Node 两个不同的环境。每个宿主环境处理模块标识符的方式不同。为了能够实现这个,它使用了一个模块识别算法,用来区分不同的平台。目前,有些 Node 模块规范是无法在浏览器端工作的,不过也正在持续修复中。

在修复前,浏览器仅仅会接收 URL 模块标识符,通过 URL 来加载模块文件。不过,在转化之前你并不知道模块有哪些依赖项,并且你在加载文件前是没有办法转化文件的。

这就意味着我们必须一层一层的遍历文件树,转化文件并找出依赖,最后查找并且加载这些依赖。如果主线程正在等待去下载这些文件,那么很多的任务会堆积在队列中。这是因为浏览器环境下下载用了很长时间。

阻塞主线程会导致应用所需的模块变得很慢。将构建过程分片进行实现了在全部下载前进行获取和构建。这种查分构建的方式是 ES 模块和 CJS 模块最本质的不同。

CJS 的做法很不同,主要是由于相对于通过网络请求从文件系统加载文件耗时更少。这意味着 Node 可以在加载文件的时候阻塞主线程。文件加载完毕后,进行实例化和计算。这也就以为着在返回模块实例前完成遍历整个树,加载,实例化并且计算依赖。

在 Node 环境下,你可以在模块内部声明变量。在查找下一个模块前,都在执行这个模块里的代码。这意味着在执行模块前,变量会有一个值。但在 ES 模块中,需要事先构建整个模块树。

将文件转化为一个模块记录

在我们加载文件后,我们需要将它转化为一个模块记录。这会让浏览器理解模块的不同部分。一旦模块记录被创建,就会被放在一个模块映射中。这意味着当它被请求时,加载器可以从映射中拉出来。

在浏览器中你只要将 type="module" 放在 script 标签上。这会通知浏览器这个文件应该被转化为一个模块。同样,只有模块才能够被导入,浏览器也就知道了模块中有哪些引用。

不过在 Node 中,并没有 HTML 标签,所以也没有地方声明 type 属性。社区内的一种方式就是使用 .mjs 扩展。使用这个扩展告诉 Node这个文件是一个模块。

无论哪种方式,加载器将决定是否将文件转化为一个模块。如果是一个模块并且有导入的话,它就会开始处理直到所有的文件被获取和转化。

2. 安装

我之前提到了,实例由代码和状态结合而成的。状态在内存中,所以安装这一步基本是关于如何在写入到内存。

首先,JS 引擎创建一个模块环境记录。这会为模块记录维护变量。然后在内存中开辟空间,让这些变量可以被导出。模块环境记录会基础追踪内存中的值导出的每个变量。内存空间并不会获取到变量的值,而是计算后得到值。

为了实例化模块树,引擎将会完成一个叫做深度优先的后序遍历。这意味从树的底部开始,底部的依赖不会再依赖其他的东西,并且创建它们的导出。

引擎会绘制出一个模块下的所有导出。然后绘制这个模块的所有导入。注意,导出和导入在内存中指向同一个地址。这里和 CJS 模块有区别,在 CJS 中所有导出对象的值都是一个拷贝。与之相反,ES 模块使用了类似绑定的东西。模块会指向内存这种的同一个地址。这意味着当导出模块修改了一个值,这个修改会在不在导入模块时表现出来。

有导出值的模块会在任何时候修改这些值,不过导入模块不会改变他们导入的值。也就是说,如果一个模块引入了一个对象,它可以改变对象的属性值。

像这样动态绑定的原因就是可以在不执行代码的情况下连接所有的模块。

在这一步的最后,我们我们会将实例和内存地址连接起来。

3. 赋值

最后一步就是填充内存空间。JS 引擎通过执行顶层的代码来完成,也就是函数外的代码。如果遇到类似异步调用的情况,还可能会出现一些负面的影响。

由于这种负面影响,赋值得到的结果可能是不相同的。这也是模块映射机制出现的一个原因。模块映射会通过 URL 来缓存模块,所以每个模块仅会有一个模块记录。这会确保每个模块只执行一次。就像初始化一样,这也是一个深度优先的后序遍历。

再说一下循环依赖的情况,需要遍历树。通常是一个很长的循环。但是为了解释这个问题,我们做一个简短的例子。

我们先看一下 CJS 是如何工作的。首先,模块会执行 require 语句。然后加载 counter 模块。

ounter 模块接着会访问导出对象里的 message。但由于这个还没有在模块中计算,会返回 undefined。JS 引擎会为本地变量分配内存空间,并且将值赋为 undefined。

Evaluation continues down to the end of the counter module’s top level code. We want to see whether we’ll get the correct value for message eventually (after main.js is evaluated), so we set up a timeout. Then evaluation resumes on main.js.

继续向下计算会执行到 counter 模块的顶部代码。这里设置了一个延时看是否可以正确的获取到 message 的值。

message 变量会被初始化后添加到内存中。不过由于这两者间并没有关联,加载模块后还是 undefined。

如果导出时用了动态绑定处理的,counter 模块最终会拿到准确的值。在执行 setTimeout 后,main.js 会执行完成并且拿到值的。

3. 精读 & 总结

模块化提供了更好的方式来组织变量和函数,把相关的变量和函数组织到一起。具体就是将这些函数和变量放到一个模块作用域内,实现在模块间共享变量。与函数作用域不同的是,模块内部的变量实现了在其他模块内共享。而且可以指定哪些变量、类或者函数可以共享。

由于 Nodejs 的缘故,目前看来 CJS 模块系统是使用数量更大。目前的 CJS 还无法兼容新的 ESM,不过 Node 工作组也正在这方面努力尝试中。而这两个模块系统最大的区别就是运行时。CJS 是一个动态的模块系统,而 ESM 只是静态模块系统。动态模块的导出只有在执行后才能得到,并且可以添加和删除,而静态模块则不可以,导入和导出是不可变化的。

而目前我们大都是通过 webpack 的构建工具之上使用 ESM,它可以在一定程度上模拟环境。期待 Node 工作组实现对 ESM 的早日支持。

0 人点赞