热更新实现原理
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 JavaScript 注册代码 | glass-easel 保留原生 Page 实例,不重放页面生命周期 |
| 完整构建 | 整个 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 树 |
平台 Page 注册策略(仅 devtools) | WX 直接保留配置和生命周期;其他平台使用既有交接实现 |
初始构建建立的基础
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 重启后的启动同步
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 重启 └─ 补丁 → 各入口 WXSS → 累计补丁日志 ├─ devtools: patches.js → Page 重载 → 原生工厂 └─ interpreter: Vite WebSocket → Sval 安装源码 ↓ 共享运行时验证并一次应用整个批次 ↓ React Refresh → WebSocket 报告已应用序号rebuild 在完整输出后结束本次更新。devtools 随后重新执行 Page 注册,WX glass-easel 不重放生命周期;interpreter 的 Page 从未被重新注册。下面主要按两种补丁模式的共享顺序和差异展开。
1. Rolldown 判断更新类型
Section titled “1. Rolldown 判断更新类型”每次源码变化会得到以下结果之一:
| 结果 | 含义 |
|---|---|
| 无变化 | 没有需要发布的运行时更新 |
| 模块补丁 | 包含变化模块的新注册程序,可以尝试局部替换 |
| 完整构建 | 变化不能表示为安全的局部更新 |
语法错误等中间状态不会立刻触发完整构建。vpt 记录错误并继续运行上一份有效代码;下一次保存恢复有效语法后,开发引擎可以继续产生正常补丁。
连续保存时,vpt 会短暂等待这一轮编译结果结束,再合并成一次磁盘通知。这段等待只减少文件写入次数,不会丢弃中间补丁:后一个补丁是基于前一个补丁产生的增量,必须按原顺序全部保留。
2. 主机按顺序发布样式和补丁
Section titled “2. 主机按顺序发布样式和补丁”一次补丁发布遵守固定顺序:
- 根据最新模块图更新 App 和页面 WXSS;原生 CSS 转换或压缩失败时只报告错误,继续使用上次成功的样式;
- 让所选模式发布累计补丁:
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 如何保留微信页面
Section titled “devtools 如何保留微信页面”WX 使用 glass-easel。开发者工具热更新会重新执行对象形式的 Page(...) 注册,但保留当前原生 Page 实例,不重新调用其数据工厂,也不重放 onUnload、onLoad、onShow。这与真正的页面卸载、重新进入不同。
共享的 injectPageHmr 通过普通生命周期包装记录已挂载的原生 Page。再次注册时检查该实例的 groupUpdates 方法:glass-easel 提供此方法,exparser 不提供。检测到 glass-easel 后直接返回配置,不抑制生命周期,不复制 Page 或 CustomWrapper 快照,也不设置等待清理的重注册标记。React Refresh 继续更新原有 React 树,Taro 继续连接原来的原生页面。
不能在热更新时设置一个依赖后续 onShow 清除的标记:该回调没有发生时,标记会泄漏到之后的真实导航,误跳过真正的卸载和加载,令新页面继续显示旧快照却无法接收 React 更新。用定时器或微任务猜测清理时机同样不是 WX 的生命周期约定。
prerender: true 的 Page({ data: () => ... }) 工厂仅在创建新原生实例时执行。热注册不会新建实例;真实导航仍执行工厂和普通 Taro 生命周期。VPT 的 createVptPage 仅在开启预渲染时把原始配置的 data 替换为工厂,通过闭包保留对象形式种子,不复制配置。原生壳不再调整数据形式,只把胶囊导出的配置交给 HMR helper 后调用 Page(pageConfig)。
该判断基于每个已挂载 Page 的运行时能力,而不是 WX 目标、App 配置或 componentFramework 编译器选择,因此可区分同一 App 中使用不同框架的页面。尚未挂载或已经卸载的配置仍直接返回;没有该能力的页面保留既有的生命周期和原生快照交接实现。各平台使用同一个 HMR helper,不再传入平台专用的 Page 注册策略。
样式如何更新
Section titled “样式如何更新”WXSS 内容不通过 JavaScript 模块的更新边界交付。完整构建和增量补丁共用一个 WX 样式插件和同一种样式事务:
- Tailwind 扫描器把已有候选文件和编译依赖登记到 Rolldown;相关文件变化时,Rolldown 重新转换对应 Tailwind 入口;
- 入口复用自己的增量生成器,得到新的浏览器 CSS 和同一代原始类名集合;编译依赖变化时才重建生成器;
- Vite 继续执行 PostCSS、CSS Modules 和预处理器转换,vpt 在内置
vite:css-post序列化浏览器 HMR 模块前记录最终模块 CSS; - 按
build.cssCodeSplit投影 Rolldown 当前模块图:开启时分别选择 App 和每个页面仍然可达的 CSS,页面排除 App 已拥有的样式;关闭时按 App、配置页面顺序合并所有可达样式并去重,页面样式保持为空; - 分别转换、压缩 App 与页面 WXSS,仅在 App 样式中加入 HTML 显示默认值;
- 用所有存活入口的 Tailwind 类名并集转换同一事务中的最终 JavaScript;
- 逐个原子替换转换成功且内容发生变化的
assets/global.wxss和页面 WXSS,写入全部成功后再发布 JavaScript 补丁。
Tailwind 生成器只负责编译入口,不拥有补丁发布。它在入口存活期间保留增量扫描缓存;候选文件变化由 Rolldown 触发入口转换,新增和删除类名都会更新同一个权威集合。vpt 不在发布事务中重新扫描项目,不重复执行 Vite CSS 预处理,不绕过 Vite 读取物理样式文件,也不从 WXSS 反向解析类名。
增量更新不会改写根目录的 app.wxss。它始终导入 assets/global.wxss;开启 build.cssCodeSplit 时,App 样式更新只替换后者,页面样式更新则写入对应的页面 WXSS;关闭时,所有应用样式更新都写入全局文件。vpt 先写入转换成功的 WXSS,再发布已经用当前类名集合转换过的 JavaScript 补丁。发布前仅把补丁中的 Vite 浏览器 CSS 字符串置空;模块工厂、CSS Modules 导出、changedIds 和补丁序号保持不变。每个文件分别比较最终字节,未变化的文件不会重写;删除页面样式导入会写入空内容,清除旧规则。若后续文件写入失败,JavaScript 不会交付;重试跳过已成功写入的文件。
HMR 的原生 CSS 转换或压缩失败时,vpt 只报告输出文件名和错误,不抛出该 CSS 错误,也不发布失败或部分处理的 CSS;对应文件继续使用上次成功的样式,有效 JavaScript 和其他正常样式继续交付。若尚无成功结果,则跳过该文件。失败的源 CSS 不会被缓存为成功结果,下次更新仍会重试;修复后恢复正常转换和发布。JavaScript 仍使用当前 Tailwind 类名集合,因此修复前可能暂时出现样式缺失或不匹配,但不会因丢弃已分配序号的补丁而触发序号缺口和状态重置。此策略不吞掉 Vite / Tailwind 编译错误、JavaScript 转换错误或文件写入错误。
完整构建仍严格拒绝任何转换或压缩错误;它在最终输出阶段执行同一种图投影,用类名并集处理 JavaScript,并输出 App 和页面 WXSS。平台骨架不再生成空的页面样式占位文件;原生自定义组件的样式随后独立输出,始终保持不透明。
什么时候执行完整构建
Section titled “什么时候执行完整构建”rebuild 对每次有效源码变化执行完整构建。两种补丁模式只在以下情况无法继续局部替换时执行:
- Rolldown 明确返回完整构建;
- 已执行的变化模块找不到接受边界;
- HMR 传播路径形成循环;
- 补丁序号缺失或重复;
- 受影响模块缺少新实现;
- 新模块执行或接受回调抛错;
- 热更新边界调用
invalidate(),包括 React Refresh 判定边界失效的情况。
补丁模式的完整构建完成后,主机按以下顺序建立新基线:
- 协调本次完整输出的 App 与页面 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 增量 → 发布内容有变化的 App / 页面 WXSS → 通过所选模式发布累计补丁 → 按序确认补丁已交付给 Rolldown → 等待 App 报告实际应用序号补丁模式完整构建
Section titled “补丁模式完整构建”写入完整输出 → 协调最终 App / 页面 WXSS → 通知旧 App 关闭 WebSocket → 创建新 buildId → 重置所选交付 → 写入 info.js → 最后更新 app.wxss,让 DevTools 重启 Apprebuild 更新
Section titled “rebuild 更新”写入完整输出 → 协调最终 App / 页面 WXSS → 最后更新 app.wxss 的唯一构建标记,让 DevTools 重启 App破坏这些顺序会让页面看到混合构建、让主机过早删除补丁,或让新 App 读取旧身份。
维护者不变量
Section titled “维护者不变量”- 一次服务器只运行一种开发更新模式,模式选择不进入增量热路径;
rebuild的有效更新只发布完整输出,不创建补丁文件、日志、客户端身份或运行时连接;- 补丁模式的普通模块更新不能改写 App 入口或其他会重启 App 的根文件;
devtools的每个 Page 从初始构建起依赖同一个hmr/patches.js;- 每个补丁模式 App 堆只创建并保留一条 Vite WebSocket,不重建连接,也不使用轮询定时器;
- 补丁模式的 App 模块运行时必须比 Page 活得更久;
- 补丁日志必须保留所有尚未确认应用的连续序号;
- 整个受影响模块集合必须在任一新边界执行前统一清除缓存;
- WX
devtools的 Page 热注册不得包装真实生命周期或创建新的原生页面连接; - React Refresh 必须复用存活的 App React 根;
- 新构建身份暴露给 App 前,所选交付必须已经重置;
- 转换成功的样式必须先于对应 JavaScript 补丁发布;HMR 原生 CSS 转换失败时只报告错误并保留上次成功的样式,不丢弃有效补丁;
- 任何无法证明连续且安全的补丁状态都必须回到完整构建。
源码中的内部名称
Section titled “源码中的内部名称”| 源码名称 | 本文中的名称 | 含义 |
|---|---|---|
DevEngine | Rolldown 开发引擎 | 生成完整输出和增量模块补丁 |
WxDevHost | vpt 开发主机 | 排队并执行所有磁盘输出、报告和构建切换 |
PatchJournal | 补丁日志 | 保存构建身份和尚未确认应用的累计补丁 |
buildId | 构建身份 | 区分两次完整构建的运行时和报告 |
seq / appliedSeq | 补丁序号 / 已应用序号 | 验证增量连续性并释放已应用历史 |
| HMR boundary | 更新边界 | 调用 import.meta.hot.accept()、可以接收新导出的模块 |
MiniHmrMode | 开发更新模式 | 选择补丁交付能力或完整重建策略,并提供对应运行时、入口改写和插件 |
| Page re-registration | Page 原生重新注册 | WX glass-easel 重新执行注册代码,但保留已挂载原生实例及其 Taro 连接 |
WxStylePlugin | WX 样式插件 | 从当前模块图和源文件生成同一事务的全局 WXSS 与 JavaScript 类名集合 |