
开篇先聊点实在的我在实际项目中用 React Native 做鸿蒙HarmonyOS端的组件开发时踩过不少坑也积累了一些经验。这篇文章就是想把“在 RN 工程里集成鸿蒙原生组件”这件事从原理到实操完整梳理一遍。先说清楚这篇文章是干什么的。如果你所在团队已经有一套 React Native 代码库老板突然说“要出鸿蒙版”你大概率会遇到一个核心问题——RN 本身跑在 JS 引擎上但鸿蒙原生 UIArkUI是由 ArkTS 和声明式语法驱动的两者之间有一道天然的桥。这道桥怎么搭、原生组件怎么封装成 RN 组件、事件怎么来回传就是本文要解决的问题。适合正在做 RN 转鸿蒙的客户端开发、以及刚接触鸿蒙生态想快速上手跨端方案的工程师。我的经验是与其把 RN 当成“另一套前端框架”硬套不如先把鸿蒙原生侧的逻辑想清楚再回来设计 JS 侧的接口。顺序反了后面全是坑。1. 整体思路拆解RN 与鸿蒙怎么“对话”1.1 不是一个 RN是“鸿蒙化”的 RN先纠正一个常见的认知错误HarmonyOS 上跑 React Native并不是把 Android/iOS 的 RN 代码原封不动拿过来编译就行。RN 的底层是 C 实现的 Fabric 渲染器和 JSIJavaScript Interface桥接层而鸿蒙的系统 API 是 ArkTS/ArkUI 提供的。要让 RN 在鸿蒙上跑起来需要一套专门适配的 RN 分支目前社区主流方案是react-native-harmonyOpenHarmony 官方维护以及各大厂的内部 fork。所以第一步要明确你写的 JS 代码依然是标准 RN 语法但原生侧绑定的不是 Android 的ReactPackage也不是 iOS 的RCTBridgeModule而是鸿蒙的TurboModule和自定义 UI 组件绑定器。你的工程里会多出一个harmony目录里面是 DevEco Studio 工程用来承载鸿蒙原生代码。1.2 组件封装的本质把 ArkUI 组件“包一层”给 JS 用如果你写过 RN 的原生模块应该熟悉这个套路JS 调方法、原生干活、回调返回结果。鸿蒙这边也是同一套思维只不过底层实现换了。我们要做的“鸿组件”开发本质上是两件事逻辑型模块非 UI比如调用系统能力、读取设备信息、计算签名等通过鸿蒙侧的napi接口Node-API对标 Node.js 原生模块导出函数JS 侧直接引用。UI 型组件比如一个地图、一个扫码框、一个有特殊动画的卡片需要把 ArkUI 的build()描述的原生 UI 挂载到 RN 的视图层级中。这个稍微复杂需要用到鸿蒙侧的组件节点绑定CustomComponentCreator和事件回调机制。文章后面会详细拆这两种类型。但先记住一句话RN 做的就是“胶水层”的事把你的 ArkTS 组件做成 JS 能调用的对象仅此而已。1.3 为什么不能直接“web 套壳”或“纯 ArkTS 重写”我见过不少团队图省事想用 WebView 把 H5 套进去应付鸿蒙适配或者干脆拉一队人用 ArkTS 把 RN 页面全部重写。套壳的问题很好理解性能和交互手感打折扣而且鸿蒙系统对 WebView 的资源管控越来越严很多系统能力比如统一扫码、系统分享在 WebView 里拿不到。重写的问题则更现实——业务迭代速度完全跟不上RN 团队和鸿蒙团队还得维护两套逻辑。我的建议是把“复用比”作为决策依据。纯 JS 层的业务逻辑状态管理、网络层、路由在鸿蒙上复用率几乎 100%UI 层的通用组件按钮、列表、弹窗建议用 RN 官方社区组件只有强依赖系统 API 或复杂手势的 UI 模块才需要自定义鸿组件。这也是后面所有实操的出发点。2. 环境准备与工程结构先搭好“双厨”环境2.1 工具链版本匹配是关键中的关键说句不好听的鸿蒙开发工具链的版本演进比前端框架还快。你是不是也遇到过DevEco Studio 升级之后原来能跑的工程突然构建报错我在这上面至少耗过两个整天。目前个人验证比较稳的一套组合是组件推荐版本说明DevEco Studio5.0.x 及以上API 12 以上的 SDK 才有稳定的 Node-API 支持HarmonyOS SDKAPI 12 或更高部分 C-API 接口在低版本不存在Node.js18构建鸿蒙 RN 工程需要新版本 node 跑脚本JDK17DevEco 自带也可以但命令行构建必须有 JDKreact-native0.72 或 0.73新版 RN 的架构Fabric对鸿蒙适配还不成熟react-native-harmony需与 RN 版本配套目前 0.72.x 的 preview 版本比较常见提示不要盲目升到 RN 0.74鸿蒙侧的 JSI 适配尚未完全跟上。更稳妥的做法是先查react-native-harmony的 RELEASE 文档确认你想要的 RN 版本有对应的 release 包再决定整体版本组合。2.2 初始化 RN 工程并添加鸿蒙平台实操步骤如下我建议你每一步都做一次构建验证不要等到最后再排错。第一步初始化 RN 工程使用社区 CLI 模板npx react-native-community/cli init HarmonyRNProject --version 0.72.11 cd HarmonyRNProject这里有个细节直接用官方react-native init也可以但模板里没有harmony目录。用社区 CLI 的好处是它默认集成了react-native-harmony的脚手架选项部分版本需要你在初始化时选择 platform或者你能比较方便地手动补。第二步安装 react-native-harmony 依赖npm install react-native-harmony --save安装完之后库会在node_modules/react-native-harmony下带一份鸿蒙侧的模板代码包括harmony目录的初始工程、构建脚本和.d.ts类型声明。第三步生成鸿蒙工程骨架npx react-native-harmony init这个命令会在你的 RN 项目根目录下生成harmony文件夹。打开里面内容典型的目录结构如下harmony/ ├── entry/ │ ├── src/main/ │ │ ├── ets/ │ │ │ ├── entryability/ │ │ │ ├── pages/ │ │ │ └── napi/ │ │ ├── resources/ │ │ └── module.json5 │ ├── build-profile.json5 │ └── oh-package.json5 ├── hvigorfile.ts └── oh-package.json5ets/pages里面是鸿蒙侧的页面入口napi目录则是放原生模块实现的地方。后续我们自己写的鸿组件大部分代码都集中在napi和相关自定义组件目录里。第四步用 DevEco Studio 打开 harmony 目录这里注意一个操作细节不要从 DevEco 直接打开整个 RN 项目根目录而是单独把harmony目录作为工程打开否则 DevEco 会把node_modules当成资源目录去索引卡到怀疑人生。打开之后DevEco 会自动同步 Gradle/Hvigor 依赖。首次同步较慢属于正常现象。同步完成后先尝试直接运行空工程确认“RN 加载鸿蒙壳子”这一层通了。2.3 验证“壳子”是否跑通空工程跑通后你在entry/src/main/ets/pages/Index.ets里会看到一个基于 RN 的入口页面一般是通过RNInstance加载index.jsbundle。这时鸿蒙壳子里其实已经嵌入了一个微型 RN 运行时但还没有任何自定义的原生组件。我建议你在这个阶段先跑一遍完整的npm start启动 Metro和 DevEco 的 run 流程从 Metro 面板上能看到 bundle 打包日志再到模拟器上确认白屏不是编译问题。这一步顺利了后面的工作才有意义。3. 核心实操写一个鸿蒙原生模块非 UI 型3.1 Node-API鸿蒙侧的“bridge 接口”先写最简单的“非 UI 型”鸿组件——比如一个返回系统版本号的原生模块。它不需要任何界面只需要把能力暴露给 JS 调用。在鸿蒙侧这套机制的正式名称是Node-APInapi。你可以理解成鸿蒙把 Node.js 的原生模块机制移植到了 ArkTS 运行时中用.d.ts声明接口、用napi_*方法注册函数然后 JS 侧通过import或TurboModule去调用。下面是napi目录下的初始化文件假设叫init.tsimport { napi } from ./napi_types; export function initNapi(): void { napi.registerModule(SystemInfo, (env, exports) { const getSystemVersion () { // 这里调用 HarmonyOS 系统能力 return HarmonyOS 5.0; }; napi.exportFunction(env, exports, getSystemVersion, getSystemVersion); }); }如果你在 DevEco 工程里用的是.d.ts C 混合方式也可以直接在.ets文件里通过ohos.napi接口进行绑定。不过不管哪种写法逻辑都一样注册模块名、导出函数、在函数里调系统 API。3.2 JS 侧调用原生模块回到 RN 的 JavaScript 侧。在index.js或任意业务代码中调用import { NativeModules } from react-native; const SystemInfo NativeModules.SystemInfo; export function getHarmonyVersion(): Promisestring { return SystemInfo.getSystemVersion(); }这一步看着简单但有一个很容易踩的坑鸿蒙侧模块名和 JS 侧名称对不上。RN 的NativeModules查找的是鸿蒙侧实际注册的模块名大小写敏感。你注册的是SystemInfoJS 侧就必须写SystemInfo写成systemInfo会直接拿到undefined而且 RN 不会报错代码也不崩溃只会给你一个难看的类型错误。3.3 传参与回调的完整姿势真实业务不可能只返回固定字符串。我们封装一个更实际的场景——读取设备里的照片权限状态鸿蒙侧通过napi暴露一个带参数和回调的函数napi.exportFunction(env, exports, checkPhotoPermission, (env, callback, args) { const permission args[0]; // 传入的权限名 const result checkPermissionSync(permission); napi.callFunction(env, callback, result); });JS 侧调用const result await NativeModules.SystemInfo.checkPhotoPermission( ohos.permission.READ_IMAGE_VIDEO );这里我的经验是能用 Promise 就用 Promise别用回调嵌套。鸿蒙侧napi对 Promise 的支持是有的但不少教程都默认展示回调。如果你在鸿蒙侧返回一个PromiseJS 侧可以直接await代码干净很多。注意napi导出的函数如果参数里带了非基础类型比如ArrayBuffer、对象要先做序列化。鸿蒙侧拿到的是 JS 传过来的对象但默认只有基础类型能直接透传。最稳妥的做法是在 JS 侧把对象JSON.stringify转成字符串鸿蒙侧再JSON.parse。3.4 非 UI 模块的常见坑线程问题鸿蒙侧napi函数默认在引擎线程执行。如果你在里面同步调一个耗时系统 API比如文件扫描、网络请求会直接卡住 JS 引擎线程RN 界面跟着白屏。正确做法是鸿蒙侧把耗时操作放到TaskPool或Worker线程完成后通过回调回到 JS 线程。日志埋点在napi模块里写console.log不一定能看到输出。建议用两个手段并行确认问题一是在鸿蒙侧用hilog打标记二是在 JS 侧console.warn两个日志分别看才能定位问题出在原生侧还是侧 JS。模块重复注册热更新时如果反复registerModule鸿蒙会报“module exist”错误。初始化逻辑里要加一个全局Set或者判断标志保证只注册一次。4. UI 型组件封装把 ArkUI 视图嵌入 RN4.1 理论铺垫Fabric 与自定义 UI 组件如果你只用了一个View标签包了一个Text那还谈不上“鸿组件”。真正的鸿组件封装要解决的核心矛盾是JS 侧描述视图树鸿蒙侧渲染原生节点。在 RN 的鸿蒙实现中这个能力叫Custom Component自定义组件。底层原理是鸿蒙侧实现一个ComponentBuilder和CustomComponentCreator把 ArkUI 的build()方法产生的原生组件树挂到一个特殊节点C-API的FrameNode/CustomNode然后 RN 的触摸事件、布局属性、样式属性通过 JSI 传给这些原生节点。换句话说你在 RN 里写的HarmonyMap /最终通过 JSI 桥变成 ArkUI 里的MapComponent实例。这个过程对业务侧是透明的但对原生开发来说你需要追求下面三个目标JS 侧能像普通 RN 组件一样传 props。组件内部能发事件回 JS比如地图点击某个标记。原生侧的状态能及时同步到 JS比如权限变化、组件尺寸变化。4.2 实操封装一个带标题栏的自定义组件我用一个实际封装过的例子来说明一个鸿蒙侧自绘的“胶囊按钮”带阴影和小圆角用 ArkUI 绘制但由 JS 控制它的文本和点击回调。鸿蒙侧定义 UI 组件 builder在entry/src/main/ets/components/CapsuleButton.ets里写一个标准的 ArkUI 组件Component export struct CapsuleButton { Prop text: string ; onButtonClick: () void () {}; build() { Column() { Text(this.text) .fontSize(16) .fontColor(#FFFFFF) } .padding({ left: 20, right: 20, top: 8, bottom: 8 }) .borderRadius(20) .backgroundColor(#007AFF) .onClick(() { this.onButtonClick(); }) } }鸿蒙侧把组件“贡献”给 RN接着在一个注册文件中绑定这个组件import { ComponentCreator } from react-native-harmony; ComponentCreator.createCustomComponent( CapsuleButton, () CapsuleButton, { text: string, }, [onButtonClick] );这里有个细节需要解释第二个参数是一个返回struct的闭包函数不是直接传组件类型——因为鸿蒙的组件实例化需要依赖上下文闭包方式可以保证每个组件实例拿到独立的状态。第三个参数是 props 类型映射第四个参数是支持的事件列表。JS 侧注册组件并引入在 RN 业务代码里需要用到requireNativeComponent旧版或codegenNativeComponent新版import { HostComponent, requireNativeComponent } from react-native; interface CapsuleButtonProps { text: string; onButtonClick?: () void; } export const CapsuleButton requireNativeComponentCapsuleButtonProps( CapsuleButton ) as HostComponentCapsuleButtonProps;然后在页面里就能像原生 RN 组件一样用CapsuleButton text点击我 onButtonClick{() console.log(clicked from harmony)} /4.3 事件反向通信从鸿蒙组件到 JS上面例子里onButtonClick是一个典型的“原生到 JS”的事件。你可能会好奇它内部怎么实现的。鸿蒙侧点击事件触发时需要把事件发到 JS 侧。RN 的鸿蒙适配层提供了一套事件分发机制大致逻辑是// 鸿蒙侧 this.onButtonClick () { this.getJSContext().callJSFunction(CapsuleButton, onButtonClick, { event: click, target: capsule-button, }); };实际上你在写 UI 组件时不需要手动调callJSFunction那是极底层更多是用react-native-harmony提供的事件绑定工具。但我们还是要明白它的本质所有从鸿蒙侧发起的事件最终都走 JSI 的callJSFunction把一个事件结构体传回 JS 的合成事件系统。所以事件对象的结构最好规范化。我习惯统一成这种形态{ type: click, nativeEvent: { target: capsule_btn_001, value: 1 } }这样 JS 侧接收后可以直接通过event.nativeEvent.value拿数据不需要猜。4.4 布局与样式尺寸同步的坑自定义鸿组件最容易出问题的地方在布局。RN 的StyleSheet最终会转化成鸿蒙侧的布局参数但这个转化并不是全量的。我的经验是支持良好width、height、margin、padding、flex、backgroundColor、borderRadius等基础布局属性。支持有限shadow*系列在鸿蒙侧的不同 API 版本上表现不一致有时需要走原生的.shadow()方法自己设。不支持RN 里的zIndex在部分鸿蒙组件中不生效尤其是跨层级的浮层。遇到这种直接改鸿蒙侧的stack层级去控制别死磕 RN 样式。建议封装的组件在内部尽量自包含把关键样式在鸿蒙侧用固定值写死JS 侧只传语义化参数比如variantprimary而不是让 JS 直接控制每一个样式细节。这能显著降低维护成本。5. 踩坑合集我在项目里遇到的高频问题5.1 启动白屏Metro 连接不上这是最常见的几乎每个人第一次在鸿蒙模拟器跑 RN 都会遇到。白屏不一定是鸿蒙代码的锅也可能是 Metro 的 bundle 地址没配对。排查顺序建议如下确认模拟器和开发机在同一网络如果是远程模拟器需要手动把entry/src/main/ets/entryability/EntryAbility.ets里的 bundleUrl 改成电脑的局域网 IP不能用localhost。查看 DevEco 的 Log 面板搜索JSContext或ReactNative关键字看有没有 bundle 加载失败的错误。确认 Metro 端口默认 8081没有被占用。鸿蒙侧经常会因为DevEco后台进程占用端口导致连接失败。如果以上都正常把鸿蒙侧的RNInstance初始化日志等级调到DEBUG看 JS 引擎有没有正常初始化。5.2 热更新几乎不可用先说结论HarmonyOS 侧的 RN 热更新目前体验远不如 Android/iOS。Metro 的 Fast Refresh 在鸿蒙上是不稳定的经常出现改了 JS 代码但界面不变、甚至崩溃的情况。我的应对方案很土但有效关掉 Fast Refresh每次改完 JS 代码手动点 DevEco 的Rerun或者用命令重新构建 bundle。等整套调试链路稳定了再考虑开 Fast Refresh。5.3 自定义组件不渲染但也不报错这种事特别伤神。如果鸿组件在页面里占位了但内容空白又没有日志大概率是组件无法通过 C-API 创建。排查办法确认注册组件时给的组件名和 JS 侧requireNativeComponent的名称完全一致。确认鸿蒙侧有没有正确的Component结构体并且build()方法里真的画出了可见内容有时候阴影颜色太浅或者文字颜色和背景一样被误以为没渲染。检查 DevEco 的hilog里有没有CreateCustomComponent failed之类的字眼。5.4 事件回调收不到JS 侧的onButtonClick一直不触发但鸿蒙侧点击确实执行了。我遇到过一次原因是鸿蒙侧事件名定义成了onButtonClick而 RN 侧监听的是onButtonClick看起来一样实际上 RN 的事件系统会做一次首字母大写转换onClickclick导致对不上。解决办法很粗暴在注册组件时事件名统一用小写开头比如buttonClickJS 侧写成onButtonClick——这是 RN 官方推荐的映射习惯。或者你去react-native-harmony源码里看一眼事件映射逻辑配套着来。5.5 打包体积与启动时间如果你在测试包里看到 APK/HAP 包体异常大先别急RN 的打包产物本来就包含 JS 引擎和基础库。但鸿蒙侧还叠加了一层 ArkTS 运行时所以首包会更大。优化思路优先把 JS bundle 拆包按页面维度使用分包加载。鸿蒙原生侧能做的尽量原生少在 JS 里引重型库。关闭不必要的 DevTools 逻辑生产环境一定用 release 模式构建。6. 进阶探索性能优化、状态同步与未来方向6.1 性能优化减少 JSI 通信很多性能问题都出在“频繁的跨桥调用”上。JSI 不比 Android 的 old bridge 快多少它只是同步性更好。如果你的鸿组件每秒需要更新多次状态比如进度条动画、实时帧数据就会在 JSI 层产生较大压力。我建议高频更新场景把状态放在鸿蒙侧维护。比如进度条组件JS 只传一个total和初始值进度更新靠组件内部自驱动完成后通过事件一次性通知 JS。这样通信频次直接从每秒几十次降到每秒一次。6.2 状态同步自定义事件与命令除了“组件内部状态变化通知 JS”还有一种场景是“JS 主动要求组件执行动作”比如调用鸿组件的reset()方法。这需要用到 RN 的dispatchCommand。鸿蒙侧实现命令ComponentCreator.registerCommands(CapsuleButton, { reset: (viewId, params) { // 重置组件状态 } });JS 侧调用import { findNodeHandle, requireNativeComponent, UIManager } from react-native; UIManager.dispatchViewManagerCommand( findNodeHandle(componentRef), reset, [options] );注意dispatchViewManagerCommand的第二个参数在某些版本要传数字 commandId有些版本可以传字符串。鸿蒙侧适配的是字符串方式这个细节很容易踩坑。6.3 混合渲染策略什么组件该做鸿组件在一个真实业务工程里不是所有 UI 都得用鸿组件。我的经验是分三类组件类型推荐做法理由通用展示组件文本、图片、按钮纯 RN 组件跨端一致性最好维护成本最低强系统能力组件扫码、地图、支付鸿组件封装ArkUI 能直接用系统级 API性能和体验最佳复杂业务容器长列表、多tabRN 容错 关键路径鸿组件长列表使用 RN 的 FlatList 性能尚可但极度复杂的卡片建议鸿组件兜底我自己踩过的最痛一次是做了一个图片签名画板组件。一开始想用 RN 的View 手势库实现结果在鸿蒙上触摸事件有延迟、笔迹不跟手。最终改成鸿组件用 ArkUI 的Canvas和PanGesture实现效果立刻质变。这件事说明判断要不要做鸿组件先问一句“这个交互在原生侧是不是天然有优势”。6.4 关于未来方向的一点个人看法鸿蒙化 RN 这条路适配工作确实还有不少粗糙的地方但趋势已经很明显了更多厂商加入到 OpenHarmony 社区react-native-harmony的迭代速度在加快。我的建议是团队里最好有一两个人承担“鸿组件沉淀”的角色把常用的地图、扫码、分享、登录等模块封装成公司内部的 npm 包同时维护一个鸿蒙侧的原生模块仓库。这样后续开新项目基本就是低成本的组装。写在最后回到最初的问题在 React Native 里开发鸿组件说到底是一层“适配思维”的转变。不要想着把 Android/iOS 的实现照搬过来也不要试图在 JS 侧解决所有问题。你要做的是把鸿蒙的原生优势系统能力、渲染性能、分布式能力通过组件化的方式反向赋能给 RN 层让跨端业务在鸿蒙上“感觉不到桥的存在”。我个人在实际工程里最大的体会是先跑通最小闭环再考虑组件复用。别一上来就设计一套宏大的组件库。先把一个胶囊按钮从 JS 通到 ArkUI再逐步加地图、加扫码、加复杂手势。每通一个你都会对鸿蒙的组件生命周期、事件机制、JSI 通信有更深一层理解。这些理解是任何文档都给不了的只能通过一次次构建、调试、崩溃、排查堆积起来。另外再分享一个实际工作中发现的小技巧鸿蒙侧napi模块的.d.ts声明文件建议用脚本自动生成不要手写。手写容易漏掉类型导致 JS 侧调用时类型收窄出错而且鸿蒙侧exportFunction的参数类型和 TS 声明一旦不一致排查成本极高。自动生成之后JS 侧直接引用.d.ts作为类型来源能避免一大批低级错误。最后的最后如果你正打算把一个老的 RN 工程往鸿蒙迁移给你一个定心丸不要把迁移看成“重写”看成“增量替换”。先用鸿组件封装几个高频原生能力让新业务先跑在鸿蒙上等团队趟平了坑再逐步平移老业务。这个过程可能会比较慢但每一步都是实的。