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 官方文档把模块分成三种情况:

  1. 文件只导出 React 组件时,通常只更新该模块并重渲染组件。
  2. 文件还导出了非组件值时,导入它的模块也可能重新执行。
  3. 文件被 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 重载。

三个平台的核心区别

层次AndroidiOS鸿蒙 RNOH
React 组件刷新使用 Fast Refresh使用 Fast Refresh由 RNOH 适配 Metro 和 React Native 刷新链路
JS Bundle 来源MetroMetroMetro + MetroJSBundleProvider
原生渲染层Android 原生组件和 RN 渲染实现iOS 原生组件和 RN 渲染实现RNOH 适配层 + ArkUI
开发连接adb reverse 或局域网通常使用同一 Wi-Fihdc 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 开发链路:

  1. Metro 是否正在运行;
  2. 设备是否连接到了当前 Metro 服务;
  3. Android 是否执行了 adb reverse;
  4. 鸿蒙是否执行了 hdc rport;
  5. iOS 真机和开发机是否处于同一网络;
  6. 应用是否为 Debug 构建;
  7. Dev Menu 中是否启用了 Fast Refresh;
  8. Metro 配置是否包含目标平台的扩展。

页面更新了,但状态每次都重置

检查 React 层和宿主层:

  1. 当前文件是否导出了非组件内容;
  2. 是否修改了 Hook 调用顺序;
  3. 是否使用了类组件;
  4. 是否添加了 @refresh reset;
  5. 是否手动执行了 Reload JS;
  6. 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 登录参与讨论。