热更新实现原理
vpt 的微信热更新不是浏览器 HMR 的直接移植。它提供三种开发更新模式:
devtools把原生 JavaScript 补丁写入dist/wx,借助开发者工具的 Page 热重载执行,再保护原 Taro/React 页面连接;interpreter通过 Vite 已有 WebSocket 推送源码,由 App 中的 Sval 安装模块实现,不重新注册 Page;rebuild不交付增量补丁,每次有效源码变化都让 Rolldown 写出完整原生项目并重启 App。
devtools 与 interpreter 共享补丁序号、模块图更新、React Refresh、样式事务、确认和完整构建恢复,只改变补丁如何到达并安装到存活的 App 运行时。一次开发服务器只选择一种模式,更新路径中没有逐补丁模式判断。
使用方法和常见问题参见开发热更新。本页解释内部设计。
先区分三种变化
Section titled “先区分三种变化”微信开发期间会发生三种容易混淆的事情:
| 变化 | 被替换的内容 | 状态结果 |
|---|---|---|
| 模块热更新 | App 内存中的部分 JavaScript 模块 | App 和兼容的 React 组件状态保留 |
开发者工具替换 Page(仅 devtools) | 当前微信 Page 对象 | vpt 保留原 React/Taro 页面连接并抑制替换生命周期 |
| 完整构建 | 整个 dist/wx 代码基线和 App 运行环境 | 所有运行时状态重置 |
devtools 的成功更新通常同时包含前两项,页面交接使第二项看起来像没有发生。interpreter 只执行第一项。rebuild 对每次有效变化都直接执行第三项。
在补丁模式中,完整构建是恢复手段。只要局部替换无法证明安全,vpt 就生成新基线并允许开发者工具重启 App。
微信环境决定了什么
Section titled “微信环境决定了什么”新代码需要环境允许的执行机制
Section titled “新代码需要环境允许的执行机制”小程序不能把网络收到的源码交给 eval() 或 Function()。devtools 因此先把补丁写入 dist/wx,由微信开发者工具编译为原生 JavaScript。interpreter 则把源码当作数据交给 Sval 的解析器和解释执行器,不调用动态代码生成 API。
解释器只存在于开发运行时。生产构建仍然全部是开发者工具可编译的静态项目文件。
devtools:改了哪个文件,决定重载多大范围
Section titled “devtools:改了哪个文件,决定重载多大范围”开发者工具根据发生变化的文件及其已有依赖关系决定如何重载:
- 根入口
app.js或app.wxss变化,会重建整个 App 运行环境; - Page 已经直接依赖的 JavaScript 文件变化,可以只重新执行 Page;
- 页面开始重载后才动态发现的新依赖,不能反过来改变这次重载范围。
所以,每个 Page 入口必须从第一次构建起就包含一个字面量依赖:
require('../../hmr/patches.js')路径按页面位置生成,但所有页面最终指向同一个 hmr/patches.js。
React 状态只能存在于没有被销毁的树中
Section titled “React 状态只能存在于没有被销毁的树中”React Refresh 不会把状态序列化后重建。它只能更新仍然存活的 React 树。vpt 必须避免普通更新重启 App,并把模块运行时放在 App 共享的微信全局环境中,而不是放在会被重新执行的 Page 中。
参与更新的部分
Section titled “参与更新的部分”| 部分 | 职责 |
|---|---|
| Rolldown 开发引擎 | 监视源码,按所选模式生成模块补丁或完整输出 |
| vpt 开发主机 | 串行处理构建结果、样式、累计补丁、交付和运行时报告 |
| DevTools 交付 | 把累计补丁渲染为 hmr/patches.js |
| Interpreter 交付 | 通过 Vite WebSocket 推送新发布的累计日志 |
| App 模块运行时 | 保存模块图、缓存、热更新边界和已应用序号 |
Sval(仅 interpreter) | 解析并解释 Rolldown 模块注册程序与更新后的模块实现 |
| React Refresh | 判断组件边界是否兼容,并更新现有 React 树 |
Taro 页面交接(仅 devtools) | 在开发者工具重新注册 Page 时保留原页面连接 |
初始构建建立的基础
Section titled “初始构建建立的基础”微信开发模式复用 Vite 已经解析好的打包开发配置,并创建一个直接写入 dist/wx 的 Rolldown 开发引擎。不会再创建第二个文件监听器或第二张模块图。
第一次完整构建会建立以下约定。
稳定的文件路径
Section titled “稳定的文件路径”开发文件名不包含内容哈希。App、Page、普通代码块和补丁模式的 HMR 文件都能在后续完整构建中覆盖同一路径,开发者工具也可以持续观察这些文件。
开发模式不会清空并重建整个输出目录。已有路径会保留,再由新构建覆盖需要更新的文件,避免破坏开发者工具对项目目录的监听。
补丁模式的构建身份
Section titled “补丁模式的构建身份”补丁模式中,每次成功并交付给开发者工具的完整构建都有一个新的 buildId。主机把它和已认证的 Vite WebSocket 地址写入:
module.exports = { buildId: '本次完整构建的唯一标识', endpoint: 'ws://127.0.0.1:<port>/__vpt_hmr__?token=<vite-token>'}App 入口在业务模块执行前读取该文件并初始化共享模块运行时。旧构建延迟到达的补丁或报告会因为 buildId 不匹配而被忽略。
devtools 的固定 Page 补丁依赖
Section titled “devtools 的固定 Page 补丁依赖”初始 hmr/patches.js 不包含更新:
module.exports = undefined每个 Page 入口仍然会同步读取它:
__rolldown_runtime__.applyPatches( require('../../hmr/patches.js'), 'pages/example/index')第一次执行什么也不会发生,但开发者工具已经记录了 Page 到补丁文件的直接依赖。以后只需替换这个文件,就能触发所需的 Page 重载。
interpreter 的 App 源码通道
Section titled “interpreter 的 App 源码通道”解释器模式不给 Page 添加补丁依赖,也不生成 hmr/patches.js。App 连接 hmr/info.js 中的 WebSocket,源码发布时主机直接广播累计补丁。
每个 App 运行环境只创建并保留一个原生 SocketTask,不会重建连接,也没有轮询、心跳或插件定时器。构建身份切换和主机关闭通过同一通道通知旧 App 关闭该连接。
App 重启后的启动同步
Section titled “App 重启后的启动同步”点击开发者工具的「编译」会创建新的 App 运行环境,但不会自动重启 Vite 或更新磁盘上的完整代码基线。此时 hmr/patches.js 可能只剩旧 App 尚未确认的后缀,新 App 不能跳过缺失的前缀直接执行它。
Socket 连接建立后,小程序发送 { kind: 'startup', buildId },通知开发服务自己刚刚启动。如果当前构建还没有产生过热更新补丁,就直接继续运行;如果已经发布过补丁,开发服务就根据最新源码重新生成完整代码,并让小程序重新加载。即使旧补丁仍然齐全,也采用完整重建,而不是在启动时逐个重放。遇到不完整的旧补丁时,小程序会跳过它们,等待完整代码生成,不会强行执行。
Socket 打开前跳过 Page 补丁执行,不暂存补丁或 ACK;由启动报告让主机决定是否需要完整构建。启动报告不是应用确认,不会裁剪历史。正常更新若应用失败,共享运行时先发送完整构建请求,再关闭 Socket;后续补丁因没有可用连接而跳过,不保存失败原因或额外的失败标记。正常 HMR 仍在成功应用后报告真实序号。Socket 引用只在 onOpen 后可用于发送,关闭或报错时清除,不维护额外的连接状态或事件队列。
rebuild 的完整输出边界
Section titled “rebuild 的完整输出边界”rebuild 不生成 hmr/info.js 或 hmr/patches.js,也不注册 Rolldown 补丁客户端。开发引擎使用 rebuildStrategy: 'always',有效源码变化完成增量分析后会直接产生一次完整输出。主机在完整代码和样式落盘后最后更新带唯一标记的 App 样式入口,保证开发者工具把这一代文件作为 App 级刷新处理。
该模式复用原生模块运行时来执行开发代码块并满足 Vite 生成代码中的 import.meta.hot 接口,但不初始化补丁 Socket、入口依赖或 Page 交接。
补丁模式的 App 级运行时
Section titled “补丁模式的 App 级运行时”模块缓存、模块引用关系、热更新边界和补丁序号都保存在 App 共享的运行时中。devtools 的 Page 重新执行时取得同一个实例;interpreter 的 WebSocket 也由该实例持有。两种补丁模式都把新实现应用到现有模块图,而不是从空状态开始。
开发构建还会使用应用实际的 Taro 模块实例,并在 React 渲染器运行前安装 React Refresh 所需的全局 Hook。这里不能另行导入一份 Taro,否则 DevTools 页面交接和解释器更新都会面对与应用不同的模块实例。
一次 JavaScript 更新
Section titled “一次 JavaScript 更新”保存源码 ─┬─ rebuild: Rolldown 完整输出 → App 样式构建标记 → App 重启 └─ 补丁 → global.wxss → 累计补丁日志 ├─ devtools: patches.js → Page 重载 → 原生工厂 └─ interpreter: Vite WebSocket → Sval 安装源码 ↓ 共享运行时验证并一次应用整个批次 ↓ React Refresh → WebSocket 报告已应用序号rebuild 在完整输出后结束本次更新。devtools 随后还会完成 Page 生命周期交接;interpreter 的 Page 从未被重新注册。下面主要按两种补丁模式的共享顺序和差异展开。
1. Rolldown 判断更新类型
Section titled “1. Rolldown 判断更新类型”每次源码变化会得到以下结果之一:
| 结果 | 含义 |
|---|---|
| 无变化 | 没有需要发布的运行时更新 |
| 模块补丁 | 包含变化模块的新注册程序,可以尝试局部替换 |
| 完整构建 | 变化不能表示为安全的局部更新 |
语法错误等中间状态不会立刻触发完整构建。vpt 记录错误并继续运行上一份有效代码;下一次保存恢复有效语法后,开发引擎可以继续产生正常补丁。
连续保存时,vpt 会短暂等待这一轮编译结果结束,再合并成一次磁盘通知。这段等待只减少文件写入次数,不会丢弃中间补丁:后一个补丁是基于前一个补丁产生的增量,必须按原顺序全部保留。
2. 主机按顺序发布样式和补丁
Section titled “2. 主机按顺序发布样式和补丁”一次补丁发布遵守固定顺序:
- 根据最新模块图重新生成需要变化的全局 WXSS;
- 让所选模式发布累计补丁:
devtools原子替换文件,interpreter通过 Vite WebSocket 广播源码; - 按补丁顺序通知 Rolldown,这些补丁已经可交付。
所有构建结果、样式、补丁、运行时报告和完整构建切换都经过同一条串行队列。交付成功只推进 Rolldown 的“已发布”前沿;App 通过 WebSocket 成功应用序号后,日志才推进“已应用”前沿。
devtools 文件使用“同目录临时 .txt 文件 + rename”替换。开发者工具忽略临时文件,只会看到完整的最终内容。interpreter 不写 JavaScript 文件,也不复制第二份发布状态;每次广播直接序列化本次累计补丁发布。
3. devtools 补丁文件保留尚未确认的完整序列
Section titled “3. devtools 补丁文件保留尚未确认的完整序列”hmr/patches.js 不是“最新补丁”,而是“当前 App 尚未确认应用的全部补丁”。例如,App 已应用到序号 3:
主机产生 4 → 文件包含 [4]Page 尚未读取主机又产生 5 → 文件包含 [4, 5]Page 这时读取 → 一次得到 [4, 5]App 报告已应用 5 → 主机删除不大于 5 的历史这解决了开发者工具合并或错过中间文件事件的问题。即使 Page 没有观察到只含 [4] 的版本,下一版仍携带从它当前状态到最新状态所需的所有注册程序。
补丁文件大致如下:
module.exports = { buildId: 'current-build', patches: [ { seq: 4, changedIds: ['src/report.tsx'], factory: () => { // Rolldown 生成的新模块图和模块实现 } }, { seq: 5, changedIds: ['src/chart.tsx'], factory: () => { // 下一次增量 } } ]}补丁文件本身只导出数据,不会擅自修改运行时。Page 入口明确把它交给 App 模块运行时。
4. devtools Page 在业务代码之前应用补丁
Section titled “4. devtools Page 在业务代码之前应用补丁”开发者工具发现 hmr/patches.js 变化后,会重新执行依赖它的存活 Page。Page 入口先调用 applyPatches(),之后才继续加载页面业务模块。因此本次重新执行看到的是新模块状态。
多个 Page 可能依次读取同一份补丁。App 运行时使用序号跳过已经应用的部分,但仍会为每个被开发者工具替换的路由建立独立的页面交接。
4b. interpreter 接收 WebSocket 源码并安装
Section titled “4b. interpreter 接收 WebSocket 源码并安装”主机把当前尚未确认的补丁 { buildId, patches: [{ seq, changedIds, code, ... }] } 作为 Vite 自定义事件发送。Sval 在一个 App 级沙箱作用域中解释每个 code;代码只登记模块图和模块工厂,应用模块仍由共享运行时稍后统一执行。
成功应用后,App 通过同一个 WebSocket 报告序号。解释器源码只在新发布时发送。每份源码携带构建身份;构建替换和主机关闭都会向旧 App 发送终止消息。
5. 运行时验证补丁
Section titled “5. 运行时验证补丁”运行时先确认 buildId 属于当前 App,再按顺序读取补丁。对每一项:
- 已应用过的序号直接跳过;
- 新序号必须等于当前期待的下一个序号;
- 通过检查后,才执行这一项的模块注册程序并继续检查下一项。
补丁是增量,序号不能缺失或重复。若批次中途发现错误,前面连续部分的注册程序可能已经登记了新实现,但应用模块尚未重新执行,序号也不会被确认;运行时会立即请求完整构建,而不是继续使用这份不完整状态。
6. 一次切换到批次中的最新实现
Section titled “6. 一次切换到批次中的最新实现”通过序号检查后,运行时先执行批次中所有补丁的注册程序。这些程序只登记新模块图和新模块实现,不立即运行应用模块。
随后运行时合并整个批次的变化模块,只做一次模块替换。连续保存 [4, 5, 6] 不会让 React 依次渲染三个中间版本;所有实现登记完成后,页面直接从当前版本切到序号 6 的版本。
7. 查找可以接受更新的位置
Section titled “7. 查找可以接受更新的位置”对于每个已经执行过的变化模块,运行时沿“谁导入了它”向上查找:
- 遇到调用过
import.meta.hot.accept()的模块,就把它作为更新边界; - 尚未执行过的变化模块无需立即处理,新实现会在第一次导入时生效;
- 如果走到顶层仍找不到更新边界,局部替换不安全;
- 如果传播路径形成循环,运行时也放弃局部替换。
React 项目的更新边界通常由 @vitejs/plugin-react 生成,vpt 不另造组件边界。
找到边界后,运行时:
- 确认每个受影响模块都有可执行的新实现;
- 保存旧边界注册的接受回调;
- 在执行任何新模块之前,一次性清除整个受影响集合的旧缓存;
- 用最新实现重新执行边界;
- 把最新导出交给旧边界的接受回调。
统一清除缓存可以防止一个新边界读到另一个受影响模块的旧导出。遍历成本与本次实际触及的模块和引用关系成正比,即 O(V + E)。
8. 成功后才确认序号
Section titled “8. 成功后才确认序号”只有模块传播、模块执行和全部接受回调都成功后,运行时才更新“已应用序号”,并发送:
{ kind: 'applied', buildId: string, seq: number}主机收到后只删除该序号覆盖的补丁前缀。把“模式已经发布”和“App 已经应用”分开,才能在 Page 尚未处理文件事件或解释器尚未收到 WebSocket 消息时继续安全发布后续补丁。
React Refresh 如何保留组件状态
Section titled “React Refresh 如何保留组件状态”组件签名、组件家族和边界兼容性仍由 @vitejs/plugin-react 与 React Refresh 判断。vpt 只适配它们对浏览器环境的假设:
- 为 React Reconciler 添加 Refresh 运行时的静态导入,在渲染器注册前完成初始化;
- 在 Refresh 运行时模块末尾调用
injectIntoGlobalHook(globalThis),把 React DevTools Hook 安装到 JavaScript 全局对象; - 通过仅在小程序开发模式启用的
define,把 Refresh 的四个window.*协议属性映射到globalThis.*。运行时与生成的组件边界共享同一个全局对象,不移除边界的前置检查。
前置初始化编译后为:
injectIntoGlobalHook(globalThis);globalThis.$RefreshReg$ = () => {};globalThis.$RefreshSig$ = () => (type) => type;映射仅覆盖 $RefreshReg$、$RefreshSig$、__registerBeforePerformReactRefresh 和 __getReactRefreshIgnoredExports。VPT 不会创建或整体替换 window;普通 window、typeof window、window.document 以及局部声明的 window 保持原有语义。生产构建和 H5 不启用这些映射。
边界兼容时,React Refresh 在原有 React 树上更新组件,所以 Hook 状态得以保留。组件类型、Hook 顺序或导出形状不兼容时,Refresh 可以重新挂载局部组件;如果边界主动使本次模块更新失效,运行时会请求完整构建。
vpt 不读取或序列化 React 内部的渲染树,不复制 Hook 状态,也不创建第二棵 React 树。
devtools 如何在 Page 原生重新注册时保留页面
Section titled “devtools 如何在 Page 原生重新注册时保留页面”模块补丁应用后,开发者工具会重新执行原生 Page 注册。这个动作不是普通页面导航,却会额外触发一组卸载、加载和显示生命周期。如果这些回调直接进入 Taro,仍然有效的 React 页面会被卸载,随后又以新的页面身份挂载,组件状态和业务上下文都会丢失。
需要同时保留的四层状态
Section titled “需要同时保留的四层状态”Page 热更新涉及四层不同的状态,不能混为一个快照:
- Taro Page 配置:
createPageConfig()创建的静态配置及其生命周期闭包,连接 Taro 页面身份和已挂载页面根; - React 页面树:组件实例、Hook 状态和上下文,由 App 中存活的 React 根持有;
- Page 视图数据:原生 Page 的
data,保存 App 投影、普通 Page 节点和每个CustomWrapper的初始占位记录; - CustomWrapper 视图数据:每个已挂载原生包装组件自己的
data.i,保存该边界下面的当前渲染快照。
Taro 会把 CustomWrapper 后代的后续更新直接发送给对应包装组件,因此第三层中的嵌套记录不会同步变成第四层的当前值。已经加载完成的懒组件尤其容易暴露这个差异:Page 初始数据仍可能是 Suspense 的 Loading…,而屏幕和 React 树早已显示真实组件。开发构建把应用图中的真实包装缓存发布到 globalThis 的 Symbol 属性,避免 HMR 启动块导入出第二个空缓存。
React Refresh 负责更新第二层。Page 重新注册既要保护第一层不被卸载,也必须在微信读取初始数据前把第三、四层拼成一个一致的原生快照。微信视图数据不包含 React Hook 状态,也不能用于重建 React 树。
重新注册的生命周期
Section titled “重新注册的生命周期”vpt 在原生 Page 注册边界协调一次短暂过程:
- 保留原来的 Taro Page 配置和已挂载 React 页面;
- 把当前微信视图数据作为本次原生注册的初始数据;
- 跳过重新注册触发的卸载,避免 Taro 销毁页面根;
- 跳过重新注册触发的加载,避免 Taro 创建第二个页面身份;
- 跳过这一次显示回调,避免重复请求、埋点或业务状态初始化;
- 随后恢复普通生命周期转发。
初始数据通过注册配置直接提供。vpt 不深拷贝递归数据树,也不调用 setData() 额外发布整页差异,因此这一步不会替代或干扰后续 React Refresh 产生的真实更新。
为什么不绑定重新注册回调中的 Page
Section titled “为什么不绑定重新注册回调中的 Page”微信调用重新注册生命周期时,会把一个临时 Page 对象绑定为回调中的 this。真实开发者工具行为表明,页面栈中原来挂载的 Page 才会继续显示并接收 React/Taro 更新。把 Taro 当前页面或页面根改绑到这个临时 Page,反而会让之后的更新脱离页面栈中的已挂载 Page。
因此 vpt 不修改 Taro 当前页面,不查找或重绑页面根,也不维护“旧 Page 到新 Page”的接管关系。原有 Taro/React 连接保持不动,React Refresh 继续在同一棵树上工作。
普通页面生命周期不受影响
Section titled “普通页面生命周期不受影响”首次加载、正常跳转、返回、隐藏和真实卸载仍使用微信传入的原始 this Page 和参数进入 Taro。真实卸载会结束该 Page 的保留状态;以后再次进入时仍执行完整挂载流程。
每个静态 Page 配置独立管理自己的重新注册过程。当前页和页面栈中的隐藏页可以分别保留,无需路由映射、页面栈扫描或全局交接阶段。
样式如何更新
Section titled “样式如何更新”WXSS 内容不通过 JavaScript 模块的更新边界交付。完整构建和增量补丁共用一个 WX 样式插件和同一种样式事务:
- Tailwind 扫描器把已有候选文件和编译依赖登记到 Rolldown;相关文件变化时,Rolldown 重新转换对应 Tailwind 入口;
- 入口复用自己的增量生成器,得到新的浏览器 CSS 和同一代原始类名集合;编译依赖变化时才重建生成器;
- Vite 继续执行 PostCSS、CSS Modules 和预处理器转换,vpt 在内置
vite:css-post序列化浏览器 HMR 模块前记录最终模块 CSS; - 使用 Rolldown 当前模块图,按 App 和配置页面的顺序选择仍然可达的最终 CSS;
- 合并为一份确定的全局级联,再对完整内容执行一次微信 WXSS 转换;
- 用步骤 2 的类名集合转换同一事务中的最终 JavaScript;
- 内容确实变化时才替换
assets/global.wxss。
Tailwind 生成器只负责编译入口,不拥有补丁发布。它在入口存活期间保留增量扫描缓存;候选文件变化由 Rolldown 触发入口转换,新增和删除类名都会更新同一个权威集合。vpt 不在发布事务中重新扫描项目,不重复执行 Vite CSS 预处理,不绕过 Vite 读取物理样式文件,也不从 WXSS 反向解析类名。
增量更新不会改写根目录的 app.wxss。它始终导入 assets/global.wxss,所以只替换后者即可让样式生效,同时保留 App 运行环境。vpt 先写入匹配类名集合的 WXSS,再发布已经用该集合转换过的 JavaScript 补丁。发布前仅把补丁中的 Vite 浏览器 CSS 字符串置空;模块工厂、CSS Modules 导出、changedIds 和补丁序号保持不变。若最终字节没有变化,vpt 不写文件,也不会制造多余的开发者工具事件。
完整构建在最终输出阶段执行同一种图投影,用其中的类名集合处理 JavaScript,并用投影得到的 WXSS 替换 Vite 编译器样式。原生 Page 和组件 WXSS 随后单独输出,始终保持不透明。
什么时候执行完整构建
Section titled “什么时候执行完整构建”rebuild 对每次有效源码变化执行完整构建。两种补丁模式只在以下情况无法继续局部替换时执行:
- Rolldown 明确返回完整构建;
- 已执行的变化模块找不到接受边界;
- HMR 传播路径形成循环;
- 补丁序号缺失或重复;
- 受影响模块缺少新实现;
- 新模块执行或接受回调抛错;
- 热更新边界调用
invalidate(),包括 React Refresh 判定边界失效的情况。
补丁模式的完整构建完成后,主机按以下顺序建立新基线:
- 协调本次完整输出的全局 WXSS;
- 生成新的
buildId,停止使用旧 Rolldown 客户端身份; - 通知旧 App 关闭 WebSocket,再旋转补丁日志身份并清空待确认补丁;
devtools同时清空补丁文件; - 写入带有新身份和认证 WebSocket 地址的
hmr/info.js; - 最后写入带有新
buildId标记的app.wxss。
最后一步是有意的 App 级文件变化。开发者工具随后重启 App,新运行时从序号 0 开始读取已经写好的匹配身份。旧 App 延迟发送的报告会被忽略。
rebuild 没有前四项补丁状态,只在完整输出和最终 WXSS 已落盘后写入新的 App 样式构建标记。两条路径都不尝试在完整构建后恢复 React 内部状态;新的磁盘基线和新的 App 运行环境就是恢复边界。
控制接口做什么
Section titled “控制接口做什么”补丁模式都通过同一个 Vite WebSocket 自定义事件发送运行时报告:
type AppliedReport = { kind: 'applied' buildId: string seq: number}
type RebuildReport = { kind: 'rebuild' buildId: string reason: string}applied 允许主机删除已经成功应用的补丁历史;rebuild 请求完整构建。每份报告都与补丁交付和构建切换按顺序处理,不存在单独的 HTTP 报告协议。
interpreter 的源码发布、构建切换和关闭由主机通过 WebSocket 主动推送。
维护这套实现时,最重要的不是某个具体构建钩子,而是以下顺序:
补丁模式增量更新
Section titled “补丁模式增量更新”收齐且保留全部 Rolldown 增量 → 发布新的 global.wxss(如果变化) → 通过所选模式发布累计补丁 → 按序确认补丁已交付给 Rolldown → 等待 App 报告实际应用序号补丁模式完整构建
Section titled “补丁模式完整构建”写入完整输出 → 协调最终 global.wxss → 通知旧 App 关闭 WebSocket → 创建新 buildId → 重置所选交付 → 写入 info.js → 最后更新 app.wxss,让 DevTools 重启 Apprebuild 更新
Section titled “rebuild 更新”写入完整输出 → 协调最终 global.wxss → 最后更新 app.wxss 的唯一构建标记,让 DevTools 重启 App破坏这些顺序会让页面看到混合构建、让主机过早删除补丁,或让新 App 读取旧身份。
维护者不变量
Section titled “维护者不变量”- 一次服务器只运行一种开发更新模式,模式选择不进入增量热路径;
rebuild的有效更新只发布完整输出,不创建补丁文件、日志、客户端身份或运行时连接;- 补丁模式的普通模块更新不能改写 App 入口或其他会重启 App 的根文件;
devtools的每个 Page 从初始构建起依赖同一个hmr/patches.js;- 每个补丁模式 App 堆只创建并保留一条 Vite WebSocket,不重建连接,也不使用轮询定时器;
- 补丁模式的 App 模块运行时必须比 Page 活得更久;
- 补丁日志必须保留所有尚未确认应用的连续序号;
- 整个受影响模块集合必须在任一新边界执行前统一清除缓存;
devtools的 Page 重新注册必须保留已挂载页面,且不能绑定到临时 Page;- React Refresh 必须复用存活的 App React 根;
- 新构建身份暴露给 App 前,所选交付必须已经重置;
- 样式必须先于对应 JavaScript 补丁发布;
- 任何无法证明连续且安全的补丁状态都必须回到完整构建。
源码中的内部名称
Section titled “源码中的内部名称”| 源码名称 | 本文中的名称 | 含义 |
|---|---|---|
DevEngine | Rolldown 开发引擎 | 生成完整输出和增量模块补丁 |
WxDevHost | vpt 开发主机 | 排队并执行所有磁盘输出、报告和构建切换 |
PatchJournal | 补丁日志 | 保存构建身份和尚未确认应用的累计补丁 |
buildId | 构建身份 | 区分两次完整构建的运行时和报告 |
seq / appliedSeq | 补丁序号 / 已应用序号 | 验证增量连续性并释放已应用历史 |
| HMR boundary | 更新边界 | 调用 import.meta.hot.accept()、可以接收新导出的模块 |
MiniHmrMode | 开发更新模式 | 选择补丁交付能力或完整重建策略,并提供对应运行时、入口改写和插件 |
| Page re-registration | Page 原生重新注册 | devtools 用已挂载 Page 的数据再次注册配置,同时忽略临时 Page 生命周期 |
WxStylePlugin | WX 样式插件 | 从当前模块图和源文件生成同一事务的全局 WXSS 与 JavaScript 类名集合 |