我第一次被问到“能不能在 React Native 里做一个鸿蒙组件”时,第一反应是去翻 React Native 官方文档,找找有没有 HarmonyOS 相关的 target。翻了半天,官方支持列表里压根没有鸿蒙。后来才搞清楚,要想在鸿蒙设备上跑 RN 应用、开发鸿蒙组件,得靠社区的开源适配方案,而且从版本选择到工程目录,每一步都有坑。这篇文章就是我从零跑通“React Native + 鸿蒙组件开发”的完整记录:先讲明白鸿蒙开发的基础认知,再说如何在 React Native 项目中集成鸿蒙应用,最后用可复现的步骤写一个能和 JS 双向通信的鸿蒙原生组件,顺带把白屏排查和无设备调试的经验也一并交代了。适合已经熟悉 React Native、但第一次接触鸿蒙开发的工程师参考。
1. 为什么React Native不能直接跑在鸿蒙上:先搞懂鸿蒙的“运行生态”
很多 RN 工程师第一次接触鸿蒙时,会下意识觉得“鸿蒙不就是 Android 换了个壳吗”。这个认知在 HarmonyOS 4 及以前还勉强能成立,因为那时候还有 AOSP 兼容层。但到了 HarmonyOS NEXT,系统不再兼容 Android APK,应用格式、UI 框架、权限模型、启动方式全部换了一套,React Native 官方自然没有现成的鸿蒙支持。
1.1 从Android思维切换到鸿蒙思维:Ability、ArkTS与ArkUI
鸿蒙应用的基础单元是 Ability,而不是 Android 的 Activity 或 iOS 的 ViewController。常见的 UIAbility 负责带有页面的交互任务,一个应用可以有一个或多个 UIAbility。开发语言上,推荐使用 ArkTS,它基本是 TypeScript 的超集,但加了很多 ArkUI 特有的声明式 UI 扩展,比如 @Component、@State、@Entry 这些装饰器。初次上手的感觉很像 SwiftUI 或 Jetpack Compose,但又不一样。
UI 部分由 ArkUI 负责。ArkUI 的声明式写法大概长这样:
typescript复制@Entry
@Component
struct HelloPage {
@State message: string = 'Hello HarmonyOS'
build() {
Column() {
Text(this.message)
.fontSize(30)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.height('100%')
}
}
这套体系的运行方式决定了应用打包产物是 HAP、HSP、HAR,而不是 APK。鸿蒙的应用发布、签名、权限声明也都有自己的一套规则。想开发鸿蒙组件,第一件事不是急着写代码,而是把这些基础概念过一遍,否则后面看文档都会卡壳。
1.2 RN在鸿蒙上的落地路径:社区适配版与官方版的差异
React Native 的核心是一个 C++ 引擎,负责执行 JS、管理组件树、调度渲染指令,但它不直接画界面。界面渲染需要平台层实现,Android 上有 Fabric,iOS 上有 RCTSurface,而鸿蒙不在官方平台列表里,所以社区和厂商合力做了适配层:通过 NAPI 把 React Native 的 C++ 层桥接到鸿蒙的 ArkUI 组件树上。
这意味着你不可能用 npm install react-native 的官方包直接在鸿蒙上跑。你需要安装鸿蒙适配包,例如 react-native-harmony 或 OpenHarmony SIG 维护的 @react-native-ohos/react-native。不同包对应不同 RN 版本,接口命名也有差异,但整体逻辑一致。
我建议把这件事理解成:“React Native 在鸿蒙上的落地 = RN 官方 JS 层 + RN C++ 核心 + 鸿蒙 ArkUI 平台适配层”。你在 RN 里写 JS、写组件,原生鸿蒙侧负责把组件实例化成 ArkUI 节点,并把触摸事件、生命周期回调传回 JS 层。开发“鸿组件”这件事,本质上就是在这个适配层之上,把自己的 ArkTS 自定义组件暴露给 RN 的 JS 侧。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 搭一套能跑通的RN+鸿蒙工程:版本匹配与目录结构是关键
这个环节是最大的坑区。很多人在跑 Hello World 时挂掉,不是因为代码写错,而是 DevEco Studio、鸿蒙 SDK、RN 版本、适配包版本四者不匹配。版本一错,编译期就开始报各种莫名其妙的错。
2.1 版本匹配决定成败:DevEco Studio、RN、鸿蒙SDK对照表
以我用的比较稳定的组合为例,列个表给你参考:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| DevEco Studio | 4.0 Release 或 5.0 | 5.0 对应 HarmonyOS NEXT,API 版本更高 |
| HarmonyOS SDK | API 10 或 API 12/13 | API 10 兼容性更稳,API 12+ 才能用新特性 |
| React Native | 0.72.x 或 0.75.x | 0.72 是社区适配最成熟的版本 |
| 鸿蒙适配包 | @react-native-ohos/react-native 对应版本 | 必须和 RN 主版本一致 |
| Node.js | 18.x | 16 偏低,20 以上可能触发脚本兼容问题 |
| ohpm / hvigor | DevEco 自带 | 注意版本不要手动乱升级 |
不要只看 RN 版本,还要看适配包发布的说明。比如某些适配包版本要求 ArkTS 编译器不低于 5.0,你的 DevEco 太老就会在编译阶段报错。我踩过一次坑:RN 0.72 + DevEco 3.1,编译时直接报 ArkTS 语法解析失败,后来换了 DevEco 4.0 才正常。
2.2 工程目录结构与hvigor配置的常见姿势
RN 鸿蒙工程的目录比普通 RN 项目多了一个 harmony 目录,这个目录就是 DevEco Studio 的工程目录,负责承载鸿蒙原生代码和打包配置。一个典型的结构如下:
code复制RNHarmonyDemo/
├─ index.js
├─ package.json
├─ src/
│ └─ App.tsx
├─ harmony/
│ └─ entry/
│ └─ src/main/
│ ├─ ets/
│ │ ├─ entryability/
│ │ │ └─ EntryAbility.ets
│ │ └─ pages/
│ │ └─ Index.ets
│ ├─ module.json5
│ └─ resources/
初始化时通常会执行类似这样的命令:
bash复制npx react-native init RNHarmonyDemo --version 0.72.7
cd RNHarmonyDemo
# 根据你选择的适配包,按文档安装对应依赖
npm install react-native-harmony
安装完依赖后,使用适配包提供的初始化命令把 harmony 目录生成出来。如果初始化命令不太顺,也可以直接从社区模板工程里拷贝 harmony 目录,再改包名和应用名。这种方式虽然糙,但胜在不会漏掉一些隐藏配置。
打开 harmony 目录后,用 DevEco Studio 打开工程,等待同步完成。这里要特别注意 oh-package.json5 文件中的依赖,确保它引用了你在 npm 里安装的适配包,并且版本一致。
2.3 初始化工程时最容易出现的三个问题
第一个是 Node 版本问题。hvigor 和 ohpm 的 Node 脚本有时候对 Node 20+ 的支持并不好,构建过程中会出现进程崩溃或权限错误。我建议统一用 Node 18 LTS。
第二个是 harmony 目录与 RN 工程关联不上的问题。简单说,鸿蒙工程需要知道 RN 的 JS bundle 从哪里来。默认配置下,鸿蒙端会从 Metro 的 http://localhost:8081/index.bundle?platform=harmony 加载 bundle。如果你的鸿蒙工程构建成功后页面一片白,请先检查网络端口映射,而不是急着找代码问题。后面调试章节会详细展开。
第三个是构建产物路径问题。RN 的 JS 代码如果被打进 hap 包里,需要配置 assets 路径;如果走 Metro 调试模式,又需要把网络地址配置好。两种模式不要混,开发期用 Metro,发布前把 bundle 打进 assets。
3. 动手开发第一个鸿蒙原生组件:从ArkTS组件到RN可见组件
当你把工程跑起来之后,就可以开始干正事了:开发鸿组件。这里用我开发的一个“跑马灯文本”组件来走完整流程,组件名定为 MyFancyLabel。它的功能很简单:接收 text 和 color 两个属性,在鸿蒙原生侧渲染一个带有颜色样式的文本,并且支持点击事件回调给 JS。
3.1 理解RN原生组件的鸿蒙侧映射机制
在 Android 的 React Native 世界里,自定义原生 View 要继承 SimpleViewManager,然后重写 createViewInstance。鸿蒙侧的逻辑类似,只不过基类和生命周期钩子换成了 RNOH 框架提供的那一套。一个原生组件至少要由三部分组成:
- ArkTS 组件类:真正渲染在页面上的 UI,继承或实现框架要求的 Component 基类。
- Descriptor 工厂:负责接收 RN 侧传来的组件标签名和属性,实例化出对应的 ArkTS 组件。
- JS 侧声明:告诉 React Native 某个标签对应原生组件名,并声明 Props 和事件类型。
这三部分缺一不可。刚上手的人最容易漏掉 Descriptor 工厂,导致 JS 侧组件写好了,但原生侧创建实例失败。
3.2 ArkTS侧:用声明式UI写一个自定义组件
下面是我在 harmony/entry/src/main/ets/components/MyFancyLabel.ets 里写的内容。注意不同适配包版本基类名字可能有差异,我在代码里以社区常见写法为例,大家要看自己版本的 API:
typescript复制import { ComponentBase, ComponentContext } from 'react-native-harmony'
export class MyFancyLabel extends ComponentBase {
private text: string = ''
private color: string = '#000000'
constructor(ctx: ComponentContext) {
super(ctx)
}
// RN 侧初始化 Props 时会走到这里
public initialProps(props: Record<string, any>) {
super.initialProps(props)
this.text = props.text || ''
this.color = props.color || '#000000'
}
// RN 侧调用命令时触发,例如刷新文本内容
public onReceiveCommand(command: string, args: any[]) {
if (command === 'updateText') {
this.text = args[0] || ''
}
}
build() {
Text(this.text)
.fontSize(18)
.fontColor(this.color)
.onClick(() => {
// 触发事件给 JS 侧
this.emitCustomEvent('onPress', { value: this.text })
})
}
}
这个组件在 ArkUI 里就是一个 Text,运行时字体、颜色都从 RN 侧传入。initialProps 相当于 Android 原生控件里的 setXxx 属性设置入口,React Native 在创建组件时会一次性把初始属性传进来。onReceiveCommand 则应对从 JS 侧主动触发的命令。
3.3 Descriptor注册:让JS侧能够实例化原生视图
有了组件类还不够,RNOH 需要一个工厂函数来创建该组件的实例。通常我会新建一个 MyFancyLabelDescriptor.ts 文件:
typescript复制import { Descriptor } from 'react-native-harmony'
import { MyFancyLabel } from './MyFancyLabel.ets'
export function createMyFancyLabelDescriptor(ctx: any) {
return new Descriptor(
MyFancyLabel,
'MyFancyLabel',
ctx
)
}
然后在应用包的注册入口,把这个工厂函数加进 packages 列表或对应的包注册表里。这一步的具体写法每个版本差异很大,但核心思想是一样的:让 RNOH 在遇到名为 MyFancyLabel 的组件时,调用这个工厂来创建原生实例。
注册完之后,一定要重新构建鸿蒙应用。很多组件没有被实例化的问题,都是因为改了 ArkTS 代码但没有重启构建。先别急着写 JS 侧,确认原生侧能编译通过再继续。
3.4 RN侧声明:codegen与requireNativeComponent的取舍
JS 侧最朴素的做法是用 requireNativeComponent 直接引入原生组件:
javascript复制import { requireNativeComponent } from 'react-native'
const MyFancyLabel = requireNativeComponent('MyFancyLabel')
export default MyFancyLabel
这个写法对应旧架构,简单直接,适合验证链路是否通。但如果你的项目已经启用了新架构(Fabric/TurboModule),我更推荐用 codegen 的方式来声明。在 package.json 里配置好 codegenConfig,然后生成类型文件:
json复制{
"codegenConfig": {
"name": "RNHarmonySpecs",
"type": "components",
"jsSrcsDir": "./src"
}
}
然后在 src 下写一个组件描述文件:
javascript复制import type { HostComponent, ViewProps } from 'react-native';
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
interface NativeProps extends ViewProps {
text?: string;
color?: string;
}
export default codegenNativeComponent<NativeProps>('MyFancyLabel') as HostComponent<NativeProps>;
codegen 的好处是自动生成类型,编译期就能发现 Props 写错的问题。我在实际项目里发现,新架构下 requireNativeComponent 虽然在鸿蒙侧也能用,但遇到事件回调时类型收敛能力很弱,团队人多的时候容易把事件载荷写飞。所以如果你不赶时间,建议直接上 codegen。
4. 组件通信设计:Props下发、事件回调与状态同步
原生组件不是孤岛,它必须和 RN 侧的数据流打通。鸿组件开发里最容易出问题的也是这一块:Props 更新不及时、事件收不到、命令调用失败。我拆开来说。
4.1 数据从JS流向ArkTS:属性绑定与刷新时机
RN 侧首次渲染时,Props 会通过 initialProps 传入 ArkTS。之后 JS 侧更新 Props,会走更新流程。很多人在更新流程里踩坑,是因为不清楚框架底层是“全量更新还是增量更新”。不同 RNOH 版本行为不一样,但保险的做法是:在更新回调里重新读取所有用到的属性,别指望框架帮你做 diff。
我写过这样的代码:
typescript复制public initialProps(props: Record<string, any>) {
super.initialProps(props)
this.updateFromProps(props)
}
public updateProps(newProps: Record<string, any>) {
super.updateProps(newProps)
this.updateFromProps(newProps)
}
private updateFromProps(props: Record<string, any>) {
const newText = props.text ?? ''
if (newText !== this.text) {
this.text = newText
}
}
这样能保证首次渲染和后续更新走同一套逻辑,不容易漏掉边界条件。另外要注意,Props 里的值必须是可序列化的。传函数、Date 对象、Map 这类数据到鸿蒙侧,大概率会被转成空对象或直接报错。遇到复杂数据,先在 JS 侧做一次字符串序列化再传。
4.2 数据从ArkTS流向JS:事件回调与自定义事件载荷
ArkTS 侧触发事件时,用到的是 emitCustomEvent。第一个参数是事件名,第二个参数是载荷对象。RN 侧监听时要在组件 Props 里声明 on 开头的事件属性。
例如原生侧:
typescript复制this.emitCustomEvent('onPress', { value: this.text, timestamp: Date.now() })
JS 侧:
jsx复制<MyFancyLabel
text={title}
color="#333333"
onPress={(event) => {
console.log('native event:', event.nativeEvent.value)
}}
/>
这里有个经验:事件载荷不要传大对象,更不要传未经处理的业务数据。鸿组件的事件传递要走跨语言边界,每次调用都有序列化开销。如果某个高频事件每秒触发几十次,建议在原生侧做节流或合并,否则低端机上会出现肉眼可见的卡顿。
4.3 一个完整的登录表单组件案例
为了让通信链路更清楚,我拿登录表单组件举例。假设已有原生登录组件 NativeLoginForm,包含一个输入框和登录按钮,点击登录后将账号密码通过事件传给 JS 侧,JS 侧可以命令它清空输入框。
ArkTS 侧核心逻辑:
typescript复制export class NativeLoginForm extends ComponentBase {
private account: string = ''
private password: string = ''
build() {
Column() {
TextInput({ placeholder: '账号' })
.onChange((value) => this.account = value)
TextInput({ placeholder: '密码' })
.onChange((value) => this.password = value)
Button('登录')
.onClick(() => {
this.emitCustomEvent('onLogin', {
account: this.account,
password: this.password
})
})
}
}
public onReceiveCommand(command: string, args: any[]) {
if (command === 'clear') {
this.account = ''
this.password = ''
}
}
}
JS 侧:
jsx复制<NativeLoginForm
onLogin={(e) => {
const { account, password } = e.nativeEvent
sendLoginRequest(account, password)
}}
/>
这个例子里,输入框的实时内容属于原生组件的内部状态,JS 侧不需要同步过来。只有用户点击登录,才需要把最终结果传给 JS。我见过有人把所有输入状态都同步到 RN 的 state 里,结果每次按键都要走一遍原生到 JS 的通信,性能差距非常明显。设计组件通信协议时,要把“原生内部状态”和“业务共享状态”分开,只同步后者。
5. 调试鸿蒙组件时绕不开的坎:白屏排查与无设备调试
鸿蒙组件开发里最让人头疼的就是白屏。RN 在 Android/iOS 上白屏,通常怀疑 Metro 或 JS 异常;鸿蒙上白屏,多了好几个可疑点:端口映射、hap 包资源、注册表、ArkTS 编译产物。我把自己的一次完整排查链路写出来。
5.1 白屏问题排查链路
有一次我在真机上运行鸿蒙应用,页面白屏,日志也没有明显崩溃。我按以下顺序一步步查,最终定位到是端口映射没做。
第一步,看 Metro 日志。如果鸿蒙端成功发起了 bundle 请求,Metro 终端一般会看到类似 BUNDLE ./index.js 的日志。如果 Metro 一点动静都没有,说明鸿蒙端根本没访问到 Metro,最常见的起因就是端口映射。鸿蒙的 hdc 类似 Android 的 adb,执行:
bash复制hdc rport tcp:8081 tcp:8081
把 PC 的 8081 端口反向转发到鸿蒙设备。没有这一步,设备上的应用访问 localhost:8081 自然找不到 Metro。
第二步,如果 Metro 收到了请求但报错,在 Metro 终端看 JS 侧堆栈。常见的错误是 Unable to resolve module 或 SyntaxError,这类问题好解决。
第三步,如果 Metro 正常但页面还是白,打开 DevEco Studio 的 Log 面板,在过滤器里输入 ReactNativeJS 或 RNOH。如果看到类似 Unable to load script 的日志,说明 bundle 拉取失败;如果看到 component not found,说明某个原生组件没有注册成功。
第四步,检查 AppRegistry.registerComponent 里注册的组件名,和鸿蒙容器启动时传入的 appKey 是否一致。这个不一致不会报红,只会白屏,非常坑。
第五步,清理缓存。鸿蒙侧的构建缓存、Metro 缓存、watchman 状态都可能导致莫名其妙的问题。我自己常用的清缓存命令是:
bash复制watchman watch-del-all
npx react-native start --reset-cache
然后删除鸿蒙工程里的 oh_modules、.hvigor、build 目录,重新构建。很多时候这一步能解决“代码改了但启动后还是旧逻辑”的问题。
5.2 没有真机和模拟器时还能怎么办
很多 RN 工程师问过这个问题:如果手头没有鸿蒙手机,也没有可用的模拟器,能否用其他方式调试鸿组件?说句实话:完整跑通 RN + 鸿蒙应用,真机或模拟器几乎是必须的,因为 RNOH 依赖原生 C++ 动态库,不是纯 JS 项目。
但有一些替代思路可以帮你把部分工作前置:
第一,用 DevEco Studio 的 Previewer 预览纯 ArkUI 组件。你把鸿蒙组件单独抽成一个独立的 @Preview 页面,不依赖 RN 宿主,就可以在 Previewer 里验证样式和基本交互。Previewer 对 RN 容器的支持很弱,但验证自定义组件本身的布局逻辑是没问题的。
第二,给组件加一个“预览入口”。举个例子,在 MyFancyLabel.ets 文件底部写一个本地预览组件:
typescript复制@Preview
@Component
struct MyFancyLabelPreview {
build() {
Column() {
MyFancyLabel({ initialProps: { text: '预览文本', color: '#1677FF' } })
}
.width('100%')
.height('100%')
}
}
注意这里只是为了在 Previewer 里看到效果,实际集成时不要依赖这个入口。这个方法特别适合在没有设备的前期阶段,用来先写好、先调好 UI,再把真机调试时间压缩到后期。
第三,如果你的公司有云真机或远程设备平台,可以通过远程连接来跑构建产物。这种方式比没有设备强,但延迟高,不适合高频调 UI。我在做性能优化时会特别依赖真机,因为模拟器跑不出真实的帧率。
6. 把鸿蒙特色能力封装进RN:分布式、导航动画与性能优化
鸿蒙组件的价值不只是把 UI 控件包一层给 RN 用,更多时候是想让 RN 应用也能调用鸿蒙的系统能力和分布式能力。这也是很多项目愿意在 React Native 里开发鸿组件的原因:RN 负责业务快速迭代,鸿蒙侧负责系统级能力。
6.1 封装系统能力:以分布式数据为例
鸿蒙的分布式数据能力允许应用在多设备之间同步数据。要在 RN 里用这个能力,我建议封装成一个 TurboModule 或普通原生 Module,暴露 Promise 风格方法给 JS,而不是直接暴露底层 API。
ArkTS 侧大致思路如下:
typescript复制import distributedData from '@ohos.data.distributedData'
export class DistributedDataModule {
async put(key: string, value: string): Promise<boolean> {
// 初始化分布式数据库
// 写入 key-value
return true
}
async get(key: string): Promise<string> {
// 从分布式数据库读取
return ''
}
}
JS 侧通过 TurboModuleRegistry.get 拿到模块,然后调用:
javascript复制import { TurboModuleRegistry } from 'react-native'
const DistributedData = TurboModuleRegistry.get('DistributedData')
await DistributedData.put('lastLoginTime', Date.now().toString())
const value = await DistributedData.get('lastLoginTime')
封装时要注意错误处理。鸿蒙侧的方法如果抛异常,尽量转成带 code 和 message 的 Error 传给 JS,不然 JS 侧拿到一堆底层错误码,排查会很痛苦。
6.2 全局导航与自定义动画的实践经验
React Native 社区常用的导航库在鸿蒙上并不都能无缝运行。页面导航如果完全由 RN 控制,会涉及原生导航容器的生命周期同步,复杂度高。如果你的项目里既有 RN 页面,又有鸿蒙原生页面,我建议把导航容器的主导权交给鸿蒙侧,RN 内只做业务页面,页面切换的原生转场动画由 NavDestination 的 transition 配置控制。
动画方面,我在真机上实测过几种效果:位移、透明度、缩放类动画在 ArkUI 里跑得都很稳;但高斯模糊、大面积阴影、复杂的 Blur 效果在低端机上会明显掉帧。所以在设计跨端动画时,尽量把特效控制在原生能力较强的范围内,RN 侧的复杂动画如果能交给 ArkUI 的隐式动画,比用 RN 的 Animated 库在鸿蒙上跑要稳。
6.3 性能调优的几个方向
组件跑通只是第一步,性能和稳定性才是线上项目真正要面对的。我在鸿组件开发中总结了几条优化思路:
第一,减少 ArkTS 和 JS 之间的属性同步频率。RN 侧每次 setState 导致 Props 变化,都会触发一次跨语言通信。如果可以批量更新,尽量用一次 Props 更新传递所有变化字段,而不是分成多次。
第二,长列表慎用纯 JS 渲染。鸿蒙侧的 List、Grid 组件性能很好,但如果你的数据量很大,建议把整个列表区域封装成原生组件,在 ArkTS 侧做数据渲染,只把点击事件回传给 JS。这样能大幅减少 JS 与原生侧频繁交互带来的开销。
第三,注意 ArkTS 的 build() 方法里不要做复杂计算和频繁创建匿名对象。我在写自定义组件时习惯把常量提前定义,把状态更新逻辑抽到独立方法里,避免每次组件刷新都重新分配对象。
第四,冷启动阶段减少同步业务逻辑。RN 应用在鸿蒙上的冷启动本来就比原生应用重一些,如果启动时同时拉取分布式数据和远程配置,白屏时间会拉长。建议把首屏需要的数据放进 hap 包内置缓存,或者让页面先渲染,再异步拉取更新。
我在实际开发中的感受是:鸿组件并不神秘,它只是 React Native 原生组件概念在鸿蒙平台上的又一次落地。但鸿蒙的工程体系、ArkTS 语法和调试工具链跟 Android/iOS 差别不小,初期投入成本主要花在版本匹配和链路打通上。等你完整写过一个组件、踩过一次白屏坑,后面的组件就会顺手很多。如果团队刚开始引入这套方案,别急着把分布式、动画这些重能力全部包进去,先挑一个业务痛点组件跑通全链路,再逐步扩展。
