跳转到内容

模块系统

微信小程序代码使用标准 ESM:同步依赖写 import,按需加载写 import()。微信没有等价的原生 ESM 执行环境,因此 vpt 会在构建时改写模块格式,并在运行时补上模块连接与跨分包加载。该适配沿用微信原生加载和缓存机制,不重复加载或执行模块,因此不增加额外运行时开销。

如果只想使用自动分包,阅读“应用代码的规则”即可。后面的章节解释构建产物和运行时,供排查问题或维护 vpt 时参考。使用示例参见全自动分包

ESM 源码
Rolldown 最终代码块
├─ 启动必须使用的代码 ──→ 主包
└─ 可以按需加载的代码 ──→ 自动生成的分包
vpt 模块运行时连接并执行

本文使用以下几个词:

名称含义
源码模块Vite 读取的一个源码文件
代码块(chunk)Rolldown 最终输出的一个 JavaScript 文件;它可能包含多个源码模块
启动代码从 App、Page 和 Component 原生入口沿静态导入可以到达的代码
模块 ID模块运行时使用的稳定名称
文件路径微信实际传给 require()require.async() 的输出路径

源码模块和最终代码块不是一一对应的。删除未使用代码、合并模块和代码切分都由 Rolldown 完成;vpt 只对最终代码块决定微信中的存放位置和加载方式。

import { initializeStore } from './store'
initializeStore()

静态导入表示:执行当前模块之前,依赖必须已经连接并可用。

从 App、Page 或 Component 入口沿静态导入可达的代码必须支持微信同步启动,因此会留在主包。若静态导入出现在一个按需功能内部,它的两端不必位于同一个分包;运行时会先取得全部静态依赖,再执行引用方。

因此,按需功能中的代码可以静态引用:

  • 主包中的共享代码;
  • 同一分包中的代码;
  • 其他分包中的代码。

源码不需要知道这三种情况的区别。

async function openReport() {
const { createReport } = await import('./features/report')
return await createReport()
}

动态导入表示:调用方允许在这里暂停,并通过 Promise 等待目标模块。它创建按需加载边界,但不保证目标一定进入分包。

目标的最终位置取决于完整依赖图:

依赖情况结果
目标也被启动代码静态导入目标已经是启动代码,留在主包
目标只能通过 import() 到达目标及其依赖参与分包规划
按需功能内部还有下一层 import()下一层形成独立的按需加载边界
import type编译时删除,不影响输出位置

如果启动代码本身过大,需要在合适的功能入口增加 import()。按源码目录移动文件,或手写微信分包配置,都不会把同步依赖变成按需依赖。

自动分包生成的目录名和文件位置属于构建结果,业务代码不应:

  • 根据源码目录推测分包;
  • 直接调用 require.async() 加载构建产物;
  • appJson 中维护代码分包;
  • 保存或拼接生成后的代码块路径。

vpt 会移除传入 appJsonsubPackagessubpackages,再根据本次实际输出生成声明。

为什么微信需要另一套执行方式

Section titled “为什么微信需要另一套执行方式”

微信构建需要同时满足三个约束:

  1. app.js、页面入口和递归组件入口由微信直接执行,且必须同步调用 App()Page()Component()
  2. 主包文件可以用 require() 同步取得,分包文件需要用 require.async() 异步取得;
  3. 应用仍然需要 ESM 的共享模块、循环依赖、实时导出绑定、动态导入和顶层 await 语义。

如果直接把物理路径写进模块关系,任何分包调整都会改变模块身份,也很难处理跨分包静态依赖。vpt 因此把微信运行时拆成三层:

职责
原生入口使用微信要求的固定文件名,同步调用注册函数
模块运行时按 ESM 规则连接、缓存和执行模块
文件加载表把模块 ID 映射到实际文件路径,并选择 require()require.async()

模块运行时只认识模块 ID,只有文件加载表知道代码位于主包还是分包。分包规划因此可以改变文件位置,而不改变模块关系。

Rolldown 先完成源码转换、未使用代码删除、模块合并和代码切分。vpt 等最终代码块依赖图出现后再规划位置,而不是按源码文件或源码目录提前猜测。

这样做有两个直接结果:

  • 规划使用的是最终会写入磁盘的 JavaScript 文件;
  • 体积估算基于已经删除未使用代码的结果。

每次完整输出只规划一次。后续渲染和文件生成都读取同一份位置结果。

所有显式构建入口及其递归静态依赖都进入主包。这些代码包括:

  • 固定路径的 App、Page 和 Component 原生入口;
  • 安装模块运行时的启动代码;
  • 创建 App()Page()Component() 参数对象的模块;
  • 上述模块在启动时需要的全部静态依赖。

这条规则来自“微信必须同步完成原生注册”的要求。主包代码不会为了凑体积被强行移入分包。

分包规划有三个目标,按优先级排列:

  1. 每个最终代码块只保存一份;
  2. 单次动态导入尽量少触及分包;
  3. 每个生成分包保留足够的体积余量。

规划器按以下步骤工作:

  1. 对每条最终动态导入,收集目标代码块及其递归静态依赖,得到一次加载所需的代码块集合;
  2. 记录每个代码块被哪些加载集合使用;
  3. 优先处理被更多加载集合共享的代码块,其次处理体积较大的代码块;
  4. 在容量允许时,把代码块放入与它共享最多加载集合的现有分包;
  5. 若多个分包同样合适,选择放入后剩余空间最少的分包;没有可用分包时创建一个新分包。

嵌套 import() 会产生新的加载集合,不会并入外层。共享代码块综合所有使用方选择唯一位置,不会为不同功能复制多份。

每个分包的规划预算是 1,900,000 字节,为最终包装和微信原生文件预留低于 2M 限制的空间。估算包含:

  • 删除未使用代码后的模块内容;
  • 代码块包装与依赖引用开销;
  • 由对应模块声明的微信原生组件文件。

单个代码块如果已经超过预算,会独占一个分包。规划器不会拆开 Rolldown 已经生成的代码块,也不会复制它。图片、字体、全局样式和其他普通构建资源目前不参与这套位置规划;完整范围参见全自动分包

不同文件使用不同输出形式:

文件输出形式作用
app.jscomp.jspages/<route>.jsCommonJS由微信直接执行并同步调用原生注册函数
普通应用代码块模块注册信息交给模块运行时连接和执行
启动运行时和文件加载表CommonJS安装模块运行时并访问微信文件加载 API

普通应用代码块不会在微信 require() 文件时立刻执行模块体。文件只导出类似下面的注册信息:

module.exports = [
['dependency.js'],
function declare(exportValue, context) {
return {
setters: [/* 接收依赖导出的函数 */],
execute() {
// 原代码块的执行体
}
}
}
]

依赖列表用于建立模块关系,setters 用于更新 ESM 导入绑定,execute 在依赖连接完成后才运行。这使文件下载顺序和模块执行顺序可以分开处理。

应用代码和非框架依赖由 Vite/Rolldown 自动分块,不按源码目录创建自定义分组,也不假设源码目录名。App、Page 胶囊沿用配置的入口路径,放在原生壳旁边;自动生成的共享代码块放入 common/

React/Taro 及其依赖闭包保留现有的 common/vendor.js 分组,其他第三方依赖不统一提取到 vendor。JavaScript 默认文件名不包含内容哈希,名称碰撞仍由 Rolldown 处理。导入资源沿用 Vite 的资源处理和构建命名规则,public/ 文件按原目录复制,不增加源码路径映射。

app.js
app-capsule.js
pages/home/index.js
pages/home/index-capsule.js
common/vendor.js
common/bootstrap.js
assets/logo-<hash>.png
sub/p_abcd1234/common/report.js

自动分包在 Rolldown 完成分块后进行,只增加生成的目录前缀,不改变代码块边界。

监听构建仅在启动时清理输出文件,保留所有目录;后续构建直接覆盖产物,不再逐轮清空。已不再使用的文件会保留到重启监听或执行干净构建。成功写入后才更新完成标记;失败不会更新标记,但写入失败可能留下部分新产物,不提供原子更新或回滚保证。

每个分包目录名由该分包内排序后的代码块名称计算得出,所以同一份构建图会得到稳定结果。每个代码块只更改最终文件名,不会被复制或重新发射。

只有最终仍包含代码的分包会写入 app.json

{
"subPackages": [
{
"name": "p_abcd1234",
"root": "sub/p_abcd1234",
"pages": []
}
]
}

这些分包只承载按需代码,不声明微信页面,所以 pages 为空。微信原生组件的配套文件会跟随声明它的代码块进入同一个包。

模块运行时使用不含生成分包目录前缀的包内路径作为 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 也会在这里被等待。

System.importSync()System.import() 使用同一份模块注册表。同一个模块 ID:

  • 只初始化和执行一次;
  • 始终返回同一个模块命名空间;
  • 在循环依赖中复用同一条模块记录;
  • 通过订阅导出更新保留 ESM 实时绑定。

启动运行时本身既要被原生入口通过 CommonJS 加载,也可能出现在应用模块图中。vpt 会把 CommonJS 已缓存的导出发布到同一份模块注册表,而不是再次执行该文件。

VPT 最终仍通过微信的 require()require.async() 取得物理文件。模块运行时只在这两个原生加载 API 之上补充 标准 ESM 所需的依赖连接、模块命名空间和实时绑定。

因此,性能分析的比较对象是微信原生模块加载:首次使用时加载并执行一次,之后从缓存复用。VPT 保持相同的工作 模型,不引入第二次文件加载或模块执行。

原生入口使用 System.importSync(),内部仍通过微信 require() 取得主包文件。两种机制的工作对应如下:

阶段微信原生模块VPT 模块运行时
取得文件首次 require()首次通过加载表调用 require()
记录缓存CommonJS 缓存共享模块注册表
执行模块首次加载执行一次依赖连接后执行一次
再次引用返回缓存导出返回同一模块命名空间

设一次同步入口首次到达 V 个代码块、E 条静态依赖边和 S 个导入 setter。VPT 建立 ESM 关系的成本为 O(V + E + S),与微信加载依赖图的线性成本处于同一量级。每个新代码块只连接和执行一次。

同步启动不会创建 Promise 或等待微任务。循环依赖复用已经登记的模块记录;后续 Page 入口引用同一共享模块时, 也不会重新加载、连接或执行。

动态 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.jsinterpreter 模式通过 Vite WebSocket 推送同一注册程序;两者都不会重新规划分包。rebuild 模式则在每次有效源码变化后生成完整代码块图并重新执行位置规划。完整流程参见热更新实现原理

  1. 源码导入关系不包含物理分包信息;
  2. 原生注册所需的完整静态依赖图始终位于主包;
  3. 每个最终代码块只属于一个物理包;
  4. 模块 ID 不随主包或分包位置改变;
  5. 只有文件加载表把模块 ID 转换成微信文件路径;
  6. 同步和异步导入共享模块注册表与命名空间。

正文使用职责名称,源码中还会看到以下简写:

源码名称本文中的名称准确含义
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 不重新生成完整代码块图,因此不会执行这些步骤。