React Native 热重载原理:Android、iOS 与鸿蒙有什么区别
从 Metro、HMR 与 React Fast Refresh 的协作关系出发,解释 React Native 在 Android、iOS、OpenHarmony 上保存代码后发生了什么,以及状态丢失和刷新失效的边界。
改一行 Text 的样式,手机屏幕在一两秒内变了;刚刚输入到一半的表单内容还在。接着你给同一个文件多加了一个导出,屏幕又从首页重新启动。再改一次原生代码,保存 JavaScript 文件彻底没有反应。
这几件事经常都被叫作“热重载”,但它们走的路径并不相同。React Native 日常使用的能力叫 Fast Refresh,它在能够证明更新安全时替换局部组件代码并尽量保留状态;判断不安全时,它会扩大更新范围,必要时退回为完整重载。
Android 和 iOS 的标准 React Native 工程共享这套 JavaScript 刷新模型。鸿蒙侧需要再加一个前提:这里说的“鸿蒙”指 OpenHarmony SIG 维护的 React Native OpenHarmony(RNOH) 适配层,而非 Meta 官方 React Native 核心仓库直接提供的第三个平台。它复用了 Metro 和 React Refresh 的 JavaScript 侧机制,但设备连接、开发菜单、原生模块兼容性都由适配层版本决定。
先把三个相似的词分开
“保存后页面变了”至少可能对应三件事。
| 名称 | 做了什么 | JavaScript 运行时和页面状态 |
|---|---|---|
| 完整重载(Full Reload) | 重新取得入口 Bundle,重新启动 JS 应用 | JS 内存、组件本地状态都会丢失 |
| 模块热更新(HMR,Hot Module Replacement) | Metro 只推送本次变更相关的模块 | 模块被替换;状态如何处理取决于上层运行时 |
| Fast Refresh | 在 HMR 的传输之上,由 React Refresh 判断组件能否原地更新 | 函数组件和 Hooks 的本地状态通常可保留;不安全时重新挂载或完整重载 |
所以,“Fast Refresh 保留状态”不是 Metro 单独完成的承诺。Metro 负责发现文件变化、重新打包并把更新送到客户端;React Refresh 负责把新旧组件视为同一个可刷新家族(family),React 再据此决定重渲染还是重新挂载。
这也解释了一个容易误判的问题:关闭 Fast Refresh 后,保存时页面不再自动更新,并不表示 Metro 停止工作。Fast Refresh 是开发客户端中可开关的一层,React Native 官方文档说明它默认开启,也能在开发菜单中切换。
保存一次文件,更新是怎样抵达屏幕的
先看一条最常见的链路:开发者只修改函数组件的渲染或样式。
保存 Screen.tsx
↓
Metro 监听到文件变化,重新计算受影响的模块图
↓
运行中的 App 通过 WebSocket 连接到 Metro 的 /hot 通道
↓
客户端执行增量模块,并交给 React Refresh Runtime
↓
React 判断组件签名兼容,重渲染组件
↓
原生渲染器根据新的 React 树更新 Android View、iOS UIKit 或 ArkUI 对应视图
React Native 的 HMRClient 源码直接创建了指向 ${origin}/hot 的 Metro 客户端;setUpReactRefresh 则把 react-refresh/runtime 注入全局 Hook,并暴露 performReactRefresh()。这两段代码把“传输更新”和“保留组件身份”清楚地分成了两个阶段。
[配图建议:画出 Metro、WebSocket、JS Runtime、React Refresh、Android/iOS/鸿蒙原生渲染器的时序图;在 React Refresh 之前标注“模块更新”,之后标注“组件状态决策”。]
这里的“增量”也有边界。Metro 会沿依赖图传播更新,React Native 官方文档把模块分成三种情况:
- 文件只导出 React 组件时,通常只更新该模块并重渲染组件。
- 文件还导出了非组件值时,导入它的模块也要重新执行。
- 这个文件被 React 树之外的模块导入时,Fast Refresh 退回完整重载。
第三种情况最容易造成“为什么改个常量就丢状态”。问题不在常量本身,而在运行时无法安全地局部替换一段同时被 React 树和普通业务模块共享的模块初始化代码。
一个能观察状态边界的最小例子
下面先把组件和配置放在同一个文件。点击几次后修改标题并保存,预期现象是:函数组件结构没有变化时,计数大多仍然保留;如果把导出的配置改为被 React 树外模块引用,刷新可能扩大为完整重载。
import React, {useState} from 'react';
import {Button, Text, View} from 'react-native';
// 这个导出刻意和组件放在同一个模块中。
// 当它被非 React 模块导入时,Fast Refresh 无法稳定地只替换 CounterCard,
// 工具链可能退回完整重载,因此生产代码应将共享配置拆到独立文件。
export const screenTitle = '订单数量';
export function CounterCard() {
// 用组件本地状态作为观察对象:只要没有重新挂载,保存后的值就应继续存在。
const [count, setCount] = useState(0);
return (
<View>
{/* 修改此处文本或样式,用来验证普通组件编辑能否快速反映到屏幕。 */}
<Text>{`${screenTitle}: ${count}`}</Text>
{/* 点击按钮改变状态;保存前后的数值用于判断是否发生了重新挂载。 */}
<Button title="加一" onPress={() => setCount(value => value + 1)} />
</View>
);
}
为了让组件文件维持稳定的导出形态,可以这样拆分:
// screenConfig.ts
// 共享常量只由配置模块负责导出,React 组件文件保持“只导出组件”的边界。
export const screenTitle = '订单数量';
// CounterCard.tsx
import React, {useState} from 'react';
import {Button, Text, View} from 'react-native';
import {screenTitle} from './screenConfig';
// 文件只导出 React 组件,Fast Refresh 更容易把它识别为可局部替换的边界。
export function CounterCard() {
const [count, setCount] = useState(0);
return (
<View>
<Text>{`${screenTitle}: ${count}`}</Text>
<Button title="加一" onPress={() => setCount(value => value + 1)} />
</View>
);
}
这个例子不能用来断言每一次保存都保留状态。React 官方给出的保证是“在安全时尽量保留”。类组件不保留本地状态;修改模块的额外导出、改变 Hooks 调用顺序或参数、某些高阶组件包装,都可能让组件重新挂载。
还有一个经常让副作用测试失真的细节:Fast Refresh 期间,useEffect、useMemo、useCallback 会重新执行,依赖数组在这一次更新中被忽略。即使 useEffect(() => {}, []),保存代码后也会再跑一次。因此 Effect 必须能够承受重复执行,例如订阅要在清理函数中取消,网络请求要处理取消或过期结果。
useEffect(() => {
// 每次建立订阅前先记录来源;保存触发的刷新同样可能执行这段逻辑。
const subscription = orderStore.subscribe(refreshOrder);
return () => {
// 清理函数保证重复执行 Effect 时不会留下多个订阅。
subscription.unsubscribe();
};
}, []);
如果确实需要每次保存都从初始状态观察挂载动画,可以在目标文件添加 // @refresh reset。这是一条局部指令,要求 Fast Refresh 每次编辑时重新挂载该文件中定义的组件。它适合调动画,不能用来修复状态管理问题。
Android:差异集中在设备连接和开发入口
Android 的 JS 刷新算法没有一套独立版本。标准 React Native Android 客户端同样通过 Metro 的 HMR 通道接收更新,再进入 React Refresh Runtime。开发时最常见的故障发生在“真机能否访问开发机上的 Metro”。
Android 模拟器和 USB 真机的网络视角不同。真机通过 USB 调试时,可以把设备的 8081 端口转发到开发机:
# 将 Android 设备对 localhost:8081 的请求转发到开发机的 Metro 端口。
# 执行前先确认 Metro 正在运行,执行后用开发菜单的 Reload 验证连接。
adb reverse tcp:8081 tcp:8081
为什么没有手动执行 adb reverse 也能热刷新
很多 React Native 项目的实际操作是先运行 yarn start,然后在 Metro 的交互终端中按下 a 启动 Android。这里的 a 不只是打开应用的快捷键。在标准 React Native CLI 工程中,它通常会进入 Android 的 run-android 启动流程,流程中可能自动尝试建立 8081 端口转发。
sequenceDiagram
participant Dev as 开发者
participant Metro as Metro
participant CLI as React Native CLI
participant ADB as ADB
participant App as Android 应用
Dev->>Metro: 执行 yarn start
Metro-->>Dev: 等待文件变化
Dev->>Metro: 按下 a
Metro->>CLI: 调用 Android 启动命令
CLI->>ADB: 检测设备并尝试建立 8081 转发
ADB-->>App: localhost:8081 指向开发机 Metro
CLI->>App: 安装或启动 Debug 应用
Dev->>Metro: 保存 JS/TS 文件
Metro-->>App: 推送模块更新
App-->>App: Fast Refresh 更新页面
Android 应用中的 Metro 地址通常是 localhost:8081。对于 USB 真机来说,应用内的 localhost 指向手机本身,无法直接访问开发机上的 Metro。adb reverse tcp:8081 tcp:8081 会建立一条反向通道:
Android 应用的 localhost:8081
↓
ADB 反向转发
↓
开发机的 localhost:8081
↓
Metro
所以,开发者没有手动输入转发命令,并不代表转发没有发生。按下 a 后,React Native CLI 可能已经自动尝试执行了这一步。另一种情况是之前运行过 Android 启动命令,8081 转发规则仍然存在;Metro 进程退出后,ADB 转发规则也不一定立即被删除。
可以使用下面的命令查看当前转发规则:
# 查看当前 Android 设备的端口转发规则。
adb reverse --list
如果输出中包含下面的内容,说明当前设备已经存在 Metro 的 8081 反向转发:
tcp:8081 tcp:8081
为了验证按下 a 是否自动建立了转发,可以先删除规则,再重新启动 Android:
# 删除当前设备的 8081 端口转发。
adb reverse --remove tcp:8081
# 确认转发规则已经被删除。
adb reverse --list
# 重新执行 yarn start,然后在 Metro 终端按 a。
yarn start
再次执行 adb reverse --list。如果 tcp:8081 tcp:8081 重新出现,就可以确认是 Android 启动流程自动建立的。连接多个设备时,需要使用 adb -s <设备序列号> reverse --list 指定目标设备。
这里还要区分三层职责:ADB 转发负责让 Android 应用访问 Metro;Metro 负责发现和提供变化后的 JavaScript Bundle;Fast Refresh 负责重新执行模块、更新组件并判断是否保留状态。ADB 转发本身不负责 React 组件刷新。
Android 模拟器如果配置了可访问开发机的地址,也可能通过 10.0.2.2:8081 等方式访问 Metro,因此不一定依赖 adb reverse。实际项目应以 adb reverse --list 和应用当前的 Debug Server 地址为准。
如果设备和电脑在同一个局域网,也可以让 App 直接访问开发机 IP。此时要注意防火墙、VPN 和公司网络隔离。React Native 的 HMR 客户端在 Android 连接失败时给出的排查方向正是 adb devices、adb reverse 或在开发设置中配置 主机 IP:端口。
开发菜单常用入口是 Android 模拟器的 Ctrl + M,实体机摇动设备;不同模拟器和 IDE 的快捷键映射可能覆盖这一行为。菜单中应确认 Enable Fast Refresh 已开启,再修改上面的 Text 验证。改原生 Kotlin/Java、AndroidManifest.xml、Gradle 配置或已编译的原生库后,仍需重新构建并安装 App,Metro 只会处理 JavaScript Bundle 和资源图中的更新。
iOS:刷新链路相同,连接方式没有 adb 这一层
iOS 使用相同的 Metro 与 React Refresh JavaScript 链路,保存函数组件得到的体验通常和 Android 接近。差异主要在开发机与 App 的网络关系:iOS 模拟器运行在 macOS 环境中,通常可以直接访问本机 Metro;iPhone 真机必须能通过局域网访问开发机,或者使用团队已经配置好的调试隧道。
模拟器的开发菜单快捷键通常是 Cmd + D,实体设备可以通过摇动打开。若 HMR 客户端无法建立连接,React Native 源码针对 iOS 的提示是检查 AppDelegate 中配置的 Metro URL。这个提示也说明排查顺序应该先看 App 实际请求的 Bundle 地址,再看网络连通性,最后才怀疑 React 组件代码。
iOS 的原生边界和 Android 一致:改 Objective-C、Swift、Pod 依赖、Info.plist 或原生模块实现后,JS 热刷新没有能力替换已编译的二进制代码。Xcode 重建、重新安装或至少重新启动带新原生代码的 App,才是正确的验证方式。
鸿蒙:先确认你运行的是哪一套 RNOH 适配
“React Native 跑在鸿蒙”目前并不指向一个和 Android/iOS 完全等价的官方核心平台目录。实践中常见的是 OpenHarmony SIG 的 RNOH:项目依赖包名为 @react-native-oh/react-native-harmony,同时带有 entry、oh-package.json5、ArkTS/HarmonyOS 工程等适配文件。
这个区别直接影响热重载的判断。RNOH 0.82.30 的发布包包含 Libraries/Utilities/HMRClient.js 和 Libraries/Core/setUpReactRefresh.js,前者连接 Metro 的 /hot 通道,后者注入 react-refresh/runtime。因此,纯 JS/TS 的函数组件编辑可以沿用 Fast Refresh 的心智模型。
但“可以沿用”不是“所有 RNOH 工程的体验完全相同”。RNOH 的原生开发支持由 HarmonyOS SDK、DevEco Studio、适配层 HSP/HAR、设备连接方式共同提供。开发菜单怎样打开、是否暴露 Fast Refresh 开关、设备如何访问 Metro,必须以当前 RNOH 模板和版本文档为准。文章不把 Android 的 adb reverse 命令照搬到鸿蒙,因为它对应的是 Android Debug Bridge,不能证明适用于 HDC 或特定 RNOH 模板。
鸿蒙项目可按下面的顺序验证,而不是先改配置:
- 启动项目标准命令使用的 Metro 服务,确认终端没有构建错误。
- 在 RNOH 模板提供的开发菜单或日志中确认设备实际请求的 Metro 地址。
- 用真机或模拟器访问该地址,先做一次 Reload,再编辑仅导出函数组件的
Text样式。 - 点击几次计数器后保存,观察状态是否保留;随后编辑 ArkTS 原生模块或
oh-package.json5,确认它要求重新构建。
前两步失败时,重点检查设备到开发机端口的可达性、RNOH 与 React Native 的版本配对,以及模板是否确实启用了开发模式。第三步成功、第四步失败时,再检查组件文件是否混入非组件导出、是否存在 Hooks 顺序变化,以及适配层是否有尚未支持的原生模块能力。
三个平台该怎样选调试动作
| 场景 | Android | iOS | 鸿蒙(RNOH) |
|---|---|---|---|
| 修改函数组件 JSX、样式、事件处理 | Fast Refresh,通常无需手动操作 | Fast Refresh,通常无需手动操作 | 预期走 Fast Refresh;以当前适配版本实测为准 |
| 保存后状态被清空 | 检查组件导出、类组件、Hooks 变化 | 检查项相同 | 先做同一最小组件实验,再核对适配层版本 |
| 真机连接 Metro 失败 | adb reverse、按 a 自动配置或局域网 IP | 检查局域网可达性与 Metro URL | 按模板的 HDC/网络配置和 Metro 地址排查 |
| 改原生模块或工程配置 | 重建 Android App | 重建 iOS App | 重建 HarmonyOS App / 适配层产物 |
| Fast Refresh 异常卡住 | 在开发菜单执行 Reload,必要时重启 Metro | 在开发菜单执行 Reload,必要时重启 Metro | 先通过模板入口 Reload,再查看 RNOH 与 Metro 日志 |
表格中的“状态被清空”有一个关键原则:先用只导出一个函数组件的最小文件复现,再回到真实模块逐项增加依赖。这样能把网络连接、组件签名和适配层问题分开,避免对缓存、IDE 或 Metro 配置连续打补丁。
排查时别把“刷新失败”当作单一问题
保存后没变化、状态丢失、红屏、连不上 Metro 的症状相近,根因却分布在不同层。下面这张表能缩短排查路径。
| 观察到的现象 | 先验证什么 | 常见根因 | 合理动作 |
|---|---|---|---|
修改 Text 后完全没有变化 | App 是否能 Reload 到最新 Bundle | Metro 未启动、设备地址错误、HMR 连接断开 | 先 Reload,再检查 Metro 地址与端口连通性 |
| 修改后变化出现,但计数归零 | 最小组件文件是否只导出组件 | 组件重新挂载、类组件、导出边界不稳定 | 拆分常量,保证 Hooks 调用顺序稳定 |
| 出现语法错误后红屏 | 再次保存一个修复后的 JS 文件 | 编译错误阻止该模块运行 | 修复语法并保存;Fast Refresh 应继续工作 |
| 原生改动没有生效 | 是否重新构建、重新安装 | 二进制未更新 | 走对应平台的原生构建流程 |
| 仅鸿蒙失败 | RNOH 版本、模板、Metro 地址 | 适配层版本配对或设备连接差异 | 对照 RNOH 官方模板与当前版本文档 |
官方文档说明,语法错误或模块初始化阶段的运行时错误修复后,Fast Refresh 会继续;组件内部运行时错误修复后,React 会用新代码重新挂载应用。因而红屏消失不等于状态一定保留,验证时要同时观察错误是否恢复和组件是否重新挂载。
结尾
React Native 的热重载体验来自一条协作链:Metro 发现并传输模块更新,HMR 客户端接收更新,React Refresh 决定组件能否在保留状态的前提下重新渲染。Android 与 iOS 的核心机制相同,差异主要出现在开发菜单和真机到 Metro 的网络通路。
鸿蒙上的 RNOH 在 JavaScript 层复用了同样的 HMR 与 React Refresh 代码,因此函数组件的调试方式可以借鉴 Android/iOS;原生工具链和适配层能力必须跟随具体 RNOH 版本验证。下一次遇到“保存后不对”时,先判断它属于连接失败、模块边界导致的重新挂载,还是原生二进制未重建,再选择对应的调试动作。
参考资料
本文参考 React Native 0.86 文档与 @react-native-oh/react-native-harmony@0.82.30 发布包,资料核对于 2026-07-30。
评论
使用 GitHub 登录参与讨论。