跳到主要内容

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模块内变量' }
  • 推演:

    1. 求值从入口开始, 深度优先进入 a.js, 再进入 b.js;
    2. b.js 读取 a.js 的 a 时, a 只完成绑定未初始化, 因此是 <uninitialized>;
    3. b.js 求值完成后回到 a.js, 此时 b 已初始化并已被改写为修改值;
    4. 最后回到入口, 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 语法; 动态导入加载来的模块仍按上面的构建/实例化/求值流程处理;