前阵子在做公司内部的鸿蒙化改造,把现有的React Native业务代码往OpenHarmony生态上迁移。迁移本身其实还好,真正折腾人的是业务里的那些复合组件——尤其是订单流程里到处都要用的步骤条。这东西看着简单,不就是几个圆点和几条横线嘛,可真要做得能对齐、能自适应、能在不同屏幕和字体缩放下不炸,再叠加鸿蒙那边的平台差异,就完全不是一回事了。
我这一篇是“React Native鸿蒙跨平台开发高级复合组件库开发系列”里的实操记录,拿订单步骤条当例子,把从设计思路、API规划、核心实现到鸿蒙适配踩坑的完整过程捋一遍。内容会涉及RNOH(React Native for OpenHarmony)的适配差异、状态机设计、 HAR打包发布、以及我在白屏和布局错乱上排到吐血的真实经历。如果你正在给团队搭RN组件库,或者马上要把现有RN工程搬到鸿蒙设备上跑,这篇应该能帮你少走不少弯路。
1. 项目定位与设计思路
1.1 为什么要在RN鸿蒙环境里自研复合组件
先聊个基础问题:ArkUI里明明有Step组件,为什么还要在RN侧自研一个步骤条?
答案是:跨端一致性。
一个成熟的跨端组件库,应该保证iOS、Android、HarmonyOS三端渲染出来的视觉效果和交互行为完全统一。如果我们直接在各端用原生组件拼,那等于每个端维护一套实现,视觉走查的时候逐端对,开发成本直接翻倍。而RN侧的优点就在于,业务代码只需要写一份,通过react-native-harmony这套适配层,把JS组件映射到鸿蒙的ArkUI组件上。
我自己实际跑下来,RNOH目前已经能覆盖绝大多数基础组件(View、Text、ScrollView、Pressable这些都没问题),但对于步骤条这种复合组件,RN侧完全可以从零绘制每个节点、连线和状态,不依赖任何端上独有的原生组件。这样做的好处有两个:
- 视觉统一,因为所有节点都是我们用View+Text+Animated拼出来的,三端渲染出的像素级基本一致。
- 可定制性强,业务方传什么颜色、尺寸、图标,组件内部自己消化,不牵扯原生逻辑。
1.2 订单步骤条的功能拆解
先看看订单步骤条在真实业务里要承担哪些职责:
- 展示订单当前所处阶段,比如“待付款 → 已付款 → 已发货 → 已签收”。
- 当前步骤要突出显示,已完成步骤要有明显的“已完成”视觉反馈,未完成步骤要弱化。
- 支持用户点击步骤节点切换查看对应阶段详情(如果业务需要的话)。
- 支持横向排布(一行展示)和纵向排布(左侧时间轴样式)。
- 步骤数量不固定,可能3步,也可能8步,要能自适应宽度。
- 可能出现失败状态,比如“支付失败”,节点需要展示异常样式。
这些需求放在一个组件里,就需要我们抽象一套清晰的数据模型和状态机。一开始我脑子里的设计很朴素,直接用一个current字段加两个颜色变量,结果真写到第六步的时候发现逻辑根本理不清。后来我参照Redux处理状态的方式,给步骤节点的状态做了枚举,整体代码瞬间清爽了。
1.3 组件库的分层策略
既然目标是“组件库”,就不能只做一个孤零零的步骤条。我在这套代码里同时埋了分层设计:
- 展示层(View Layer):只负责根据数据渲染UI,不关心数据从哪来。
- 逻辑层(Logic Layer):处理步骤状态流转、点击回调、受控模式等。
- 适配层(Adapter Layer):针对RNOH差异做兼容,比如字体缩放、安全区、自定义组件注册等。
这样做的好处是,以后如果要接Taro、要接纯ArkUI,只需要换掉适配层,核心状态逻辑可以直接复用。对于团队来说,这种设计投入的边际成本很低,但长期维护价值很高。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 组件API与数据模型设计
2.1 定义步骤条的数据模型
代码写得好不好,一半看数据模型定义得好不好。这个组件的核心数据结构是这样的:
typescript复制export type StepStatus = 'wait' | 'process' | 'finish' | 'error';
export interface StepItem {
title: string;
description?: string;
status?: StepStatus;
icon?: React.ReactNode;
disabled?: boolean;
extra?: React.ReactNode;
}
export interface StepsProps {
items: StepItem[];
current?: number;
status?: 'process' | 'error';
direction?: 'horizontal' | 'vertical';
onChange?: (index: number) => void;
finishIcon?: React.ReactNode;
processIcon?: React.ReactNode;
errorIcon?: React.ReactNode;
labelPlacement?: 'horizontal' | 'vertical';
}
这里有个我反复调整过的细节:status到底放在StepsProps层还是放在每一个StepItem里?
最终我两个都支持。外层current决定默认状态,单个StepItem.status可以覆盖默认状态。因为订单流程里偶尔会出现中间某一步被跳过或置灰的情况,优先级必须是item.status > 自动计算状态。
2.2 状态流转规则
步骤条本质上是一个状态机,状态流转规则如下:
- 索引小于
current的节点:finish - 索引等于
current的节点:process(或error,如果传入status="error") - 索引大于
current的节点:wait
这套规则写起来很简单,但实际业务里会出现几个变种:
- 已完成节点点击回看:有的产品希望已完成节点可以点击,跳转到对应详情;未完成的不允许点击。这个用
onChange回调控制,组件内部不直接改current,只通知父组件。 - 部分完成状态:比如订单有5个步骤,其中第3步是“分拣中”,但实际第2步有两个子节点,一个完成一个未完成。这种属于嵌套步骤,后面扩展章节再展开。
受控模式是这个组件的重点。current由父组件传入,组件内部不维护自己的current状态,这样订单状态从服务端刷新下来时,current自动跟着变,不需要组件内部做同步。
tsx复制// 父组件使用方式
const [current, setCurrent] = useState(2);
<Steps
items={steps}
current={current}
onChange={setCurrent}
direction="horizontal"
/>
2.3 图标定制与默认行为
默认情况下,步骤节点按状态显示序号(1、2、3...)。但我强烈建议组件库默认就支持图标覆盖,因为订单场景里“已完成”显示对勾、“当前步骤”显示数字、失败显示叉号,是再常见不过的需求。
实现上我用一个renderIconByStatus函数,根据状态分发到对应的渲染函数:
tsx复制const renderNodeContent = (item: StepItem, index: number, status: StepStatus) => {
if (item.icon) {
return item.icon;
}
switch (status) {
case 'finish':
return finishIcon ?? <Text style={styles.finishText}>✓</Text>;
case 'error':
return errorIcon ?? <Text style={styles.errorText}>!</Text>;
case 'process':
return <Text style={styles.processText}>{index + 1}</Text>;
default:
return <Text style={styles.waitText}>{index + 1}</Text>;
}
};
图标组件我直接支持React.ReactNode,这样业务方可以塞任何自定义组件进去,不管是Image、Text还是动画组件,自由度最高。
3. 核心实现与技术细节
3.1 步骤节点的渲染结构
每个步骤节点我拆成三部分:
- 图标区(Icon):固定宽高,比如32x32或40x40,圆形背景 + 内容。
- 文本区(Content):标题 + 描述,
flex: 1撑开剩余空间。 - 连线区(Connector):节点之间的连接线。
结构大致是:
tsx复制<View style={[styles.itemContainer, horizontal ? styles.itemHorizontal : styles.itemVertical]}>
<View style={styles.nodeRow}>
<View style={[styles.iconWrap, statusColorStyle]}>
{renderNodeContent(item, index, status)}
</View>
{index < items.length - 1 && (
<View style={[styles.connector, connectorStyle]} />
)}
</View>
<View style={styles.contentWrap}>
<Text style={[styles.title, titleStyle]} numberOfLines={1}>
{item.title}
</Text>
{item.description ? (
<Text style={styles.description} numberOfLines={2}>
{item.description}
</Text>
) : null}
</View>
</View>
这里比较关键的是连线。一开始我直接用View的宽度做连线,发现当步骤数量不固定时,连线的长度很难算准。后来改成让连线区域通过flex: 1自动占满剩余空间,问题就解决了。
横向布局时,连线在图标右侧;纵向布局时,连线在图标下方,用固定高度加左右居中实现。
3.2 横向与纵向布局的切换
布局切换是这类组件最容易写乱的地方。我的处理方式是:准备两套样式,通过direction动态切换。
tsx复制const containerStyle = direction === 'horizontal'
? styles.horizontalContainer
: styles.verticalContainer;
const itemStyle = direction === 'horizontal'
? styles.horizontalItem
: styles.verticalItem;
横向布局时,外层是flexDirection: 'row',每个步骤节点用flex: 1等分宽度,文字对齐方式可以选择居中或左对齐。步骤文字过多时,需要在ScrollView内允许横向滚动,否则5个以上步骤会把文字压缩得没法看。
纵向布局时,整体是一列,每条步骤左侧是图标+竖线,右侧是文字内容。这种布局在移动端订单详情页里特别常见。
我建议在组件里同时暴露labelPlacement属性,控制文字是放在图标右侧还是图标下方。订单步骤条一般用horizontal放右侧,电商物流进度一般用vertical放下方。
3.3 动画与手势处理
步骤条的状态切换最好有动画,否则突兀地变颜色很生硬。我用了Animated库做两个动画:
- 图标背景色过渡:当步骤从
wait变成finish时,背景色从灰色变成主题色,做一个timing动画。 - 连线颜色过渡:已完成连线和未完成连线的颜色过渡。
tsx复制const bgColor = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(bgColor, {
toValue: status === 'finish' ? 1 : 0,
duration: 300,
useNativeDriver: false,
}).start();
}, [status]);
const backgroundColor = bgColor.interpolate({
inputRange: [0, 1],
outputRange: ['#e5e5e5', '#1677ff'],
});
注意,useNativeDriver在RNOH上对颜色动画的支持还不完善,所以背景色这种属性我都是设成false,用JS驱动。虽然性能差一点,但步骤条这种低频动画完全没问题。
点击手势我直接用Pressable包在最外层,点击时判断item.disabled,如果不禁用就回调onChange(index)。再加上一个pressed状态的透明度反馈,手感会好很多。
3.4 深色模式与无障碍支持
组件库如果连深色模式都不支持,上线后肯定要被视觉挑刺。这里我用useColorScheme来适配:
tsx复制const scheme = useColorScheme();
const isDark = scheme === 'dark';
const backgroundColor = isDark ? '#1c1c1e' : '#ffffff';
const textColor = isDark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)';
文字颜色、连线颜色、未激活节点颜色都要根据isDark动态切换。尤其是“未完成”的灰色,在深色模式下要用稍微亮一点的灰色,否则看不清边界。
无障碍方面,我给每个步骤节点加上accessible和accessibilityLabel,让读屏软件能朗读出“第二步,已发货,2025年3月10日”。这一步在订单详情页里还挺重要的,很多视障用户真的每天查快递。
4. 鸿蒙生态下的适配实践
4.1 RNOH当前差异点
我从React Native 0.72迁移到鸿蒙侧时,发现react-native-harmony这个适配层已经在持续迭代,API对齐度比我预想中高,但有几个差异值得注意。
字体缩放(fontScale):鸿蒙系统允许用户在设置里调整字体大小,RNOH会把这个缩放值透传下来。如果你的步骤条用了固定宽高的节点,系统字体调到特大时,文本会把节点撑破。我踩过这个坑,最后处理方案是给节点文本加adjustsFontSizeToFit或干脆把节点宽高改成根据内容自适应。
zIndex兼容性:RNOH上zIndex在某些场景下不生效,尤其是多个绝对定位元素重叠时。我们的连线穿过图标底部时需要把图标层级抬高,如果不加elevation只看zIndex,会发现连线把图标盖住了。鸿蒙侧的解法是同时设置zIndex和elevation,双保险。
自定义字体:鸿蒙系统跟iOS和Android的字体加载机制不太一样,RNOH加载.ttf字体文件的方式还在完善中。步骤条里如果业务用了特殊字体,建议先走默认系统字体验证渲染效果,再排查字体注册问题。
4.2 HAR封装与组件库发布
鸿蒙原生应用是以HAR(Harmony Archive)为单位打包复用代码的。RNOH在鸿蒙侧的集成也有对应的HAR方案。具体流程是这样:
- 在DevEco Studio里创建
Har模块,把react-native-harmony适配层和自定义的原生TurboModule封装进去。 - 步骤条组件里如果不需要自定义原生模块,那JS侧可以直接打包进
bundle,无需额外写原生代码。 - 如果组件像后续要做的“审批流时间轴”一样调用系统能力(比如日期选择、日历),就需要用
TurboModule暴露原生接口,再封装成HAR给业务方集成。
这里有个团队协作的细节:HAR包要指定package.json入口,业务方安装依赖后,通过import { Steps } from '@company/rn-steps'直接使用。发布到私有npm源时,要把构建产物(lib/或dist/)一起带上去,否则业务方拿不到编译后的组件。
4.3 组件库里的“暗坑”清单
这几个坑是我连续加班排查出来的,列出来给后来人提个醒。
月份和日期格式化:鸿蒙侧的JavaScript引擎在Intl.DateTimeFormat上支持不完整,某些options参数会被静默忽略。如果你在步骤条里显示“3月10日”,千万别依赖Intl的完整实现,直接用getMonth() + 1这种原生方法拼字符串更稳。
键盘避开模式:如果步骤条单元格内嵌了输入框(比如审批意见),在鸿蒙上keyboardAvoidingView的behavior不能用padding,建议改用height。这个我是真被坑过,表单页里的步骤条加输入框,软键盘一弹出来,内容直接顶飞。
AVPlayer与视频步骤:更复杂的业务里步骤条节点可能要嵌视频预览,RNOH早期版本的视频组件对H.265硬解支持有限。如果你要做视频审核流的步骤展示,先在鸿蒙真机上验证视频组件兼容性,模拟器上一切正常不能作为判断依据。
4.4 启动白屏的排查清单
这个系列经常有人问RN工程跑到鸿蒙上一启动就白屏,我这次也踩到了。白屏排查顺序我整理成一个清单,每一步都有明确的操作目的:
- 确认Bundle加载路径:在
MainAbility的onWindowStageCreate里打印loadBundle的路径,确认har里的bundle路径跟设置的一致。不一致会导致JS代码根本没加载,白屏必然。 - 确认JS引擎初始化:RNOH默认支持Hermes,但如果是老版本迁移,要检查
JSBundleProvider是否返回了正确的bundle。Hermes字节码和JavaScript文本格式不对也会白屏。 - 检查原生组件注册:如果步骤条里用到自定义原生组件但没有在
ArkTS侧注册,RN渲染树构建失败,会白屏且没有明显报错。打开DevTools看控制台,如果出现Invariant Violation或Component "xxx" is not registered,就是这个问题。 - 验证
getBundleUrl方法:在鸿蒙侧自定义RNInstance时,getBundleUrl要指向你实际打包的bundle位置。项目里曾有人同时存在两个bundle入口,导致加载了错误的初始页。 - 使用DevEco日志过滤:在DevEco Studio的Log窗口过滤
ReactNativeJS,能看到RN侧的console.log和报错。白屏时优先看这一栏,比盲猜高效得多。
5. 常见问题与排查技巧实录
5.1 步骤状态不同步
这是我们自己团队实际提过的一个issue:页面从服务端拉取订单状态后,current已经变了,但步骤条UI没刷新。
排查后发现是受控组件的PureComponent在作怪。current虽然变了,但items数组引用没变,浅比较直接跳过了更新。解决方案很简单:父组件每次更新订单时,重新拷贝数组:
tsx复制setSteps(prev => [...prev]); // 强制生成新数组引用
或者干脆把组件改成非纯组件,在shouldComponentUpdate里自定义比较逻辑。
5.2 固定高度导致的节点错位
横向步骤条里,每个节点的高度是固定的48px,但当描述文字换行时,节点被撑高,连接线就错位了。
我的处理方案是给连接线设置固定高度并垂直居中,不让它跟随文字高度变化:
tsx复制const connectorStyle = {
flex: 1,
height: 2,
marginTop: isHorizontal ? -16 : 0, // 手动对齐图标中心
};
这种手工微调方式不优雅,但胜在简单、可控、不会引入额外计算。如果项目里要求自适应高度,后续可以把节点行和连接线拆成绝对定位,用onLayout测量图标位置再计算连线坐标。
5.3 动画掉帧与性能优化
步骤条节点数量超过10个、且每个节点都在做背景色动画时,低端鸿蒙设备上能明显感受到掉帧。
我的优化手段有这几层:
- 减少动画节点数量:只对状态变化的节点做动画,不变化的节点直接渲染最终样式。
- 用
React.memo包裹单个节点:让未变化的节点不重新渲染。 - 拉平嵌套结构:把图标、文字、连线摊平到同一层级,减少View嵌套深度。RNOH的布局引擎对深层嵌套的样式计算更慢,尽量控制在3层以内。
tsx复制const StepNode = React.memo(({ item, index, status }) => {
return (
// 渲染单个节点
);
});
5.4 组件如何在鸿蒙模拟器上调试
鸿蒙模拟器目前有平台限制,只支持arm64架构,这点跟iOS模拟器类似。很多开发者在x86的Windows机器上配置模拟器,弹出“运行设备不兼容”的提示,这是模拟器本身的架构限制,不是代码问题。
我实际调试步骤条的主力方案是:
- 真机调试,连接DevEco Studio,实时看日志。
- 用
DevEco Studio的Profiler抓性能帧数据,排查动画掉帧。 - 在RN侧用
console.log输出关键参数,配合鸿蒙Log看ArkTS侧的报错。
真机调试是最靠谱的,尤其是涉及到字体缩放、深色模式、屏幕尺寸适配时,模拟器表现和真机差距很大。
6. 从步骤条到复合组件库的演进
6.1 下一步的扩展方向
步骤条做完之后,我发现它天然可以扩展成很多业务组件:
- 审批流组件:节点上支持头像、审批意见、时间线,本质上就是带人员信息的垂直步骤条。
- 物流轨迹时间轴:纵向步骤条加展开收起功能,显示包裹轨迹详情。
- 多级步骤条:每个步骤下面再挂子步骤,适合复杂制造业订单。
这些组件都可以复用步骤条的核心状态机和布局逻辑,只要把节点内容变成插槽(Slot)即可。
6.2 服务端动态下发步骤配置
后面我们团队计划让步骤条支持服务端下发配置,通过JSON定义每个节点的标题、状态、图标、跳转链接。这样做的好处是订单流程调整时,不需要发版App,后台配置一下就能生效。
JSON Schema大致长这样:
json复制{
"direction": "horizontal",
"steps": [
{
"title": "创建订单",
"status": "finish",
"time": "1720000000000"
},
{
"title": "仓库处理中",
"status": "process",
"time": "1720003600000"
}
]
}
组件内部做一个parseSchema方法,把JSON转成内部StepItem[],然后走已有的渲染流程。这样就把步骤条从“写死的UI组件”升级成了“配置驱动的业务组件”。
我的最终体会
这整套步骤条组件开发下来,最深的一个感受是:跨平台组件库的难点,从来不在“写出一个能用的组件”,而在“写出一个在三个平台上表现一致的组件”。鸿蒙侧的适配远比我想象中多,每一个看似通用的RN API,都可能因为ArkUI的渲染差异而表现不同。所以做这类组件一定要养成一个习惯:核心逻辑用纯TypeScript写,不依赖任何平台API;平台差异全部收口到适配层。这样不管鸿蒙适配层怎么更新,我们的业务代码都不用动。
最后分享一个实操小技巧:开发步骤条这类复合组件时,强烈建议在项目里同时写一个Storybook形式的Demo页,把10种不同状态组合全部铺在同一屏。我每调整一次布局,就看一眼Demo页,很多错位和样式问题都是在这页上一眼发现的,比到业务页面里大海捞针高效得多。
