React Native 热重载的原理:Android、iOS 与鸿蒙上的表现和区别
从 Metro 和 Fast Refresh 的协作关系出发,解释 React Native 在 Android、iOS 与 OpenHarmony 上保存代码后的刷新链路、状态边界和平台差异。
你修改了一个 Text 的文案,保存文件后,Android 或 iOS 手机上的页面很快就更新了;但当你修改了原生模块、权限配置或第三方依赖时,页面却必须重新编译。
鸿蒙项目里还可能出现另一种情况:Metro 已经启动,JS 文件也发生了变化,但页面没有自动更新,或者更新后页面状态全部丢失。
这些现象经常都被叫作“热重载”,但它们走的路径并不相同。React Native 日常使用的能力叫 Fast Refresh,它在能够证明更新安全时替换局部组件代码并尽量保留状态;判断不安全时,它会扩大更新范围,必要时退回为完整重载。
Android 和 iOS 的标准 React Native 工程共享这套 JavaScript 刷新模型。鸿蒙侧需要再加一个前提:这里说的“鸿蒙”指 OpenHarmony SIG 维护的 React Native OpenHarmony(RNOH)适配层,而非 React Native 核心仓库直接提供的第三个平台。它复用了 Metro 和 React Refresh 的 JavaScript 侧机制,但设备连接、开发菜单、原生模块兼容性都由适配层版本决定。
先区分三个概念
“保存后页面变了”至少可能对应三件事。这里容易产生一个疑问:保存一次文件,究竟是哪一层把更新送到了手机?先把这条链路拆开,后面的平台差异才容易定位。
| 名称 | 做了什么 | JavaScript 运行时和页面状态 |
|---|---|---|
| 完整重载(Full Reload) | 重新取得入口 Bundle,重新启动 JS 应用 | JS 内存、组件本地状态通常都会丢失 |
| 模块热更新(HMR,Hot Module Replacement) | Metro 只推送本次变更相关的模块 | 模块被替换;状态如何处理取决于上层运行时 |
| Fast Refresh | 在 HMR 的传输之上,由 React Refresh 判断组件能否原地更新 | 函数组件和 Hooks 的本地状态在安全时可以保留 |
Metro 负责发现文件变化、重新处理模块并把更新送到客户端;React Refresh 负责判断组件能否继续沿用原来的身份;React 再据此决定重渲染还是重新挂载。
这也解释了一个容易误判的问题:关闭 Fast Refresh 后,保存时页面不再自动更新,并不表示 Metro 停止工作。Fast Refresh 是开发客户端中的一层能力,React Native 官方文档说明它默认开启,也可以在开发菜单中切换。
保存一次文件,更新怎样抵达屏幕
先看一条最常见的链路:开发者只修改函数组件的渲染或样式。
保存 Screen.tsx
↓
Metro 监听到文件变化,重新计算受影响的模块图
↓
运行中的 App 通过开发连接接收模块更新
↓
客户端执行变化模块,并交给 React Refresh Runtime
↓
React 判断组件结构是否兼容,重新渲染组件
↓
原生渲染器根据新的 React 树更新 Android View、iOS UIKit 或 ArkUI 对应视图
这里的“增量”也有边界。React Native 官方文档把模块分成三种情况:
- 文件只导出 React 组件时,通常只更新该模块并重渲染组件。
- 文件还导出了非组件值时,导入它的模块也可能重新执行。
- 文件被 React 树之外的模块导入时,Fast Refresh 可能退回完整重载。
第三种情况最容易造成“为什么改个常量就丢状态”。问题的关键在于运行时无法安全地局部替换一段同时被 React 树和普通业务模块共享的模块初始化代码。
Fast Refresh 处理的是 JavaScript 模块和 React 组件。它不会把 Kotlin、Swift、Objective-C、ArkTS 或 C++ 的修改直接发送到运行中的应用里。
一个能观察状态边界的最小例子
下面的组件用一个计数器观察状态是否被保留。点击几次后修改标题并保存,函数组件结构没有变化时,计数通常可以继续保留。
import React, { useEffect, useState } from 'react';
import { Button, Text, View } from 'react-native';
export default function App() {
// 这个状态用于观察 Fast Refresh 是否让组件继续使用原来的实例。
const [count, setCount] = useState(0);
useEffect(() => {
// Fast Refresh 期间,带依赖数组的 Effect 也可能再次执行。
// 因此这里的日志不能单独用来判断应用是否发生了完整重启。
console.log('Effect 已执行');
return () => {
// 重复执行 Effect 前可能先调用清理函数。
// 真实订阅、定时器和事件监听都应在这里释放。
console.log('Effect 已清理');
};
}, []);
return (
<View>
{/* 修改这段文字或样式,用来验证普通组件编辑能否快速反映到屏幕。 */}
<Text>当前计数:{count}</Text>
{/* 点击按钮改变状态;保存前后的数值用于判断是否发生了重新挂载。 */}
<Button
title="增加计数"
onPress={() => {
setCount(value => value + 1);
}}
/>
</View>
);
}
预期观察结果是:
- 修改 JSX 或样式后,页面很快更新;
- 组件结构和 Hook 调用顺序稳定时,计数通常继续保留;
- Effect 可能重新执行,即使依赖数组是空数组;
- 手动执行完整 JS Reload 后,计数会从初始值重新开始。
哪些情况会让状态重置
下面这个指令可以强制当前文件中的组件每次编辑都重新挂载:
// 这条指令要求 Fast Refresh 每次编辑都重新挂载当前文件中的组件。
// 它适合调试只在首次挂载时执行的动画和初始化逻辑。
// @refresh reset
状态重置还可能来自这些情况:
- 当前文件同时导出了非组件内容;
- 文件被 React 树之外的模块引用;
- 使用了类组件;
- 修改了 Hook 的调用顺序;
- 修改了 Hook 的参数或组件结构;
- 使用某些高阶组件包装后,组件身份无法继续匹配;
- 手动触发了 Reload JS;
- 鸿蒙适配层清理并重新创建了 RNInstance。
共享配置可以拆到独立模块中,让组件文件保持稳定的导出边界:
// 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>
);
}
还有一个容易让副作用测试失真的细节:Fast Refresh 期间,useEffect、useMemo、useCallback 可能重新执行,依赖数组在这次更新中不会完全按照普通运行时规则处理。因此 Effect 必须能够承受重复执行。
useEffect(() => {
// 每次建立订阅前先记录来源;保存触发的刷新同样可能执行这段逻辑。
const subscription = orderStore.subscribe(refreshOrder);
return () => {
// 清理函数保证重复执行 Effect 时不会留下多个订阅。
subscription.unsubscribe();
};
}, []);
Android:差异集中在设备连接和开发入口
如果 Android 和 iOS 共享 JavaScript 刷新机制,为什么真机连接和开发菜单的操作看起来不一样?差异主要发生在 React Native 运行时之外:设备如何访问 Metro,以及平台如何启动和重建原生工程。
Android 的 JavaScript 刷新算法和 iOS 共享同一套 React Native 刷新模型。开发时最常见的问题发生在真机能否访问开发机上的 Metro。
Android 真机通过 USB 调试时,可以把设备的 8081 端口转发到开发机:
# 将 Android 设备对 localhost:8081 的请求转发到开发机的 Metro 端口。
# 执行前先确认 Metro 正在运行,执行后用开发菜单的 Reload 验证连接。
adb reverse tcp:8081 tcp:8081
应用内的 localhost 指向手机本身,无法直接代表开发机。adb reverse 建立了一条反向通道:
Android 应用的 localhost:8081
↓
ADB 反向转发
↓
开发机的 localhost:8081
↓
Metro
可以使用下面的命令查看当前转发规则:
# 查看当前 Android 设备的端口转发规则。
adb reverse --list
如果输出中包含下面的内容,说明当前设备已经存在 Metro 的 8081 反向转发:
tcp:8081 tcp:8081
Android 模拟器也可能通过专用主机地址访问开发机,因此不一定依赖 adb reverse。实际项目应以设备当前的 Debug Server 地址和端口转发状态为准。
开发菜单常用入口是 Android 模拟器的 Cmd + M 或 Ctrl + M,实体机可以摇动设备;也可以使用:
# Android 的系统按键事件可以打开 React Native Dev Menu。
adb shell input keyevent 82
菜单中应确认 Enable Fast Refresh 已开启,再修改上面的 Text 验证。修改 Kotlin、Java、C++、AndroidManifest.xml、Gradle 配置或已经编译的原生库后,需要重新构建并安装 App。
iOS:刷新链路相同,连接方式没有 ADB 这一层
iOS 使用相同的 Metro 与 React Refresh JavaScript 链路,保存函数组件得到的体验通常和 Android 接近。
iOS 模拟器运行在 macOS 环境中,通常可以直接访问本机 Metro。iOS 真机则需要通过局域网访问开发机,或者使用团队已经配置好的调试连接。
模拟器和真机都可以通过开发菜单启用 Fast Refresh。当前 React Native 官方调试文档列出的 iOS Simulator 快捷键是 Ctrl + Cmd + Z,实体设备可以摇动设备打开开发菜单。
iOS 原生工程发生下面这些变化时,需要重新通过 Xcode 构建:
- Swift、Objective-C 或 Objective-C++ 文件;
- Info.plist;
- Podfile 或 CocoaPods 依赖;
- 原生模块注册;
- 签名和能力配置;
- 原生资源和构建脚本。
修改 StyleSheet、组件布局或 JavaScript 事件处理函数,仍然属于 JavaScript 层变化,不需要因为平台是 iOS 就额外执行一次 Xcode 构建。
鸿蒙:RNOH 增加了一个适配层
鸿蒙项目既然也使用 Metro,为什么保存代码后的表现仍可能和 Android、iOS 不同?关键在于 Metro 只负责 JavaScript 更新,RNOH 还要负责把更新接入 OpenHarmony 的原生实例和 ArkUI 渲染链路。
本文所说的“鸿蒙”,具体指基于 OpenHarmony 的 React Native for OpenHarmony,也就是 RNOH。它和 Android、iOS 一样使用 React Native 的 JavaScript 编程模型,但底层需要通过 RNOH 适配 React Native 和 ArkUI。
RNOH 的架构可以简化为:
React Native 业务代码
↓
Metro
↓
JavaScript 运行时
↓
JSI 与 React Common
↓
RNOH 适配层
↓
Fabric 与 TurboModule
↓
ArkUI 与 OpenHarmony 系统能力
RNOH 文档中涉及几个和热刷新直接相关的组件:
- createHarmonyMetroConfig:让 Metro 使用鸿蒙平台相关配置;
- MetroJSBundleProvider:加载 Metro 服务提供的 JS Bundle;
- RNApp:RNOH 提供的应用入口组件;
- RNSurface:更底层的 RN Surface 接入方式;
- RNInstance:承载 React Native JavaScript 运行时和相关原生状态的实例。
RNOH 的 Metro 配置
RNOH 项目的 Metro 配置通常需要合并 React Native 默认配置和鸿蒙配置:
// metro.config.js
const {
getDefaultConfig,
mergeConfig,
} = require('@react-native/metro-config');
const {
createHarmonyMetroConfig,
} = require('@react-native-oh/react-native-harmony/metro.config');
// 这个配置对象用于保留 React Native 默认配置,
// 同时补充鸿蒙平台的模块解析和资源处理规则。
const config = {
transformer: {
getTransformOptions: async () => ({
transform: {
// 当前示例关闭实验性的 import 支持,具体取值以目标 RNOH 版本为准。
experimentalImportSupport: false,
// 开启内联 require,减少部分模块首次加载时的开销。
inlineRequires: true,
},
}),
},
};
// 包名可能随 RNOH 版本变化,实际项目应以对应版本文档为准。
module.exports = mergeConfig(
getDefaultConfig(__dirname),
createHarmonyMetroConfig({
reactNativeHarmonyPackageName:
'@react-native-oh/react-native-harmony',
}),
config,
);
如果项目没有配置鸿蒙 Metro 扩展,常见结果包括:
- Metro 能启动,但找不到鸿蒙平台实现;
- 组件解析到了 Android 或 iOS 版本;
- 设备加载 Bundle 失败;
- 修改文件后设备没有收到预期更新;
- 依赖包使用了未适配 OpenHarmony 的原生实现。
使用 RNApp 加载 Metro Bundle
RNOH 文档给出的 RNApp 接入方式可以简化为:
// index.ets
import {
MetroJSBundleProvider,
RNApp,
} from '@react-native-oh/react-native-harmony';
@Entry
@Component
struct EntryPage {
build() {
// MetroJSBundleProvider 负责从开发服务器获取 JS Bundle。
// 保存 React Native 业务代码后,应用才有机会收到更新。
RNApp({
jsBundleProvider: new MetroJSBundleProvider(),
});
}
}
设备和开发机之间可以通过端口转发连接 Metro:
# 将鸿蒙设备的 8081 端口映射到开发机上的 Metro 服务。
hdc rport tcp:8081 tcp:8081
# 在 React Native 工程目录启动 Metro。
npm run start
RNOH 文档还提到,可以通过 MetroJSBundleProvider.fromServerIp 访问局域网内的 Metro 服务。使用这种方式时,设备和开发机需要处于可互相访问的网络环境。
RNSurface 的状态差异
RNOH 的 RNSurface 接入方式需要开发者自行管理 RNInstance。官方调试文档给出的热加载处理思路包括监听 RELOAD 事件,然后清理并重新初始化实例:
// 下面是 RNSurface 场景的简化示例。
// 真实项目还需要补充 RNInstance、Surface 和资源的完整生命周期管理。
this.ctx.devToolsController.eventEmitter.subscribe(
'RELOAD',
async () => {
// 先释放旧实例关联的资源,避免旧的 JS 运行时继续持有页面状态。
this.cleanUp();
// 重新创建 RNInstance 并加载最新的 JavaScript Bundle。
// 由于实例被重新创建,之前的 React 局部状态不应假定能够保留。
this.init();
},
);
这段代码带来一个关键结论:RNOH 文档支持通过 Metro 自动更新应用,但局部状态是否保留,取决于具体的 RNOH 版本、入口组件和 RNInstance 生命周期。
Android 和 iOS 上,Fast Refresh 通常直接在现有 React Native 实例中应用模块更新。RNSurface 如果在刷新时销毁并创建新的 RNInstance,刷新行为就更接近一次完整的 JS 重载。
三个平台的核心区别
| 层次 | Android | iOS | 鸿蒙 RNOH |
|---|---|---|---|
| React 组件刷新 | 使用 Fast Refresh | 使用 Fast Refresh | 由 RNOH 适配 Metro 和 React Native 刷新链路 |
| JS Bundle 来源 | Metro | Metro | Metro + MetroJSBundleProvider |
| 原生渲染层 | Android 原生组件和 RN 渲染实现 | iOS 原生组件和 RN 渲染实现 | RNOH 适配层 + ArkUI |
| 开发连接 | adb reverse 或局域网 | 通常使用同一 Wi-Fi | hdc rport 或设备访问开发机 IP |
| 入口生命周期 | React Native 实例通常由标准工程管理 | React Native 实例通常由标准工程管理 | RNApp 和 RNSurface 的行为可能不同 |
| 局部状态保留 | 由 Fast Refresh 边界决定 | 由 Fast Refresh 边界决定 | 还取决于 RNInstance 是否被重建 |
| 原生修改 | 重新构建 Android 工程 | 重新构建 iOS 工程 | 重新构建 DevEco/Harmony 工程 |
| 生态兼容性 | 主要看 Android 和 RN 版本 | 主要看 iOS、Pod 和 RN 版本 | 还要看 RNOH 版本及第三方库适配情况 |
从刷新速度上直接比较“哪个平台更快”并不严谨。实际速度还会受到 Bundle 大小、Metro 缓存、设备性能、网络连接、JavaScript 引擎和刷新范围影响。
哪些修改需要重新构建
另一个实际问题是:什么情况下应该继续排查热刷新,什么情况下应当立即重新构建原生工程?可以先按文件所在层次判断,再执行对应平台的验证动作。
| 修改内容 | Fast Refresh 是否足够 | 推荐动作 |
|---|---|---|
| JSX、TSX、JavaScript 业务逻辑 | 通常足够 | 保存文件,观察页面更新 |
| React Native 样式和事件处理 | 通常足够 | 保存文件 |
| useState、useEffect 等 Hook 逻辑 | 通常足够 | 保存文件并检查状态是否保留 |
| 导出结构或入口模块 | 可能退回整包 JS Reload | 手动 Reload,必要时重启 Metro |
| Android Kotlin、Java、C++ | 不足 | 重新构建 Android 应用 |
| iOS Swift、Objective-C、Pod | 不足 | 重新通过 Xcode 构建 |
| 鸿蒙 ArkTS、C++、ArkUI 组件 | 不足 | 重新通过 DevEco Studio 构建 |
| Manifest、Info.plist、鸿蒙权限 | 不足 | 重新构建并安装 |
| Gradle、Podfile、hvigor 和原生依赖 | 不足 | 重新安装依赖并构建 |
| Release 包 | 不支持开发调试能力 | 使用 Debug 包验证 |
一套可执行的排查顺序
保存文件后完全没有更新
先检查 JavaScript 开发链路:
- Metro 是否正在运行;
- 设备是否连接到了当前 Metro 服务;
- Android 是否执行了 adb reverse;
- 鸿蒙是否执行了 hdc rport;
- iOS 真机和开发机是否处于同一网络;
- 应用是否为 Debug 构建;
- Dev Menu 中是否启用了 Fast Refresh;
- Metro 配置是否包含目标平台的扩展。
页面更新了,但状态每次都重置
检查 React 层和宿主层:
- 当前文件是否导出了非组件内容;
- 是否修改了 Hook 调用顺序;
- 是否使用了类组件;
- 是否添加了 @refresh reset;
- 是否手动执行了 Reload JS;
- RNOH 的 RNSurface 是否清理并重新创建了 RNInstance。
JS 修改可以更新,原生组件修改没有变化
这种现象通常说明 Metro 工作正常,变化发生在原生构建边界之外。需要重新确认:
- 修改的文件是否属于 Android、iOS 或鸿蒙原生工程;
- 原生依赖是否重新安装;
- 构建产物是否重新安装到设备;
- 当前运行的应用是否确实来自最新构建。
Android 和 iOS 正常,鸿蒙没有更新
重点检查 RNOH 配置:
- metro.config.js 是否调用了 createHarmonyMetroConfig;
- react-native-harmony 包版本是否和 RNOH 工程匹配;
- 应用是否通过 MetroJSBundleProvider 加载开发 Bundle;
- RNApp 和 RNSurface 使用的是哪条接入路径;
- RNOH 目标版本是否支持当前 React Native 上游版本;
- 使用的第三方库是否提供 OpenHarmony 原生实现。
RNOH 社区路线图显示,RNOH 按选定的 React Native 上游版本进行适配,版本支持并不等同于 Android/iOS 上游版本的自动继承。升级 React Native 时,应同时核对 RNOH 的版本说明、支持矩阵和第三方库适配情况。
结论
React Native 的热重载体验来自一条协作链:Metro 发现并传输模块更新,HMR 客户端接收更新,React Refresh 决定组件能否在保留状态的前提下重新渲染。Android 与 iOS 的核心机制相同,差异主要出现在开发菜单和真机到 Metro 的网络通路。
鸿蒙上的 RNOH 在 JavaScript 层复用了同样的 HMR 与 React Refresh 代码,因此函数组件的调试方式可以借鉴 Android/iOS;原生工具链和适配层能力必须跟随具体 RNOH 版本验证。下一次遇到“保存后不对”时,先判断它属于连接失败、模块边界导致的重新挂载,还是原生二进制未重建,再选择对应的调试动作。
参考资料
本文参考 React Native 0.86 文档与 RNOH 社区文档,资料核对于 2026-07-31。
评论
使用 GitHub 登录参与讨论。