模块系统
微信小程序代码使用标准 ESM:同步依赖写 import,按需加载写 import()。微信没有等价的原生 ESM 执行环境,因此 vpt 会在构建时改写模块格式,并在运行时补上模块连接与跨分包加载。该适配沿用微信原生加载和缓存机制,不重复加载或执行模块,因此不增加额外运行时开销。
如果只想使用自动分包,阅读“应用代码的规则”即可。后面的章节解释构建产物和运行时,供排查问题或维护 vpt 时参考。使用示例参见全自动分包。
ESM 源码 ↓Rolldown 最终代码块 │ ├─ 启动必须使用的代码 ──→ 主包 └─ 可以按需加载的代码 ──→ 自动生成的分包 ↓ vpt 模块运行时连接并执行本文使用以下几个词:
| 名称 | 含义 |
|---|---|
| 源码模块 | Vite 读取的一个源码文件 |
| 代码块(chunk) | Rolldown 最终输出的一个 JavaScript 文件;它可能包含多个源码模块 |
| 启动代码 | 从 App、Page 和 Component 原生入口沿静态导入可以到达的代码 |
| 模块 ID | 模块运行时使用的稳定名称 |
| 文件路径 | 微信实际传给 require() 或 require.async() 的输出路径 |
源码模块和最终代码块不是一一对应的。删除未使用代码、合并模块和代码切分都由 Rolldown 完成;vpt 只对最终代码块决定微信中的存放位置和加载方式。
应用代码的规则
Section titled “应用代码的规则”静态 import
Section titled “静态 import”import { initializeStore } from './store'
initializeStore()静态导入表示:执行当前模块之前,依赖必须已经连接并可用。
从 App、Page 或 Component 入口沿静态导入可达的代码必须支持微信同步启动,因此会留在主包。若静态导入出现在一个按需功能内部,它的两端不必位于同一个分包;运行时会先取得全部静态依赖,再执行引用方。
因此,按需功能中的代码可以静态引用:
- 主包中的共享代码;
- 同一分包中的代码;
- 其他分包中的代码。
源码不需要知道这三种情况的区别。
动态 import()
Section titled “动态 import()”async function openReport() { const { createReport } = await import('./features/report') return await createReport()}动态导入表示:调用方允许在这里暂停,并通过 Promise 等待目标模块。它创建按需加载边界,但不保证目标一定进入分包。
目标的最终位置取决于完整依赖图:
| 依赖情况 | 结果 |
|---|---|
| 目标也被启动代码静态导入 | 目标已经是启动代码,留在主包 |
目标只能通过 import() 到达 | 目标及其依赖参与分包规划 |
按需功能内部还有下一层 import() | 下一层形成独立的按需加载边界 |
import type | 编译时删除,不影响输出位置 |
如果启动代码本身过大,需要在合适的功能入口增加 import()。按源码目录移动文件,或手写微信分包配置,都不会把同步依赖变成按需依赖。
应用不应依赖物理分包
Section titled “应用不应依赖物理分包”自动分包生成的目录名和文件位置属于构建结果,业务代码不应:
- 根据源码目录推测分包;
- 直接调用
require.async()加载构建产物; - 在
appJson中维护代码分包; - 保存或拼接生成后的代码块路径。
vpt 会移除传入 appJson 的 subPackages 和 subpackages,再根据本次实际输出生成声明。
为什么微信需要另一套执行方式
Section titled “为什么微信需要另一套执行方式”微信构建需要同时满足三个约束:
app.js、页面入口和递归组件入口由微信直接执行,且必须同步调用App()、Page()或Component();- 主包文件可以用
require()同步取得,分包文件需要用require.async()异步取得; - 应用仍然需要 ESM 的共享模块、循环依赖、实时导出绑定、动态导入和顶层
await语义。
如果直接把物理路径写进模块关系,任何分包调整都会改变模块身份,也很难处理跨分包静态依赖。vpt 因此把微信运行时拆成三层:
| 层 | 职责 |
|---|---|
| 原生入口 | 使用微信要求的固定文件名,同步调用注册函数 |
| 模块运行时 | 按 ESM 规则连接、缓存和执行模块 |
| 文件加载表 | 把模块 ID 映射到实际文件路径,并选择 require() 或 require.async() |
模块运行时只认识模块 ID,只有文件加载表知道代码位于主包还是分包。分包规划因此可以改变文件位置,而不改变模块关系。
微信构建过程
Section titled “微信构建过程”1. 先生成最终代码块
Section titled “1. 先生成最终代码块”Rolldown 先完成源码转换、未使用代码删除、模块合并和代码切分。vpt 等最终代码块依赖图出现后再规划位置,而不是按源码文件或源码目录提前猜测。
这样做有两个直接结果:
- 规划使用的是最终会写入磁盘的 JavaScript 文件;
- 体积估算基于已经删除未使用代码的结果。
每次完整输出只规划一次。后续渲染和文件生成都读取同一份位置结果。
2. 确定主包代码
Section titled “2. 确定主包代码”所有显式构建入口及其递归静态依赖都进入主包。这些代码包括:
- 固定路径的 App、Page 和 Component 原生入口;
- 安装模块运行时的启动代码;
- 创建
App()、Page()和Component()参数对象的模块; - 上述模块在启动时需要的全部静态依赖。
这条规则来自“微信必须同步完成原生注册”的要求。主包代码不会为了凑体积被强行移入分包。
3. 把其余代码分组
Section titled “3. 把其余代码分组”分包规划有三个目标,按优先级排列:
- 每个最终代码块只保存一份;
- 单次动态导入尽量少触及分包;
- 每个生成分包保留足够的体积余量。
规划器按以下步骤工作:
- 对每条最终动态导入,收集目标代码块及其递归静态依赖,得到一次加载所需的代码块集合;
- 记录每个代码块被哪些加载集合使用;
- 优先处理被更多加载集合共享的代码块,其次处理体积较大的代码块;
- 在容量允许时,把代码块放入与它共享最多加载集合的现有分包;
- 若多个分包同样合适,选择放入后剩余空间最少的分包;没有可用分包时创建一个新分包。
嵌套 import() 会产生新的加载集合,不会并入外层。共享代码块综合所有使用方选择唯一位置,不会为不同功能复制多份。
每个分包的规划预算是 1,900,000 字节,为最终包装和微信原生文件预留低于 2M 限制的空间。估算包含:
- 删除未使用代码后的模块内容;
- 代码块包装与依赖引用开销;
- 由对应模块声明的微信原生组件文件。
单个代码块如果已经超过预算,会独占一个分包。规划器不会拆开 Rolldown 已经生成的代码块,也不会复制它。图片、字体、全局样式和其他普通构建资源目前不参与这套位置规划;完整范围参见全自动分包。
4. 生成微信可执行的文件
Section titled “4. 生成微信可执行的文件”不同文件使用不同输出形式:
| 文件 | 输出形式 | 作用 |
|---|---|---|
app.js、comp.js、pages/<route>.js | CommonJS | 由微信直接执行并同步调用原生注册函数 |
| 普通应用代码块 | 模块注册信息 | 交给模块运行时连接和执行 |
| 启动运行时和文件加载表 | CommonJS | 安装模块运行时并访问微信文件加载 API |
普通应用代码块不会在微信 require() 文件时立刻执行模块体。文件只导出类似下面的注册信息:
module.exports = [ ['dependency.js'], function declare(exportValue, context) { return { setters: [/* 接收依赖导出的函数 */], execute() { // 原代码块的执行体 } } }]依赖列表用于建立模块关系,setters 用于更新 ESM 导入绑定,execute 在依赖连接完成后才运行。这使文件下载顺序和模块执行顺序可以分开处理。
5. 写入目录并生成 app.json
Section titled “5. 写入目录并生成 app.json”应用代码和非框架依赖由 Vite/Rolldown 自动分块,不按源码目录创建自定义分组,也不假设源码目录名。App、Page 胶囊沿用配置的入口路径,放在原生壳旁边;自动生成的共享代码块放入 common/。
React/Taro 及其依赖闭包保留现有的 common/vendor.js 分组,其他第三方依赖不统一提取到 vendor。JavaScript 默认文件名不包含内容哈希,名称碰撞仍由 Rolldown 处理。导入资源沿用 Vite 的资源处理和构建命名规则,public/ 文件按原目录复制,不增加源码路径映射。
app.jsapp-capsule.jspages/home/index.jspages/home/index-capsule.jscommon/vendor.jscommon/bootstrap.jsassets/logo-<hash>.pngsub/p_abcd1234/common/report.js自动分包在 Rolldown 完成分块后进行,只增加生成的目录前缀,不改变代码块边界。
监听构建仅在启动时清理输出文件,保留所有目录;后续构建直接覆盖产物,不再逐轮清空。已不再使用的文件会保留到重启监听或执行干净构建。成功写入后才更新完成标记;失败不会更新标记,但写入失败可能留下部分新产物,不提供原子更新或回滚保证。
每个分包目录名由该分包内排序后的代码块名称计算得出,所以同一份构建图会得到稳定结果。每个代码块只更改最终文件名,不会被复制或重新发射。
只有最终仍包含代码的分包会写入 app.json:
{ "subPackages": [ { "name": "p_abcd1234", "root": "sub/p_abcd1234", "pages": [] } ]}这些分包只承载按需代码,不声明微信页面,所以 pages 为空。微信原生组件的配套文件会跟随声明它的代码块进入同一个包。
微信运行时如何加载模块
Section titled “微信运行时如何加载模块”模块 ID 与文件路径分离
Section titled “模块 ID 与文件路径分离”模块运行时使用不含生成分包目录前缀的包内路径作为 ID。例如:
模块 ID: common/report.js文件路径: sub/p_abcd1234/common/report.js代码块之间的静态导入和动态导入都引用左侧 ID。构建生成的文件加载表负责把它转换为右侧路径:
function loadModule(moduleId) { switch (moduleId) { case 'common/shared.js': return require('./shared.js') case 'common/report.js': return require.async('../sub/p_abcd1234/common/report.js') default: throw new Error(`Unknown module: ${moduleId}`) }}实际生成的函数名是 transport。它只接受本次构建已知的模块 ID,不允许运行时拼接任意文件路径。import.meta.url 同样使用当前模块的逻辑 ID,而不是物理分包路径。
每个原生入口执行相同的同步流程:
微信执行原生入口 ↓require() 启动运行时 ↓System.importSync() 取得对应的注册参数对象 ↓调用 App()、Page() 或 Component()System.importSync() 会同步取得目标模块及其递归静态依赖,建立导出绑定,按依赖优先顺序执行,并返回模块命名空间。构建阶段已经保证这条同步依赖图全部位于主包。
这个 API 只供 vpt 生成的原生入口使用,不是业务 API。启动依赖中如果出现顶层 await,模块图将无法同步完成,运行时会直接报错;需要异步工作的代码应放到启动后的动态导入边界。
应用的 import() 最终使用 System.import():
import(moduleId) ↓文件加载表取得目标注册信息 ↓递归取得目标的静态依赖 ↓建立整张依赖图的导出绑定 ↓按依赖顺序执行 ↓Promise 返回模块命名空间主包依赖由 require() 取得,分包依赖由 require.async() 取得。一个按需功能即使静态引用另一个分包,运行时也会先等待相关文件,再执行引用方。依赖中的顶层 await 也会在这里被等待。
所有模块共享一份注册表
Section titled “所有模块共享一份注册表”System.importSync() 和 System.import() 使用同一份模块注册表。同一个模块 ID:
- 只初始化和执行一次;
- 始终返回同一个模块命名空间;
- 在循环依赖中复用同一条模块记录;
- 通过订阅导出更新保留 ESM 实时绑定。
启动运行时本身既要被原生入口通过 CommonJS 加载,也可能出现在应用模块图中。vpt 会把 CommonJS 已缓存的导出发布到同一份模块注册表,而不是再次执行该文件。
VPT 最终仍通过微信的 require() 和 require.async() 取得物理文件。模块运行时只在这两个原生加载 API 之上补充
标准 ESM 所需的依赖连接、模块命名空间和实时绑定。
因此,性能分析的比较对象是微信原生模块加载:首次使用时加载并执行一次,之后从缓存复用。VPT 保持相同的工作 模型,不引入第二次文件加载或模块执行。
同步启动成本
Section titled “同步启动成本”原生入口使用 System.importSync(),内部仍通过微信 require() 取得主包文件。两种机制的工作对应如下:
| 阶段 | 微信原生模块 | VPT 模块运行时 |
|---|---|---|
| 取得文件 | 首次 require() | 首次通过加载表调用 require() |
| 记录缓存 | CommonJS 缓存 | 共享模块注册表 |
| 执行模块 | 首次加载执行一次 | 依赖连接后执行一次 |
| 再次引用 | 返回缓存导出 | 返回同一模块命名空间 |
设一次同步入口首次到达 V 个代码块、E 条静态依赖边和 S 个导入 setter。VPT 建立 ESM 关系的成本为
O(V + E + S),与微信加载依赖图的线性成本处于同一量级。每个新代码块只连接和执行一次。
同步启动不会创建 Promise 或等待微任务。循环依赖复用已经登记的模块记录;后续 Page 入口引用同一共享模块时, 也不会重新加载、连接或执行。
首次动态导入成本
Section titled “首次动态导入成本”动态 import() 通过同一份加载表调用微信 require() 或 require.async()。若这次导入新增 Vₙ 个代码块、
Eₙ 条静态依赖边和 Sₙ 个 setter,模块连接成本为 O(Vₙ + Eₙ + Sₙ)。
若新增依赖图包含 Q 个尚未加载的物理代码块,加载表最多为每个代码块调用一次微信加载 API。跨越的 K 个
分包是否需要下载、解压和初始化,仍由微信的原生分包机制决定。
微信加载所需文件+ VPT 连接新增依赖图+ 新模块体执行+ 顶层 await 等待VPT 不增加另一层网络请求,也不复制模块体。自动分包按动态加载集合的重叠度放置代码块,尽量减少一次
import() 触及的分包数量,同时保证每个代码块只有一份。
微信 CommonJS 和 VPT 模块运行时都会缓存已经加载的模块。VPT 缓存命中后:
- 不再调用加载表;
- 不再调用
require()或require.async(); - 不再连接依赖;
- 不再执行模块体;
- 返回同一个模块命名空间。
多个并发 import() 共享第一次加载的完成 Promise,不会并行启动重复加载。动态 import() 始终返回 Promise,
所以缓存命中仍有 Promise 解析,但没有文件 I/O、依赖连接或模块执行。
一个新入口只会处理尚未出现在注册表中的部分,已缓存的共享依赖不会增加第二次运行时成本。
静态 import 和动态 import() 主要改变成本发生的时间:
| 导入方式 | 下载 | 执行 |
|---|---|---|
静态 import | 代码位于主包,启动时可用 | 对应原生入口加载时执行一次 |
动态 import() | 需要时才取得所在分包 | 首次调用时执行一次 |
| 已缓存模块 | 不再加载 | 不再执行 |
尚未打开的 Page 的静态代码需要位于主包,但不会仅因为 App 启动就执行页面模块体。下载与执行仍然分离。
模块系统增加一份固定运行时,以及随代码块和依赖边线性增长的注册信息和加载表。每个最终代码块只写入一个 物理位置,自动分包不会复制代码。
微信 CommonJS 没有 ESM 实时绑定。VPT 只在导出值确实变化时通知订阅它的 setter;一次通知的成本与订阅者 数量线性相关,不使用轮询,也不重复执行模块。
普通生产模块通常只在初始化时发布导出。对象形式的多个导出会合并为一次订阅者遍历。
微信 CommonJS 缓存保存已加载文件的导出,VPT 注册表保存对应的模块命名空间、依赖、setter 和执行状态。
若累计加载 V 个代码块、E 条依赖边、S 个 setter 和 X 个导出,元数据占用为
O(V + E + S + X)。
模块记录在当前 App 运行期间保留,与微信模块缓存的生命周期一致。CommonJS 和 VPT 模块图都需要的启动代码 共享同一份执行结果,不会产生两份业务模块实例。
| 场景 | 微信原生模块 | VPT 模块运行时 |
|---|---|---|
| 首次同步加载 | 同步递归加载并执行一次 | 同步递归加载并执行一次 |
| 首次异步加载 | require.async() 后执行 | require.async()、连接后执行 |
| 再次导入 | 使用缓存 | 使用缓存 |
| 并发导入 | 由原生加载状态合并 | 共享同一完成 Promise |
| 模块副本 | 每个物理模块一份 | 每个最终代码块一份 |
VPT 的首次连接工作随新增依赖图线性增长,缓存命中后不再加载、连接或执行。因此从运行次数和复杂度看, VPT 模块运行时与微信原生模块加载处于同一量级。
VPT 只补充标准 ESM 语义,不重复执行应用模块,不让物理分包改变依赖模型,也不增加额外运行时开销。
微信开发模式的第一次完整构建使用与生产构建相同的分块策略、无内容哈希的默认 JavaScript 文件名、位置规划和运行时。增量 HMR 仍发布模块补丁,不重写整个代码块文件。
普通 JavaScript HMR 不重新生成完整代码块图。Rolldown 只生成源码模块补丁:devtools 模式改写 hmr/patches.js,interpreter 模式通过 Vite WebSocket 推送同一注册程序;两者都不会重新规划分包。rebuild 模式则在每次有效源码变化后生成完整代码块图并重新执行位置规划。完整流程参见热更新实现原理。
必须保持的边界
Section titled “必须保持的边界”- 源码导入关系不包含物理分包信息;
- 原生注册所需的完整静态依赖图始终位于主包;
- 每个最终代码块只属于一个物理包;
- 模块 ID 不随主包或分包位置改变;
- 只有文件加载表把模块 ID 转换成微信文件路径;
- 同步和异步导入共享模块注册表与命名空间。
源码中的内部名称
Section titled “源码中的内部名称”正文使用职责名称,源码中还会看到以下简写:
| 源码名称 | 本文中的名称 | 准确含义 |
|---|---|---|
shell | 原生入口 | 微信直接执行的固定路径 CommonJS 文件 |
capsule | 模块注册信息 | require() 后只返回依赖和执行函数、由模块运行时处理的文件;App/Page/Component capsule 专门创建注册参数对象 |
bootstrap | 启动运行时 | 安装 SystemJS 并接入文件加载表的主包代码 |
transport | 文件加载表 | 从模块 ID 选择物理路径和 require 方式的生成函数 |
amphibious | 双重入口运行时代码 | 同一份缓存导出需要同时提供给 CommonJS 和模块注册表的内部分类 |
placement | 位置规划 | 最终代码块到主包或某个分包的唯一映射 |
LTHP | 分包启发式 | 源码注释对“按动态加载集合重叠度装包”的简称,不是应用可见的模块概念 |
这一部分只影响初始构建和完整构建,不属于小程序运行时成本。设最终代码总量为 N、代码块数为 C、静态边
数为 E、动态加载集合数为 T、分包数为 B,所有加载集合的静态闭包总量为 M:
| 构建工作 | 时间复杂度 |
|---|---|
| 最终 ESM 代码转换 | O(N) |
| 主包静态闭包 | O(C + E) |
| 动态加载集合收集 | O(M) |
| 分包装箱 | 最坏 O(C × B × T) |
| 加载表稳定排序与生成 | O(C log C) |
| 最终文件落位 | O(C) |
规划使用 O(C + M + B) 空间。完成后,代码块位置和加载方式的单次查询为 O(1)。普通 JavaScript HMR
不重新生成完整代码块图,因此不会执行这些步骤。