热更新原理深度解析
从Bundle生成、增量补丁到回滚机制,拆解RN热更新的核心原理和自建方案。
一句话概括
RN 热更新的本质是在不经过应用商店审核的情况下替换 JS Bundle 文件和 Assets 资源,核心三件事:Bundle 怎么生成、增量补丁怎么打、回滚怎么兜底。
核心知识点
1. 热更新能做什么、不能做什么
清楚边界是面试第一关:
1
2
✅ 能做的:JS 逻辑变更、UI 调整、文案修改、样式修复、第三方 JS 库升级
❌ 不能做的:原生代码(Java/ObjC)修改、新增权限、新增 Native Module、修改 Info.plist
为什么不能更新原生代码? 因为 iOS/Android 不允许从网络下载并执行可执行代码。JS 能热更是因为它在 Hermes/JSC 虚拟机里运行,字节码不算「原生可执行代码」。这也是 Flutter 不支持热更新的根本原因——Dart 编译成了原生机器码。
2. Bundle 的生成与结构
Metro 打包出来的 Bundle 是一个自执行的闭包数组,每个模块被分配一个数字 ID:
1
2
3
4
5
6
7
8
9
// Metro 输出的 Bundle 结构
__d(function(g, r, i, a, m, e, d) {
// 模块 42 的代码
// r(0) = require 模块 0
}, 42);
__d(function(g, r, i, a, m, e, d) { /* 模块 0 = react */ }, 0);
require(42); // 入口
为什么用数字 ID 而不是路径字符串? 数字 ID 在 gzip 下压缩率极高、且 require(42) 比 require('./path/to/file') 查找快得多。这个设计也是增量补丁体积能做到全量 10-20% 的基础。
3. 增量更新——只传变化的部分
全量下载一个 5MB 的 Bundle 太慢了。增量更新的思路是:服务端算出「旧版本 → 新版本」的差异补丁,客户端下载补丁后本地还原:
1
旧 Bundle (1.0, 5MB) + 补丁包 (0.5MB) → bspatch → 新 Bundle (2.0, 5MB)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 客户端判断用补丁还是全量
async function checkUpdate(localHash, remoteHash) {
// 1. 先问服务端:有没有针对我当前版本的补丁?
const patchUrl = `${CDN}/patch/${localHash}/${remoteHash}`;
const patchResp = await fetch(patchUrl);
if (patchResp.ok) {
// 有补丁 → 只下载 0.5MB
const patch = await patchResp.arrayBuffer();
const oldBundle = await readLocalBundle();
return applyBsPatch(oldBundle, patch); // 本地还原
}
// 无补丁 → 回退到全量下载
return downloadFullBundle(remoteHash);
}
补丁体积为什么能做到这么小? bsdiff 算法在二进制层面找差异,Metro 的数字 ID 模块系统让模块插入/删除只影响局部,不会导致整个文件「面目全非」。
4. 回滚机制——热更新的最后防线
热更新最怕的事:新 Bundle 上线后所有用户崩溃,而你来不及审核发版。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// CodePush 的回滚检测:启动时设一个标记文件,正常启动后删除
// 如果下次启动发现标记还在 = 上次 crash 了
const ROLLBACK_MARKER = `${sandboxDir}/codepush/crashed.json`;
function onAppStart() {
const crashed = readJSON(ROLLBACK_MARKER);
if (crashed && crashed.count >= 3) {
// 连续 crash 3 次 → 回滚到上一个版本
revertToPreviousBundle();
deleteFile(ROLLBACK_MARKER);
return;
}
// 标记「正在运行新版本」,30 秒后自动删除(表示没 crash)
writeJSON(ROLLBACK_MARKER, { count: (crashed?.count || 0) + 1 });
setTimeout(() => deleteFile(ROLLBACK_MARKER), 30000);
}
核心思路: crash 标记 + 计数的组合确保「偶然 crash 不回滚,确实坏了才回滚」。
5. 分包策略——基础包和业务包分离
单一 Bundle 在大型 App 中会膨胀到 10-20MB。分包把代码拆成两层:
1
2
基础包 (base.bundle, ~5MB):react、react-native、通用工具库 → 打成 App 包内,基本不变
业务包 (biz.bundle, ~1-3MB):页面组件、业务逻辑 → 走热更新,每次迭代只升级这个
Metro 的分包配置关键是 createModuleIdFactory——让基础包和业务包共享同一套模块 ID 空间,加载时不会冲突。
其实你每天都在用
- 微信小程序更新: 背后的逻辑和 RN 热更新一模一样——JS 逻辑包从 CDN 下载,替换本地缓存,下次启动生效
- 淘宝/京东首页布局突然变了却没更新 App: 就是热更了业务 Bundle,页面结构是「动态楼层」渲染的
- App 内弹窗让你「立即重启」: 强制更新(Mandatory)模式,下载完立即
RCTReloadCommand - 游戏 App 的「正在下载更新包」进度条: 同样是 Bundle 替换 + Assets 资源更新,只是换了下载进度 UI
- App 首次安装后打开特别快、第二次反而慢: 首次用的是 APK 内的基础包(本地加载极快),第二次检测到更新后开始下载新业务包
常见误解(FAQ)
❌ 误区1:「热更新 = 绕过审核,什么都能改」
热更新只能改 JS 代码,不能改原生代码。如果你新增了一个需要相机权限的功能,热更新做不到——因为没有在 AndroidManifest.xml 中声明权限。苹果审核尤其关注这一点:如果热更新被用来「从审核版切换到功能完全不同」的商业版,会被下架。
❌ 误区2:「用了 Hermes,热更新就不能做了」
Hermes 的 .hbc 字节码文件可以像 .jsbundle 一样替换。只是构建链路多了一步:Babel 转译 → Hermes 编译 → 打包为 .hbc。CodePush 和大多数自建方案都已适配 Hermes。
❌ 误区3:「自建热更新太复杂,直接用 CodePush 就行」
CodePush 已被微软整合进 App Center,进入维护模式(不新增功能、不保证 bug 修复节奏)。新项目建议评估自建方案——核心就一个文件上传 + 版本比对 + 下载的 HTTP 服务,复杂度不高,但自主可控。
❌ 误区4:「灰度和热更新是两件事」
它们是同一件事。灰度就是在「检查更新」接口里加条件判断——按用户 ID 哈希分流、按渠道分流、按地区分流。自建更新服务做灰度只需要在 checkUpdate 里加一段 if。
一句话总结
热更新 = Bundle 替换 + 增量补丁 + 回滚保护,三件事做好了就能做到「上线一个修复,10 分钟全网生效」——但别忘了,它改写不了原生代码。