跳转到内容

热更新实现原理

vpt 的微信 HMR 建立在微信开发者工具的文件热重载行为上,而不是把浏览器 HMR 搬进小程序。理解它的关键不是先看模块更新算法,而是先理解开发者工具如何根据发生变化的物理文件决定重载范围。

使用与排查方法参见开发者工具热更新。本页只解释当前代码真正执行的机制。

浏览器可以通过 WebSocket 收到更新通知,再从开发服务器导入新的 JavaScript URL。微信小程序不能把 wx.request() 收到的源码交给 eval()Function() 或等价机制执行。

因此,可执行补丁必须先成为 dist/wx 中的真实 JavaScript 文件,再由微信开发者工具编译和执行。HTTP 只能报告状态,不能传输可执行更新。

开启 compileHotReLoad 后,开发者工具会区分 App 级重载与页面 JavaScript 热重载:

  • 普通完整输出发生变化时,开发者工具可以重新启动 App,旧的 JavaScript 堆、Taro 根和 React Fiber 树都会消失。
  • 页面入口或页面在初始代码中直接加载的 JavaScript 文件发生变化时,开发者工具可以重新执行页面代码,同时保留 App 运行环境。

React Refresh 只能更新仍然存活的 Fiber 树,所以 vpt 必须把普通 JavaScript 更新压缩成一个页面级文件变化,不能重写原来的 App、页面和共享代码文件。

依赖必须从第一次构建开始就直接存在

Section titled “依赖必须从第一次构建开始就直接存在”

每个页面入口从初始构建起就包含一个字面量 require()

require('../../hmr/patches.js')

相对路径根据最终页面文件位置生成。这个依赖不能等页面开始重载后再动态发现,也不能通过运行时拼接路径。开发者工具需要从既有物理依赖关系判断这次变化属于页面 JavaScript 热重载。

页面入口正是开发者工具会重新执行的代码。如果模块运行时保存在页面作用域中,每次更新都会丢失模块缓存、补丁序号和 React Refresh 状态。

vpt 把开发模块运行时保存在 App 持有的微信全局环境中。页面重新执行后仍然访问同一个运行时、同一份模块图和同一棵 React Fiber 树。

整个机制可以概括为:

Rolldown 生成模块补丁,vpt 只改写所有页面预先直接依赖的 hmr/patches.js;微信开发者工具因此重新执行页面而不重启 App,存活的 App 模块运行时再把补丁应用到原模块图,并由 React Refresh 更新现有 Fiber 树。

保存源码
Rolldown 从现有开发模块图生成补丁
只改写 dist/wx/hmr/patches.js
微信开发者工具重新执行存活页面入口
页面先 require() 补丁文件
App 模块运行时同步替换受影响模块
React Refresh 更新现有 React 树
Taro 根重新绑定开发者工具创建的替换页面

微信开发模式使用 Vite bundled development 和一个直接写入 dist/wx 的 Rolldown DevEngine。vpt 替换 Vite 默认的内存交付方式,但继续使用 Vite 已解析的模块图和插件转换结果。

启动时会完成以下工作:

  1. 取得 Vite 为 bundled development 生成的 Rolldown 配置。
  2. 创建唯一的物理 DevEngine,不启动第二个 watcher 或嵌套构建。
  3. 使用稳定的开发文件名,避免内容哈希改变物理依赖路径。
  4. 关闭开发 source map,并把注入的运行时代码降级到微信支持的语法目标。
  5. 在 HTTP 服务显示就绪前完成第一次完整物理构建。
  6. HTTP 端口确定后生成本次完整构建的 buildId
  7. 重置 hmr/patches.js,再写入 hmr/info.js

vpt 还关闭 Vite bundled development 的“按 HTTP 请求重新生成过期输出”路径。微信项目以磁盘文件为准,任何绕过 HMR 协议的额外完整写入都可能让开发者工具重启 App。

App 入口在业务依赖执行前初始化开发运行时:

__rolldown_runtime__.initialize(require('./hmr/info.js'))

hmr/info.js 包含:

{
buildId: string
endpoint: string
}

buildId 同时是当前 Rolldown HMR 客户端的身份。endpoint 使用 Vite 实际绑定的协议、主机和端口,不假设一定是 localhost:5173

每个页面入口在共享运行时代码已经可用之后、业务页面模块执行之前加载补丁文件:

require('../../hmr/patches.js')

初始 hmr/patches.js 为:

module.exports = undefined

所以第一次页面启动不会应用任何更新,但开发者工具已经记录了这个直接依赖。

普通源码变化到来后,DevEngine 会为当前 buildId 返回以下结果之一:

  • Noop:没有需要发布的更新。
  • Patch:可以用模块补丁表示。
  • FullReload:当前变化不能安全表示为局部更新。

当前构建只接受身份与 buildId 相同的 Patch。旧完整构建延迟返回的结果会被忽略,不可能进入新构建的补丁历史。

每个补丁直接使用 Rolldown 提供的:

{
seq: number
changedIds: string[]
code: string
filename: string
}

vpt 不解析或重新生成 Rolldown 的模块工厂代码,只把它包装成可以由微信项目文件同步执行的程序。

一次成功的 JavaScript HMR 不会要求 DevEngine 重新输出普通应用文件。开发主机使用 rebuildStrategy: 'never',只有明确的恢复路径才触发完整构建。

补丁文件通过对最终路径直接写入完成。这里不使用“写临时文件再 rename”的原子替换方式:开发者工具对文件写入形态也会作出不同判断,rename 曾被观察为更大范围的项目变化。直接完成最终文件写入才能稳定触发所需的页面重载边界。

这一约束意味着普通 JavaScript HMR 完成后,物理差异应只有:

dist/wx/hmr/patches.js

app.js、页面入口、共享代码、JSON、WXML 和普通资源保持不变。

一个补丁文件包含当前 buildId 和一个或多个尚未确认交付的补丁:

__rolldown_runtime__.storePatches({
buildId,
patches: [
{
seq,
changedIds,
factory: () => {
// Rolldown 生成的模块图和模块工厂注册代码
}
}
]
})

require() 该文件时会同步调用 storePatches()。补丁不会从 HTTP 响应中取回,也没有 WebSocket JavaScript 传输。

开发者工具发现 hmr/patches.js 变化后,会重新执行依赖它的存活页面入口。页面入口的执行顺序保证:

  1. 共享模块运行时先从微信模块缓存中取得,仍然是 App 已经使用的同一个实例。
  2. hmr/patches.js 在业务页面导入之前执行。
  3. 新模块实现同步注册并应用。
  4. 页面剩余代码随后看到的是更新后的模块状态。

同一个补丁文件可能被多个存活页面依次加载。运行时使用序号忽略已经应用的补丁,但每次 storePatches() 仍会打开对应页面的替换生命周期窗口,让每个被开发者工具替换的页面都能重新绑定。

App 模块运行时扩展 Rolldown 的开发运行时,直接复用它维护的模块图、反向引用关系、模块缓存、导出对象和可重新执行工厂。

payload.buildId 必须与 App 启动时读取的 hmr/info.js 一致。旧构建补丁只会产生警告,不会修改当前模块图。

运行时取得文件中最大的补丁序号,并发送:

{
kind: 'delivery',
buildId,
seq
}

这个报告表示补丁文件已经被微信执行,不表示 React 已经完成渲染。主机据此释放已交付补丁并调用 DevEngine 的 notifyPayloadDelivered()

模块应用失败使用单独的 rebuild 报告处理,因此“文件已到达”和“模块应用成功”是两个不同事实。

storePatches() 在应用模块前设置热更新标记。开发者工具随后触发的页面替换生命周期会检查该标记,以区分代码热重载和真实用户导航。

运行时维护 appliedSeq

  • seq <= appliedSeq 的补丁是重复交付,直接跳过。
  • 新补丁必须等于 appliedSeq + 1
  • 序号缺失会立即停止当前范围并请求完整构建。

执行补丁的 factory() 会更新 Rolldown 模块图,并为能够重新执行的模块注册新工厂。此时只安装新实现,还没有清除旧模块缓存。

运行时从每个 changedId 开始:

  1. 未执行过的变化模块不需要立即刷新;新工厂保留到它第一次被导入。
  2. 已执行模块沿已经执行的引用方逐层向上遍历。
  3. 遇到具有接受回调的热上下文时,记录为接受边界。
  4. 沿途模块加入同一个更新集合。
  5. 没有接受边界或传播路径形成循环时,终止局部更新。

Rolldown 的反向索引同时包含静态和动态导入关系。遍历只处理已经执行的受影响子图,复杂度为 O(V + E)

更新集合中的每个模块都必须存在可重新执行工厂。运行时先保存旧接受边界及其回调,再统一删除整个更新集合的模块缓存。

统一清除发生在任何新模块执行之前,避免一个边界重新执行时读到另一个受影响模块的旧导出。

运行时重新初始化每个接受边界。初始化会从已注册工厂执行新的模块实现及其失效依赖,并得到最新导出。

随后调用上一代热上下文中的接受回调,把最新导出交给它。新执行产生的新热上下文将供下一次更新使用。

如果接受回调调用 invalidate(),或任意工厂、边界回调抛出异常,本次范围停止,运行时发送 rebuild 报告。只有模块更新成功后,appliedSeq 才会增加。

vpt 使用 @vitejs/plugin-react 生成组件签名、类型注册和接受边界,不实现自己的 React 状态复制。

浏览器版本的 Refresh 代码依赖 HTML 前置脚本、window 和自由变量形式的 React DevTools Hook。微信环境没有相同的词法全局,因此开发构建只改写三个确定的协议位置:

  1. Refresh 运行时中的已知 window 协议属性改为微信全局环境。
  2. React 相关模块中的自由 __REACT_DEVTOOLS_GLOBAL_HOOK__ 改为显式访问 global.__REACT_DEVTOOLS_GLOBAL_HOOK__
  3. 依赖浏览器前置脚本的 $RefreshReg$ 检查被移除,因为模块已经包含局部注册包装器。

App 开发运行时会在 Taro React 渲染器加载前,在 WeChatGlobal 上安装最小 React DevTools Hook。Refresh 运行时加载后会取得已经注册的渲染器,从而能够调度现有 Fiber 根更新。

React 组件边界的接受回调验证新旧导出,并安排 React Refresh。兼容组件复用现有 Fiber 与 Hook 状态;不兼容边界调用 invalidate(),进入完整构建恢复路径。

vpt 不把 Fiber 序列化到补丁文件,也不创建第二棵 React 树。状态能够保留,是因为这次物理文件变化没有让开发者工具重启 App,原 Fiber 树始终存活。

页面状态为什么不会被 Taro 卸载

Section titled “页面状态为什么不会被 Taro 卸载”

模块替换成功并不自动等于页面可继续显示。开发者工具重新执行页面代码时会创建替换页面,并触发页面卸载、加载和显示过程。若这些回调进入 Taro 默认逻辑:

  • onUnload 会卸载原 React 页面子树。
  • onLoad 会创建新的页面身份和渲染连接。
  • Hook 状态和原生输入状态都会随旧树消失。

vpt 只在热更新标记存在时调整这组替换生命周期。

保存旧页面实例的:

$taroPath
$taroParams

然后跳过原始 Taro onUnload,因此现有 React 页面子树仍在 App 根中。

将保存的路径和参数写入开发者工具创建的新页面实例,然后:

  1. 把新实例重新注入 Taro 的页面映射。
  2. 更新 Current.page
  3. 找到原 $taroPath 对应的 Taro 页面根。
  4. 把该根的 ctx 改为新的微信页面实例。
  5. 调用 updateChildNodes() 生成完整节点快照。
  6. 调用 performUpdate(true) 把完整 UI 同步到新页面实例。

必须重新发送完整节点树,因为旧页面此前产生的增量更新已经被消费,替换页面不能依赖旧增量队列恢复画面。

首次替换 onShow 清除热更新标记,并跳过业务 onShow。这样热更新不会重复请求数据或重置业务状态。

没有热更新标记时,onLoadonShowonUnload 全部原样转发。正常跳转、返回和关闭页面不受影响。

主机为当前完整构建保留尚未确认物理交付的补丁。

假设当前已确认序号为 3

  1. 保存一次产生补丁 4,文件写入 [4]
  2. 开发者工具尚未加载时再次保存,文件改写为 [4, 5]
  3. 页面加载后按顺序应用 45
  4. 运行时报告交付到 5
  5. 主机删除待发布队列中不大于 5 的前缀。

如果开发者工具重复执行同一文件,appliedSeq 会跳过已经成功应用的序号。如果运行时看到 [5] 但自己的 appliedSeq 仍为 3,缺少序号 4 会请求完整构建,而不是猜测中间状态。

当前控制接口只有一个路径,并只接受 POST。运行时发送两类报告:

type DeliveryReport = {
kind: 'delivery'
buildId: string
seq: number
}
type RebuildReport = {
kind: 'rebuild'
buildId: string
}

主机只接受当前 buildId 的报告:

  • delivery:释放已到达运行时的补丁,并通知 DevEngine。
  • rebuild:触发一次完整构建。

接口不保存可执行源码,不返回补丁,不进行轮询,也不维护另一套运行时会话协议。

以下情况会进入完整构建:

  • DevEngine 返回 FullReload
  • 已执行变化模块找不到接受边界。
  • HMR 传播遇到循环路径。
  • 补丁序号中断。
  • 更新集合缺少可执行工厂。
  • 工厂或接受回调抛出异常。
  • React Refresh 使边界失效。
  • 运行时主动发送 rebuild 报告。

完整构建完成时,主机:

  1. 生成新的 buildId
  2. 从 DevEngine 移除旧客户端并注册新客户端。
  3. 清空未确认补丁。
  4. hmr/patches.js 重置为空状态。
  5. 最后写入新的 hmr/info.js

普通输出同时由 DevEngine 重新写入 dist/wx。微信开发者工具据此重新启动 App;新的 App 堆、模块运行时、React 根和补丁序号共同从基线开始。

不需要在旧 App 堆中实现第二套“重置”流程。完整物理输出和开发者工具重启就是恢复协议。

浏览器 Vite HMRvpt 微信 HMR
WebSocket 通知浏览器导入更新 URL。可执行代码必须写入开发者工具观察的物理文件。
HMR 请求可以直接携带或定位新 JavaScript。HTTP 只报告交付序号与完整构建请求。
浏览器页面对象不会因模块更新被替换。开发者工具重新执行页面并创建替换页面实例。
React DOM 仍连接原来的页面环境。Taro 页面根必须重新绑定新的微信页面实例。
window、DOM 和 HTML 前置脚本存在。Refresh 协议必须定向适配到微信全局环境。
location.reload() 完成恢复。完整物理构建让开发者工具重启 App。
内存输出或更新 URL 决定代码版本。文件路径、直接依赖和写入形态共同决定重载边界。

收到源码不等于可以执行源码。没有开发者工具编译的物理文件,就没有合法的补丁执行边界。

这会绕过单一补丁入口,并让开发者工具看到更大范围的输出变化。即使页面重新执行,原文件代码也无法独立协调模块缓存、接受边界和 React Refresh。

开发者工具需要从初始物理依赖图判断重载范围。动态路径或间接发现不能替代页面入口中的既有字面量依赖。

页面正是被重新执行的边界。运行时随页面重建后,就无法保存旧模块缓存、接受回调、补丁序号或 React Refresh 家族。

给微信全局环境添加一个 window 别名

Section titled “给微信全局环境添加一个 window 别名”

微信不会把对象属性自动解析为 JavaScript 自由变量。广泛模拟 window 还会改变业务代码语义,所以 vpt 只改写已知的生成代码协议位置。

React Fiber 包含渲染器拥有的可变关系,不能可靠序列化和重建。vpt 选择避免普通更新重启 App,而不是在重启后复制 React 内部状态。

维护 HMR 代码时必须保持:

  1. 每个页面从初始构建起直接、字面量地依赖同一个 hmr/patches.js
  2. 页面必须先取得 App 模块运行时,再执行补丁文件和业务模块。
  3. 普通 JavaScript HMR 只能改写 hmr/patches.js
  4. 补丁文件必须直接写入最终路径,不能通过临时文件 rename 发布。
  5. HTTP 不得传输可执行补丁。
  6. 补丁必须同时匹配当前 buildId 和下一个连续序号。
  7. 更新集合必须在任何接受边界重新执行前统一清除缓存。
  8. React Refresh 必须复用仍然存活的 App Fiber 根。
  9. 只有开发者工具产生的替换生命周期可以跳过 Taro 默认处理。
  10. 正常页面导航必须继续调用原始 Taro 生命周期。
  11. 任何不安全或不完整的模块状态都必须请求完整构建。
  12. buildId 发布前必须先重置补丁文件。