ESM 通过 import
语句引入其它依赖,通过 export
语句导出模块成员。
在浏览器环境中,<script>
可以通过声明 type="module"
将一个 JS 文件标记为模块,带有 type="module"
声明的<script>
类似于启用了 defer
,脚本文件的下载不会阻塞HTML渲染,代码内容会被延后执行。
这篇文章仅讨论浏览器环境下的 ESM。
概括
ES模块的加载主要分为三个步骤:
- 构建 Construction:
- 找到入口文件;
- 根据
import
语句递归构建依赖图; - 下载模块脚本文件,并文件转换为 Module Record。
- 实例化 Instantiation:
- 为模块导出的成员申请内存空间;
- 建立
import
和export
之间的链接;
- 求值 Evaluation:
- 运行模块代码;
- 向内存中的成员填充实际的值。
模块加载过程
步骤1 构建
构建过程的作用在于:构建依赖图,以及了解各个模块之间import/export
的成员(静态)。
路径解析与文件下载
在代码中我们使用的模块通常是相对路径,path resolver
负责将相对路径转换为文件的绝对路径,从而可以让浏览器去下载模块文件。
转换为模块记录
当模块文件下载到浏览器本地之后,浏览器会对模块文件进行静态解析,从模块代码文件总结出一个模块记录(Module Record),可以理解为是模块的元数据。
一个模块记录大致包含了如下信息:
模块文件的源代码,以及根据源代码构建的 AST;
该模块依赖的其它模块;
从其它模块分别导入了哪些成员。
缓存机制
在浏览器中,一个标签页会维护一个模块缓存映射表,它的 key 是模块解析后的实际路径,它的 value 是模块记录(Module Record)。
当模块文件的路径被解析完成之后,它就会被添加到缓存中,而在“完成路径解析”和“转换为模块记录”这段时间内,它的 value 会被标记为 fetching。
递归
场景描述:
- 用户访问
https://www.example.com/index.html
,返回的 HTML 文件包含模块入口脚本文件
<script src="main.js" type="module"/>
- 相对路径
main.js
被解析为绝对路径https://www.example.com/main.js
,然后浏览器开始下载文件(此时这个模块路径已经被记录到缓存了,标记为 fetching); - 文件下载到浏览器本地之后,静态解析代码,捕获
import
语句(import
语句会被默认提升到代码顶部),解析结果得到模块记录(Module Record),模块记录会被更新到缓存里; - 模块记录包含依赖的其它模块,此时浏览器会递归地解析它们的路径,并下载它们的脚本文件(由上图红色箭头标明)。
在这个过程中,网络请求下载脚本文件占据了大部分的时间开销。
复杂的依赖关系可能导致初始化构建过程过久,影响首屏时间。
常用的优化手段是使用动态import,在运行时按需引入指定的模块。
动态加载
语法
import('./dynamic-module.js').then(module => {console.log(module.default);console.log(module.xxx);
});import(`./module-${moduleName}.js`).then(module => {// ...
});
import
函数的参数是模块的文件路径,返回一个 Promise
对象,通过 then
方法可以获取到模块对象。
模块对象包含模块导出的成员,默认导出使用default
属性获取。
应用场景:
- 模块懒加载,优化首屏时间;
- 根据不同逻辑加载不同的模块,所需的模块是在运行时才确定的。
步骤2 实例化
实例化的主要作用是为模块的state
分配内存空间,此时仅作内存的分配,state
的值在这一刻还不确定。
浏览器会以 深度优先,后序遍历 的方式遍历依赖图,为每一个模块 export 的成员分配内存空间。
当模块的所有 export 完成内存分配之后,会开始将 import 链接到相应的内存地址。
这意味着 export 导出的成员和 import 引入的成员指向同一处内存空间。基础数据类型也是如此。
特点:
- 模块内部更新
state
,外部的state
也随之变化(因为它们指向同一块内存); - 模块导出的
state
是只读的。
这种现象和 CommonJS 存在很大区别,CommonJs 在导入模块成员的时候,是对模块的导出进行了拷贝。
这意味着在使用模块导出的
state
时,要注意其数据是否是最新的,因为模块内部和外部的state
是相互独立的,内部更新state
并不会影响到外部的state
。不过这种情况一般比较少发生,我们很少直接导出一个基本数据类型,而是导出一个对象,对象内部再记录这些基本数据类型。由于导出的是对象,只要模块内部不要直接覆盖整个对象,而是对对象的属性进行更新,就不会有太大问题。
步骤3 求值
步骤1和2完成之后,模块的成员已经完成了内存的分配,以及 import/export 之间的链接。
最后需要完成的,就是运行模块代码,并将成员的值填入先前分配的内存中。
模块代码中可能存在一些带有副作用的代码,为了避免每一次执行都会导致模块的 exports 发生变化,模块代码只会被执行一次。
循环依赖
循环依赖是所有模块化方案都要讨论的问题。
案例
实际项目中,依赖图是很复杂的,导致循环依赖的环可能包含了许多模块。这里仅讨论最简单的情况,即两个模块相互依赖对方。
CommonJS
假设main.js
是入口文件。
main.js
const num = require('./a.js');
console.log(num);
exports.message = 'main';
a.js
const { message } = require('./main.js');
module.exports = 123;
setTimeout(()=>console.log(message), 0);
我们期待在main.js
中输出的num
为123,而在a.js
中输出的message
为 main;而实际运行结果是:
123
undefined
CommonJS 的 require 函数是同步地加载模块,并且一次性完成,不像ESM分为三个步骤。
如上图,当代码执行到 ① 时,执行require
函数,解析路径、记录到缓存中、读取模块文件、执行模块代码(步骤②)。
由于 CommonJS 的同步特性,它不能直接运行于浏览器环境,这里讨论的 Node.js 环境下的模块加载。
在执行步骤②的过程中,main.js
导出的成员还没有赋值,此时的module.exports
是一个空对象。
但是由于 CommonJS 是在模块的路径解析阶段就记录了缓存,因此步骤②的require
函数可以得到模块main.js
的module.exports
,只不过此时的module.exports
还是空对象。
由于它此时还是空对象,因此解构赋值出来的message
是undefined
。
我们期待等步骤③这些同步代码执行完成之后,message
应该就会更新为main
了,于是我们在a.js
中,使用setTimeout
来将任务推入宏任务队列中,延后执行。
但结果是,尽管main.js
中的message
被赋值了,a.js
中的message
也不会被更新。这是因为在导入的时候进行了拷贝,所以两个message
是相互独立的。
ESM
main.js
import num from './a.mjs';console.log(num);export const message = 'main';
a.js
import { message } from "./main.mjs";export default 123;setTimeout(()=>console.log(message), 0);
由于 ESM 的 import/export 是被链接到同一块内存区域的,因此当 main.js
赋值message
之后,a.js
中的message
也会更新为 main
。
输出结果:
123
main
在浏览器环境下,为了使用 ESM 语法,入口脚本文件需要标明
type="module"
。在 Node.js 环境下,为了表明文件是使用 ES 模块化语法,需要将文件后缀改为
.mjs
,或者在package.json
中配置type
为module
。
总结
ES Modules (ESM) 是一种现代模块化方案,具备以下特点和优势:
-
模块化声明:
- 使用
import
和export
语句实现模块的引入与导出。 - 在浏览器中通过
<script type="module">
标签加载,不阻塞 HTML 渲染。
- 使用
-
加载过程:
- 构建:递归构建依赖图并下载模块。
- 实例化:为导出的成员分配内存空间,建立
import
和export
的链接。 - 求值:运行模块代码,填充内存中的成员值。
-
与 CommonJS 对比:
特性 ESM CommonJS 加载方式 异步加载,不阻塞渲染 同步加载 导入成员机制 共享同一内存空间,实时更新 拷贝机制,数据独立 浏览器支持 原生支持 <script type="module">
仅支持 Node.js 环境 -
优势:
- 原生支持 动态加载。
- 解决 循环依赖 问题,确保模块成员实时更新。
引用
[1] ES modules: A cartoon deep-dive - Mozilla Hacks - the Web developer blog