
最近一个做 React Native 的朋友来找我说业务方给了一台鸿蒙HarmonyOS设备要求把系统里那套手写签名组件直接挪到 RN 项目里用。听起来就是封一个原生组件给 JS 调的老活结果真正动手以后发现横在面前的是一整套鸿蒙开发基础ArkTS 的语言约束、ArkUI 的状态驱动模型、组件生命周期再到桥接层的事件注册。这篇文章把我实际走过的路线完整梳理一遍内容包括鸿蒙开发的基本功、RN 与鸿蒙集成的两种主流形态以及从鸿蒙侧封装组件到 RN 侧 JS 调用的完整实操流程最后附上我在联调过程中踩过的坑。适合正在做 RN 跨端改造、需要适配鸿蒙设备的团队也适合想搞明白RN 里能不能直接跑鸿蒙组件的开发者。1. 先搞清楚RN里的鸿组件到底指什么1.1 两种常见的组件形态先说概念。大家喊的鸿组件通常指的根本不是同一个东西。第一种是鸿蒙系统里的应用组件也就是 Ability、Page 层面的东西比如一个扫码页、一个分享面板它们在鸿蒙生态里以独立页面或者服务的形式存在由系统负责生命周期和任务调度。第二种是 ArkUI 里的 UI 组件也就是用 Component 装饰器声明的最小可复用单元类似 RN 里的一个 View或者 Android 里的一个自定义 View。在 RN 项目里集成鸿组件绝大多数场景要处理的是第二种也就是把 ArkUI 组件封装成 RN 能渲染、能传属性、能收事件的原生 UI 组件。如果是第一种通常走的是应用级跳转或者跨应用拉起算不上严格意义的RN 内嵌组件也不需要用桥接那套东西。1.2 两种典型的业务场景我把实际项目中见过的需求整理成了两类大家可以自己对号入座。一类是场景 ARN 页面里要展示鸿蒙原生的高精度地图、复杂图形组件这些组件只有鸿蒙 SDK 提供JS 层没有等价实现或者用纯 JS 重写的成本高到离谱这时候需要把 ArkUI 组件桥接给 RN让 RN 页面里直接渲染一块鸿蒙原生视图。另一类是场景 B现有鸿蒙应用要接入 RN 生态的页面比如复用 RN 社区里的图表库、列表库、表单校验逻辑这时候角色反过来了是鸿蒙 App 作为宿主RN 作为子页面运行。这两类场景的工程结构完全不一样。很多人上来就问RN 怎么集成鸿组件其实应该先回答你的宿主是谁、桥的方向是哪一边。后文我会分别说明实战部分以场景 A 为主因为场景 A 对 RN 开发者的要求更高既要懂鸿蒙又要懂桥接写出来的东西也更容易沉淀成团队通用的封装层。1.3 为什么了解鸿蒙开发基础是绕不开的前提这个前提不是官方文档里的一句套话而是实际操作中真正会卡住人的地方。React Native 的组件模型核心是属性驱动 UIJS 把 props 传给原生层原生层根据 props 更新视图鸿蒙 ArkUI 则是状态驱动 UI组件内部用 State 装饰的变量驱动重绘。两者数据流方向一致但触发机制差很多。举个例子RN 里一个组件的背景色变了多半是因为父组件重新渲染传了新的 props鸿蒙里一个组件的外观变了往往是 State 状态被修改后build 方法重新执行。这两种模式下桥接层必须想清楚哪些数据应该从 JS 流向原生哪些状态应该留在原生内部。如果全都靠属性传递代码会变得极其啰嗦而且渲染效率也会打折扣。此外组件生命周期的名字和触发时机也完全不同RN 有 componentDidMount、componentWillUnmountArkUI 有 aboutToAppear、aboutToDisappear桥接层必须正确处理映射关系否则会出现RN 侧以为组件加载完了鸿蒙侧其实还没创建的错位问题。2. 鸿蒙侧的基本功ArkTS、工程结构与组件模型2.1 ArkTS不是普通 TypeScriptArkTS 是基于 TS 扩展出来的语言但比普通 TS 严格不少。官方规定了强静态类型检测、禁止使用某些 JS 动态特性比如乱用 any、eval、with目的是让 UI 框架可以在编译期做更多优化。我刚接触时的感觉是明明写 TS却总被 lint 教育一度怀疑自己不会写 TypeScript。日常开发你要习惯几件事。装饰器是绕不开的Component、State、Prop、Link、Watch 这些是 ArkUI 的语法糖决定了变量的响应式属性。样式不写在单独的 CSS 或 StyleSheet 里而是直接写在组件上用链式属性比如 .width(100%)、.backgroundColor(#fff)。还有不允许直接把 JSON 对象当 class 实例传必须显式声明类型。这些差异直接决定了你在写鸿蒙侧组件时必须用鸿蒙的思维组织代码不能把 RN 函数组件那套直接搬过去。2.2 工程结构先理解 entry 和 module用 DevEco Studio 创建鸿蒙工程默认有一个 entry 模块里面是 ets 目录。ets 目录下按 pages 组织页面每个页面是一个 Entry 装饰的组件。还有 resources 目录放字符串、颜色、图片等资源配置里要写 module.json5 声明权限和能力。RN 开发者最容易困惑的是鸿蒙项目里页面和组件不是同一层概念。一个 ability 是应用入口对应一个完整的 UIAbility 生命周期page 是实际显示的内容而 Component 只是可以嵌入 page 的组件单元。你要桥接给 RN 的自定义组件通常是 Component 单元它不一定是 Entry也不需要 ability。换句话说鸿组件更像控件不是页面。这个认知很重要因为很多人在第一次接触鸿蒙工程时总想把桥接组件往 ability 方向写结果多写了一堆生命周期逻辑实际却根本不需要。2.3 状态管理与自定义组件写法用一个简单例子说明 ArkUI 组件的写法Component export struct MapPanel { Prop mapType: string normal; State markerCount: number 0; private onMarkerTapped: () void () {}; aboutToAppear() { // 初始化逻辑 } build() { Column({ space: 8 }) { Text(标记数量: ${this.markerCount}) Button(增加标记) .onClick(() { this.markerCount; if (this.onMarkerTapped) { this.onMarkerTapped(); } }) } .width(100%) .padding(12) .backgroundColor(#f5f5f5) } }这里 Prop 表示外部传入的单向属性State 是组件内部响应式状态。改动 statebuild 里的 UI 会自动更新这就是状态驱动的核心。跟 RN 相比鸿蒙侧更强调把状态放在组件内部UI 仅作为状态的投影。桥接的时候我们一般只把外部确实需要控制的少量属性暴露出去内部交互状态尽量封闭在原组件里。2.4 自定义组件生命周期ArkUI 自定义组件生命周期主要是 aboutToAppear、aboutToDisappear以及 onPageShow、onPageHide 这类页面级生命周期。aboutToAppear 在组件创建后、build 执行前回调适合做初始化aboutToDisappear 在组件销毁前回调适合释放资源、移除事件监听。桥接层的工作就是在这些时机告诉 RN 侧组件准备好了组件要走了。如果这些回调没有正确透传RN 侧会出现资源泄漏或者事件恢复失败。实际项目里我见过组件销毁后回调仍然被触发的情况JS 侧收到多次叠加事件状态越滚越乱。后来排查才发现是鸿蒙侧没有在 aboutToDisappear 里把事件回调置空。这个细节值得每一位写鸿组件的人特别注意。3. 集成路线选择哪种方案适合你的项目3.1 方案 A鸿蒙 App 作为宿主RN 页面嵌入这种路线是给鸿蒙应用增加 RN 能力。工程的主入口是鸿蒙你创建承载 RN 容器的页面加载 JS Bundle然后 React Native 的视图树会渲染在这个容器中。RN 组件与鸿蒙原生组件可以共存但 JS 侧要访问鸿蒙 API 时最终还是通过桥接层。这种方案的优点是能复用 RN 生态社区里几十万个组件库都可以拿过来用。缺点是启动链路变长JS Bundle 加载有耗时需要做好首屏优化RN 和鸿蒙两套运行时共存内存占用也会比纯鸿蒙应用高不少。适合那种鸿蒙团队为主、RN 辅助的项目。3.2 方案 BRN App 为主鸿蒙组件作为桥接组件这是本文实战重点。RN App 还是原来的工程针对鸿蒙设备引入社区维护的鸿蒙 RN 适配层把 ArkUI 组件封装成 RN 原生组件。社区适配层本质上把 RN 的 C 核心编译到了鸿蒙设备上渲染逻辑对接 ArkUI这样 RN 的组件树和鸿蒙原生组件可以在同一棵视图树里共存。封装过程通常要做三件事在鸿蒙侧实现一个组件控制器负责创建 ArkUI 组件实例在控制器里声明组件属性JS 侧传什么 props就映射成什么属性在控制器里处理事件注册把 ArkUI 回调转成 RN 侧事件。和 Android/iOS 的 ViewManager 是一个思路只是鸿蒙侧的注册机制、UI 线程入口有自己的写法。3.3 方案对比维度方案 A鸿蒙宿主方案 BRN 宿主主体工程鸿蒙工程RN 工程适用场景鸿蒙应用复用 RN 生态RN 应用适配鸿蒙设备开发重点RN 容器搭建、Bundle 加载鸿蒙原生组件桥接复杂点首屏加载与生命周期ArkUI 组件封装与事件透传团队要求RN/鸿蒙都要懂RN 为主鸿蒙侧要看得懂选型建议很简单主导团队是哪个生态就让哪个生态做宿主。如果团队大部分成员是 RN 背景强行把鸿蒙工程当主工程维护成本会翻倍反过来也一样。架构是为团队和业务服务的别为了先进去挑战组织能力边界。3.4 什么时候真的需要写鸿组件如果不是性能敏感、不是必须调用鸿蒙私有能力建议不要写鸿组件。RN 社区里现成的 JS 组件库大多能用用 WebView 也能挡一阵。真正需要原生封装的是这几种情况组件依赖鸿蒙专属 SDK比如某些系统级能力、特定硬件能力列表滚动、地图渲染这类对帧率和交互手感要求高的场景需要与系统其他能力深度联动比如多设备协同、分布式投屏相关能力。判断标准就一条JS 层做不了、WebView 达不到要求才值得写原生封装。鸿蒙的分布式能力是它的特色但桥接普通组件时通常不涉及等真正需要多端流转时再通过系统 API 接入也不迟。4. 实战用 ArkTS 封装一个鸿蒙原生组件4.1 定义组件能力边界我以一个模拟的签名面板组件为例。这个组件接收笔的颜色和线宽用户手写完之后把结果回传。选它是因为它同时涉及属性传递、触摸事件、时机回调足够覆盖常见桥接需求。能力边界定下来输入是笔颜色 color、线宽 lineWidth、是否显示清除按钮 clearable输出是用户点击完成时回调签名图片数据以及错误信息。边界越清晰后面写桥接越省事。最忌讳把原生组件的内部状态一股脑全部暴露给 JS比如把每个点位的坐标都同步出来既浪费性能又让 JS 侧的协议变得臃肿。4.2 用 ArkTS 实现 UIComponent export struct SignaturePanel { Prop color: string #000000; Prop lineWidth: number 3; Prop clearable: boolean true; private onComplete: (data: string) void (data: string) {}; private onError: (msg: string) void (msg: string) {}; private drawing false; private points: Array[number, number] []; build() { Column({ space: 12 }) { Stack() { // 实际的签名画布逻辑会在这里 Text(签名区域).width(100%).height(200).backgroundColor(#fff) } .height(200) .width(100%) .border({ width: 1, color: #ccc }) Row({ space: 8 }) { Button(完成) .onClick(() { if (this.points.length 2) { this.onError(没有检测到有效笔迹); return; } // 生成图片数据并回传 this.onComplete(data:image/png;base64,...); }) if (this.clearable) { Button(清除).onClick(() { this.points []; }) } } } .width(100%) } aboutToAppear() { // 初始化画布上下文 } }注意这里的 onComplete 和 onError 是普通回调属性桥接层在创建组件时会把它替换成真正发送 RN 事件的方法。在鸿蒙侧代码里这两个回调就是普通的函数类型字段业务逻辑不需要感知自己是在 RN 环境里运行。4.3 桥接控制器创建、更新、销毁组件鸿蒙侧的控制器主要负责三件事创建 ArkUI 组件实例、把 JS 传来的属性同步到组件、把组件回调转成 RN 事件。以下为示意代码实际创建实例的方式由所选用的鸿蒙 RN 适配层决定但思路是通用的。export class SignaturePanelController { private component?: SignaturePanel; create(): SignaturePanel { // 实际由框架按 ArkUI 规范创建这里仅表示逻辑 this.component new SignaturePanel(); this.attachDefaultCallbacks(); return this.component; } updateProps(props: Recordstring, object) { if (!this.component) return; if (props[color] ! undefined) this.component.color String(props[color]); if (props[lineWidth] ! undefined) this.component.lineWidth Number(props[lineWidth]); } attachDefaultCallbacks() { this.component.onComplete (data: string) { this.emitToJS(complete, data); }; this.component.onError (msg: string) { this.emitToJS(error, msg); }; } }这里之所以用字符串事件名是为了让 JS 侧统一监听一个 onNativeEvent再根据 type 字段分发。好处是协议简单调试时只需要看一个事件通道缺点是事件名拼错时编译不报错。所以我在实际项目里会把事件名定义成常量两边共享一份字段清单从源头减少拼写错误。4.4 注册组件让 RN 侧能识别组件把组件注册到桥接层扫描入口里会多一条映射比如组件名 SignaturePanel 对应 SignaturePanelController。这一步决定了 JS 侧 requireNativeComponent 时能找到它。注册时要特别留意包名和组件名的大小写鸿蒙侧桥接层通常以字符串匹配大小写错了不会报编译错误但运行时组件一直渲染不出来。我遇到过一次小写签名问题排查了整整一个下午最后发现只是大小写不一致。5. JS侧封装技巧props 过滤与事件命名设计5.1 用 requireNativeComponent 创建 JS 包装组件RN 侧第一步是获取原生组件引用import { requireNativeComponent } from react-native; const SignaturePanelNative requireNativeComponent(SignaturePanel);如果项目已经切到新架构Fabric代码会有一点差异但旧架构兼容层基本还是走这套。拿到原生引用后包一层 JS 组件主要做两件事props 白名单过滤和事件转换。不要直接把整包 props 透传给原生组件先把鸿蒙侧不认识的属性过滤掉减少无意义的原生层更新。5.2 设计 JS 组件 propsfunction SignaturePanel(props) { const { color, lineWidth, clearable, onComplete, onError, ...rest } props; const handleNativeEvent (event) { if (event.nativeEvent.type complete) { onComplete?.(event.nativeEvent.data); } else if (event.nativeEvent.type error) { onError?.(event.nativeEvent.data); } }; return ( SignaturePanelNative color{color} lineWidth{lineWidth} clearable{clearable} onNativeEvent{handleNativeEvent} {...rest} / ); }事件这里有一个通用做法原生侧所有回调统一发一个 onNativeEventJS 侧用 event.nativeEvent 里的 type 字段区分。好处是 JS 包装组件的 API 可以保持单一数据流坏处是调试时事件类型容易拼错。实际项目里我更建议把事件名写得显式一点比如 complete、error 都挂在 nativeEvent 上同时提供类型定义让 IDE 能自动补全。5.3 属性同步的方向与时机RN 传属性给鸿蒙原生组件和普通 RN 原生组件一样是由原生控件的 updateProps 顺序保证的。但鸿蒙组件的 Prop 有同步时机问题某些版本的适配层只在组件创建时应用一次属性后续属性变化需要显式调用更新方法。对应做法是在 JS 里用 useEffect 监听关键 props变化时通过 ref 调用命令接口。我把这段联动封装成一个自定义 hook组件内部保持干净。代码上就是 useEffect 里判断旧值和新值有变化时调用原生实例的更新命令。这个方法比依赖系统自动同步更可靠代价是代码里多了一些显式联动逻辑但对鸿组件来说稳定大于省事。5.4 用 TypeScript 定义组件协议给鸿组件写 TS 类型非常值得因为鸿蒙侧属性名、类型一旦确认JS 侧跟着改的成本最低export interface SignaturePanelProps { color: string; lineWidth: number; clearable?: boolean; onComplete?: (data: string) void; onError?: (msg: string) void; }类型定义放在共享目录里鸿蒙侧代码引用同一份字段名避免两端属性名不一致。我见过太多例子鸿蒙侧叫 clearableJS 侧叫 showClear运行时属性不生效编译还不报错。等到测试环境才暴露问题定位成本非常高。6. 从编译到真机我惯用的联调顺序与日志定位法6.1 鸿蒙侧先编译RN侧再启动联调时我的固定顺序是先在 DevEco Studio 里打开鸿蒙工程确认能编译通过再启动鸿蒙模拟器或连接真机让鸿蒙侧应用先以页面方式跑起来然后启动 RN 的 Metro 服务让 JS Bundle 能热更新最后打开集成后的入口看到 RN 渲染内容和鸿蒙组件同时出现。这个顺序看着啰嗦但能显著减少联调问题。顺序反过来的话如果鸿蒙侧编译失败RN 侧 Metro 会一直报各种奇怪的加载错误排查半天发现根本没有鸿蒙组件白费功夫。先保证两头各自能跑再谈集成是跨界联调的基本原则。6.2 真机调试时的端口和日志真机上 RN 与 Metro 通信需要配置开发服务器地址鸿蒙适配层里一般有专门的 debug 配置项要填上电脑的局域网 IP。注意鸿蒙设备如果有网络安全策略需要确认应用有网络权限否则 Bundle 加载会一直卡在超时。日志输出方面鸿蒙侧用 hilog 查看日志 Tag 设置为自定义组件名比如 SignaturePanelRN 侧用 Metro 终端看 JS 日志错误信息会以 RedBox 或 Toast 形式提示。桥接层日志最容易被忽略。我建议在属性同步、事件发送两头各打一条日志打上请求标记比如 [SignaturePanel][updateProps] color#000000。这样一旦 UI 异常通过时间线就能判断是属性没同步还是事件没送达还是渲染本身出了问题。6.3 典型报错与解决思路现象可能原因优先排查点组件渲染空白组件名未注册或大小写不符搜索注册表映射属性传不进去Prop 类型不匹配鸿蒙侧打印 updateProps事件收不到事件名拼错或 emit 时机过早JS 侧监听是否在组件创建前页面卡死原生侧主线程阻塞检查是否在非 UI 线程执行 UI 操作App 闪退生命周期未释放检查 aboutToDisappear 清理这张表是我排查问题的第一反应清单。组件空白先查注册属性问题先打日志看同步是否发生事件问题要看时序。大多数情况下问题不在桥接框架本身而在两端协议没对齐。7. 实测中踩过的坑生命周期错位、线程与包体积7.1 生命周期错位组件创建的时序陷阱最典型的问题是RN 侧的 onLayout 和 componentDidMount 触发时鸿蒙侧组件实例可能还没创建完成尤其在一些异步初始化场景下。这时候如果 JS 立刻调用原生命令会拿到 null。解决办法是在鸿蒙侧缓存需要执行的命令等组件创建完成后再执行或者在 JS 侧用自定义事件作为组件就绪信号就绪之后再发命令。我在模拟项目 X 里遇到过一个更隐蔽的问题复用同一个签名面板组件切换数据源时鸿蒙侧组件直接销毁重建而 RN 侧只是更新 props导致旧组件的内部状态残留签名内容怎么清都清不掉。最后靠给组件加 key 强制重建才解决。这不是桥接 bug是组件生命周期设计方向搞反了。7.2 回调跨线程导致的时序抖动鸿蒙侧某些触摸事件和系统回调并不都在 UI 线程直接拿回调结果操作 UI 或者发送事件偶尔会出现时序抖动事件先到了但状态还没更新完于是 JS 侧拿到的是中间态数据。处理方式是在鸿蒙侧收到回调后统一切换到主线程再 emit。这条经验让我少踩了很多莫名的数据不一致问题。跨端开发里线程归属是一个很容易被忽略的角度。RN 侧习惯性认为原生回调都是顺序的、可靠的但鸿蒙侧多线程环境下回调的执行线程并不总是 UI 线程。桥接层最好在发事件前做一次线程检查必要时重新投递到 UI 线程。7.3 包体积和首屏加载鸿蒙侧代码打进 RN 包里之后包体积会比纯 RN 明显增大因为 ArkUI 运行时、基础组件库都要带进来。如果设备配置一般首屏加载时间可能从几百毫秒涨到一两秒。我的建议是鸿组件尽量按需注册不要一股脑把整个 UI 组件库全部注册进桥接层图片、字体资源放远程不要把高清素材打包进原生包里。还有一点Debug 模式下鸿蒙运行时会有额外的调试逻辑启动更慢。如果只是验证功能可以接受如果要测真机性能一定要用 Release 包别在 Debug 下做结论。7.4 稳定优先创新靠边最后一条经验鸿组件数目一旦超过十个维护成本会指数增长。每次跨端升级RN 大版本升级、鸿蒙适配层升级、DevEco Studio 升级都可能带来桥接 API 变化建议把封装层和业务层严格分层业务代码只依赖 JS 组件接口不直接依赖桥接 API这样升级时只需要改动封装层。我每次接这种需求都会先花半天把鸿蒙侧的生命周期文档翻一遍再把 RN 桥接层源码打开对比着看。两边基础打牢之后剩下的就是拿日志一遍遍喂边界情况。多留一点排查标记联调的时候能救你很多次。鸿组件开发本质上就是两个系统交界处的活能把这套流程跑顺跨端适配的很多问题都会变得简单。