ESM 加载机制
ESM 加载涉及哪些核心概念?
- 关系依赖图: 模块之间的依赖图;
- 模块记录: 把依赖文件解析成的数据结构, 其实例称为模块实例;
- 模块映射 (module map): 模块加载器的缓存, 以 URL 为键管理模块实例;
- 异步加载: 构建、实例化与求值三个阶段的文件获取都是异步的, 具体机制由各环境的加载器实现;
构建阶段做了什么?
- 任务: 获取并加载模块文件, 解析为模块记录;
- 顺序: 从入口文件开始, 按
import语句深度优先后序遍历所有依赖; - 登记: 每个模块记录生成后立即加入模块映射, 避免重复加载;
实例化阶段做了什么?
- 任务: 为变量与函数分配内存空间;
- 过程: 把模块记录中的变量绑定到对应内存地址, 只绑定地址而不初始化; 函数是例外, 会直接初始化;
- 顺序: 同样按深度优先后序处理模块记录;
求值阶段做了什么?
- 任务: 执行代码并完成赋值;
- 顺序: 深度优先后序;
- 一次性: 由于模块可能有副作用, 每个模块只运行一次;
- 时机: 整个模块求值完成后才更新模块映射中的缓存;
导出与导入变量为什么是同一个内存地址?
- 机制: 导入方拿到的是导出方变量的内存地址, 双方指向同一处;
- 结果: 导出方后续修改变量, 所有导入方都能读到最新值 (live binding);
- 对比: CommonJS 导入的是值拷贝, 见 CommonJS 与循环依赖;
循环依赖时输出顺序如何推演?
// index.js
import * as a from "./a.js";
console.log("入口模块引用 a 模块: ", a);
// a.js
import * as b from "./b.js";
let a = "原始值-a模块内变量";
console.log("a模块引用b模块: ", b);
a = "修改值-a模块内变量";
export { a };
// b.js
import * as a from "./a.js";
let b = "原始值-b模块内变量";
console.log("b模块引用a模块: ", a);
b = "修改值-b模块内变量";
export { b };
- 输出:
b模块引用a模块: [Module: null prototype] { a: <uninitialized> }
a模块引用b模块: [Module: null prototype] { b: '修改值-b模块内变量' }
入口模块引用a模块: [Module: null prototype] { a: '修改值-a模块内变量' }
-
推演:
- 求值从入口开始, 深度优先进入
a.js, 再进入b.js; b.js读取a.js的a时,a只完成绑定未初始化, 因此是<uninitialized>;b.js求值完成后回到a.js, 此时b已初始化并已被改写为修改值;- 最后回到入口,
a也已初始化为修改值;
- 求值从入口开始, 深度优先进入
-
顶层 await 会延迟模块自身的求值, 见 async 与 await;
动态 import 有什么不同?
- 语法: 在运行时调用
import(modulePath), 返回 Promise 并支持await; - 行为: 作为独立入口处理, 可以嵌套动态导入, 实现按需加载与代码分割;
- 实现: 浏览器中通过额外的 HTTP 请求获取模块;
const module = await import(modulePath);
module.fn();
静态 import 为什么不能运行时决定?
- 路径限制: 模块路径必须是原始字符串, 不能是函数调用;
- 位置限制: 不能写在
if或任意代码块内, 无法按条件导入; - 原因:
import/export要充当代码结构的骨架; 只有结构简单固定, 工具才能分析依赖、打包、摇树 (tree-shaking);
import ... from getModuleName(); // SyntaxError, 只接受字符串
if (need) {
import ...; // SyntaxError, 不允许
}
import() 表达式返回什么?
- 返回值: 加载模块并返回 Promise, 解决值是包含该模块全部导出的模块命名空间对象;
- 位置: 可出现在代码任意位置, 因此路径与是否加载都能在运行时决定;
const modulePath = prompt("Which module to load?");
import(modulePath).then((obj) => obj.hi()).catch((err) => console.error("加载失败", err));
- 取命名导出: 可直接解构模块对象;
- 取默认导出: 默认导出挂在模块对象的
default属性上;
const { hi, bye } = await import("./say.js"); // 命名导出
const obj = await import("./say.js");
const say = obj.default; // 或写作 { default: say }
import() 是函数吗?
- 结论: 不是; 它只是恰好用小括号的特殊语法, 类似
super(); - 后果: 不能赋值给变量, 也不能用
call/apply调用; - 环境: 普通脚本里也能用, 不要求
script type="module";
什么时候该用动态导入?
- 按需加载: 只有走到某个分支或用户触发时才需要该模块;
- 代码分割: 把大模块拆成独立请求, 缩短首屏时间;
- 对比: 静态
import声明在解析阶段就确定关系, 见 ESM 语法; 动态导入加载来的模块仍按上面的构建/实例化/求值流程处理;