[JS] ES Modules的运作原理

ESM 通过 import 语句引入其它依赖,通过 export 语句导出模块成员。

在浏览器环境中,<script> 可以通过声明 type="module" 将一个 JS 文件标记为模块,带有 type="module" 声明的<script> 类似于启用了 defer,脚本文件的下载不会阻塞HTML渲染,代码内容会被延后执行。

这篇文章仅讨论浏览器环境下的 ESM。

概括

ES模块的加载主要分为三个步骤:

  1. 构建 Construction
    • 找到入口文件;
    • 根据import语句递归构建依赖图;
    • 下载模块脚本文件,并文件转换为 Module Record。
  2. 实例化 Instantiation
    • 为模块导出的成员申请内存空间;
    • 建立importexport之间的链接;
  3. 求值 Evaluation
    • 运行模块代码;
    • 向内存中的成员填充实际的值。

模块加载过程

步骤1 构建

构建过程的作用在于:构建依赖图,以及了解各个模块之间import/export的成员(静态)。

路径解析与文件下载

在代码中我们使用的模块通常是相对路径,path resolver负责将相对路径转换为文件的绝对路径,从而可以让浏览器去下载模块文件。

转换为模块记录

当模块文件下载到浏览器本地之后,浏览器会对模块文件进行静态解析,从模块代码文件总结出一个模块记录(Module Record),可以理解为是模块的元数据。

一个模块记录大致包含了如下信息:

  • 模块文件的源代码,以及根据源代码构建的 AST;

  • 该模块依赖的其它模块;

  • 从其它模块分别导入了哪些成员。

缓存机制

在浏览器中,一个标签页会维护一个模块缓存映射表,它的 key 是模块解析后的实际路径,它的 value 是模块记录(Module Record)。

当模块文件的路径被解析完成之后,它就会被添加到缓存中,而在“完成路径解析”和“转换为模块记录”这段时间内,它的 value 会被标记为 fetching

递归

场景描述:

  1. 用户访问 https://www.example.com/index.html,返回的 HTML 文件包含模块入口脚本文件
<script src="main.js" type="module"/>
  1. 相对路径main.js被解析为绝对路径 https://www.example.com/main.js,然后浏览器开始下载文件(此时这个模块路径已经被记录到缓存了,标记为 fetching);
  2. 文件下载到浏览器本地之后,静态解析代码,捕获import语句(import语句会被默认提升到代码顶部),解析结果得到模块记录(Module Record),模块记录会被更新到缓存里;
  3. 模块记录包含依赖的其它模块,此时浏览器会递归地解析它们的路径,并下载它们的脚本文件(由上图红色箭头标明)。

在这个过程中,网络请求下载脚本文件占据了大部分的时间开销。

复杂的依赖关系可能导致初始化构建过程过久,影响首屏时间。

常用的优化手段是使用动态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.jsmodule.exports,只不过此时的module.exports还是空对象。

由于它此时还是空对象,因此解构赋值出来的messageundefined

我们期待等步骤③这些同步代码执行完成之后,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 中配置 typemodule

总结

ES Modules (ESM) 是一种现代模块化方案,具备以下特点和优势:

  • 模块化声明

    • 使用 importexport 语句实现模块的引入与导出。
    • 在浏览器中通过 <script type="module"> 标签加载,不阻塞 HTML 渲染。
  • 加载过程

    1. 构建:递归构建依赖图并下载模块。
    2. 实例化:为导出的成员分配内存空间,建立 importexport 的链接。
    3. 求值:运行模块代码,填充内存中的成员值。
  • 与 CommonJS 对比

    特性 ESM CommonJS
    加载方式 异步加载,不阻塞渲染 同步加载
    导入成员机制 共享同一内存空间,实时更新 拷贝机制,数据独立
    浏览器支持 原生支持 <script type="module"> 仅支持 Node.js 环境
  • 优势

    • 原生支持 动态加载
    • 解决 循环依赖 问题,确保模块成员实时更新。

引用

[1] ES modules: A cartoon deep-dive - Mozilla Hacks - the Web developer blog