[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负责将相对路径转换为文件的绝对路径,从而可以让浏览器去下载模块文件。

image-20240915173728406

转换为模块记录

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

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

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

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

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

缓存机制

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

image-20240915181338503

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

递归

image-20240915181952763

场景描述:

  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只读的

image-20240915225358286

这种现象和 CommonJS 存在很大区别,CommonJs 在导入模块成员的时候,是对模块的导出进行了拷贝

image-20240915225757132

这意味着在使用模块导出的 state 时,要注意其数据是否是最新的,因为模块内部和外部的 state 是相互独立的,内部更新 state 并不会影响到外部的 state

不过这种情况一般比较少发生,我们很少直接导出一个基本数据类型,而是导出一个对象,对象内部再记录这些基本数据类型。由于导出的是对象,只要模块内部不要直接覆盖整个对象,而是对对象的属性进行更新,就不会有太大问题。

步骤3 求值

步骤1和2完成之后,模块的成员已经完成了内存的分配,以及 import/export 之间的链接。

最后需要完成的,就是运行模块代码,并将成员的值填入先前分配的内存中。

模块代码中可能存在一些带有副作用的代码,为了避免每一次执行都会导致模块的 exports 发生变化,模块代码只会被执行一次

循环依赖

循环依赖是所有模块化方案都要讨论的问题。

案例

image-20240916001635606

实际项目中,依赖图是很复杂的,导致循环依赖的环可能包含了许多模块。这里仅讨论最简单的情况,即两个模块相互依赖对方。

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
image-20240916003859389

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是相互独立的。

image-20240916005943833

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/797900.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Go runtime 调度器精讲(八):sysmon 线程和 goroutine 运行时间过长的抢占

原创文章,欢迎转载,转载请注明出处,谢谢。0. 前言 在 Go runtime 调度器精讲(七):案例分析 一文我们介绍了一个抢占的案例。从案例分析抢占的实现,并未涉及到源码层面。本文将继续从源码入手,看 Go runtime 调度器是如何实现抢占逻辑的。 1. sysmon 线程 还记得 Go run…

Go runtime 调度器精讲(八):sysmon 线程和运行时间过长的抢占

原创文章,欢迎转载,转载请注明出处,谢谢。0. 前言 在 Go runtime 调度器精讲(七):案例分析 一文我们介绍了一个抢占的案例。从案例分析抢占的实现,并未涉及到源码层面。本文将继续从源码入手,看 Go runtime 调度器是如何实现抢占逻辑的。 1. sysmon 线程 还记得 Go run…

usb协议

1 USB 信号编码 USB 传输的编码就是 NRZI 格式,在 USB 中,电平翻转代表逻辑 0,电平不变代表逻辑1:翻转的信号本身可以作为一种通知机制,可以看到,即使把 NRZI 的波形完全翻转,所代表的数据序列还是一样的,对于像 USB 这种通过差分线来传输的信号方便。

引入语义标签过滤:利用标签相似度增强检索

引入语义标签过滤:利用标签相似度 增强检索 传统的标签搜索缺乏灵活性。如果我们要过滤恰好包含给定标签的样本,可能会出现这样的情况,特别是对于只包含几千个样本的数据库, 可能没有任何(或只有少数)与我们的查询匹配的样本。两种搜索的不同之处在于搜索结果的稀缺性 传统的…

娄涵格第一次作业

这个作业属于哪个课程 https://edu.cnblogs.com/campus/zjlg/rjjc这个作业的目标 介绍自己。自我评估,期待在课程收获什么,担当什么样的角色姓名-学号 娄涵格-2022329301112自我介绍 1、基本信息 大家好,我的名字是娄涵格,来自浙江台州,目前是浙江理工大学22电气工程及其自…

踩坑日志3:每一个epoch都会重新随机采样,固定batch容易使模型陷入局部解

前几天师弟在机器学习领域看到了一个对样本选择的方法,目的是从特征的角度均匀选择样本。如下图所示,首先初始化将样本的特征进行加和并归一化,迭代取出样本(取值最大的那个样本,再令样本的值乘以1-样本的值更新所有样本)。这般便可以从理论上均匀的取到不同分布的样本,…

动态规划——数学模型精解

动态规划是运筹学的一个分支,主要用于求解多阶段决策过程的优化问题。1950年代初,R.E. Bellman提出了最优性原理,将复杂的多阶段问题分解为一系列单阶段问题逐步求解,开创了动态规划这一方法。1957年,他出版了《Dynamic Programming》,成为该领域的经典著作。动态规划自问…

C#实现系统登录

1, 新建窗口frm_Loginusing System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Windows.Forms;namespace WindowsFormsA…

深度学习(FCN)

FCN是全卷积网络,用于做图像语义分割。通常将一般卷积网络最后的全连接层换成上采样或者反卷积网络,对图像的每个像素做分类,从而完成图像分割任务。 网络结构如下:这里并没有完全按照原始网络结构实现,而是尝试upsample和convTranspose2d结合的方式,看看有什么效果。 下…

多线程五-线程通信之wait与notify

wait与notify用于syncronized的线程间通信的一种,wait用来阻塞线程并释放锁,notify用来唤醒线程。他们与condition作用基本一致,但是由于syncronized为jdk实现,阅读源码有难度,所以通过了解其原理,用来帮助我们后续理解condition的源码。 可以通过下面一张图来理解:下面…

帝国cms忘记了后台密码怎么办

如果你忘记了帝国CMS(EmpireCMS)的后台管理员密码,可以通过以下步骤来重置密码: 方法 1: 通过数据库重置密码登录数据库:使用数据库管理工具(如phpMyAdmin)连接到你的数据库。 登录数据库管理界面。找到用户表:通常表名为 phome_enewsuser(具体表名可能有所不同)。 打…