HarmonyOS上做React开发,听起来有点绕,但实际上React Native for OpenHarmony(后面统一叫RNOH)这摊事已经能跑了。我维护的这套开源教程《玩转React》前十七篇都在讲组件、状态管理和网络层,到了第十八篇,正好借“课程详情页面”这个真实场景,把前面的知识点串起来。这篇文章我会把页面拆解、核心模块的实现思路、以及我在真机调试时踩的坑全部写出来,代码逻辑和踩坑记录都是直接能从项目里捞出来复用的。
先交代一下这个页面要解决的问题:用户在首页点开一门课程,进入详情页后需要看到课程封面、标题、简介、课时列表、评价信息,以及底部“立即购买”的固定操作栏。这是典型的电商+内容混合型页面,技术点上涵盖了轮播图、富文本渲染、长列表加载、状态批处理、安全区适配等多个方向。适合正在用RNOH做鸿蒙应用、又想保持React开发习惯的团队参考,也适合刚接触HarmonyOS开发、想找一个完整页面案例的读者。
1. 页面需求拆解与方案选型
1.1 这个页面到底要做什么
课程详情页是知识付费类App里最核心的落地页,用户从任何入口进来,最终都要在这个页面决定“买不买”“学不学”。所以页面不只是展示信息,还要承担转化任务。我拆解功能时列了一个清单,确保开发过程中不遗漏:
- 课程封面区域:多图轮播,支持手势滑动和自动播放;
- 课程基本信息:标题、讲师、价格、学习人数、评分;
- 富文本详情:课程大纲、适合人群、讲师介绍,由后端返回HTML片段;
- 课时列表:可展开折叠,展示每节课的名称、时长、试听标识;
- 评分与评论:只展示前几条热门评论,支持跳转到全部评论页;
- 底部操作栏:收藏+购买/试听按钮,购买按钮随时根据课程状态变化。
这个页面在React Native for OpenHarmony上实现时,有几个地方跟原生ArkTS页面思路不一样。RNOH里没有“Ability切片”这种概念,页面就是React组件,路由用react-navigation管理,页面之间通过参数传递课程ID。这跟Web端React开发的心智模型几乎一样,团队上手成本低很多。
1.2 为什么不用ArkTS原生,而是选React
很多做鸿蒙开发的朋友看到“React”就皱眉,觉得HarmonyOS就应该用ArkTS写。我不否认ArkTS是鸿蒙一等公民,官方生态也最全。但现实情况是,很多团队手里已经有一份React Native的代码库,或者团队主要技术栈是React,这时候再为鸿蒙单独维护一套ArkTS代码,成本很高。RNOH的价值在于复用,它能把现有的React Native业务代码跑到鸿蒙设备上,补齐iOS和Android之外的第三端。
技术选型上,我建议按这个标准判断:
- 如果是从零启动、只做鸿蒙的单端应用,直接学ArkTS更省事,没必要绕一圈React;
- 如果已经有多端React Native代码,或者团队React经验明显强于ArkTS,RNOH是降低维护成本的好方案;
- 如果App对硬件能力(蓝牙、NFC、传感器)有强依赖,现阶段ArkTS的API覆盖更完整,React那边还得等桥接生态慢慢补。
我们这个教程系列定位是“玩转React”,就是假设你熟悉React但不太熟鸿蒙,所以全程用RNOH思路来讲。课程详情页用到的轮播图、富文本、列表组件,RNOH社区都已经有可用的库,真正要花时间处理的是鸿蒙端特有的样式兼容和性能细节,这些我后面会重点展开。
1.3 技术栈和依赖版本
我在这个教程系列里统一固定了一套版本组合,避免大家在依赖版本上踩坑:
| 依赖 | 版本 | 说明 |
|---|---|---|
| react | 18.2.0 | 使用并发特性和批处理机制 |
| react-native | 0.72.x | RNOH基于这个版本适配 |
| @react-navigation/native | 6.x | 页面导航 |
| react-native-swiper | 1.6.x | 轮播图组件 |
| react-native-render-html | 6.x | 富文本渲染 |
| @react-native-community/slider | 4.x | 底部操作栏相关滑动需求 |
RNOH的版本对应关系比较固定,0.72版本对应OpenHarmony 4.x。开发时我直接参考官方仓库的sample工程初始化项目,比自己在空工程里一个个装依赖稳得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 课程详情页的整体架构设计
2.1 页面结构拆成组件树
页面本身就是一个大组件,但如果把全部逻辑堆在一个文件里,后期没法维护。我按功能域拆成了六个子组件,每个组件只负责一块独立逻辑:
code复制CourseDetailScreen
├── CourseSwiper # 封面轮播
├── CourseInfo # 标题、讲师、价格、学习人数
├── CourseRichContent # 富文本详情
├── CourseChapterList # 课时列表(可折叠)
├── CourseComments # 热门评论
└── BottomActionBar # 收藏 + 购买按钮
拆分的核心标准是“独立状态”。比如轮播图有当前索引状态,ChapterList有展开收起状态,BottomActionBar有收藏状态的本地缓存,这些状态彼此不相关,拆开以后各自维护,父组件不需要关心它们内部怎么变化。父组件只负责拉取课程数据,然后通过props分发下去。
组件树设计的一个细节是数据形态。后端返回的课程详情是个嵌套结构,比如course对象里有chapters数组,chapters里又包含lessons数组。如果直接把原始对象传给子组件,子组件访问路径会很长而且容易出错。我在实战中会在父组件里把数据重新整理成扁平结构,比如把课时列表单独抽出来,让ChapterList只接收lessonList数组,它的props就清晰多了。
2.2 数据模型与网络层设计
课程详情接口返回的数据结构设计得好不好,直接影响页面渲染的复杂程度。我见过很多后端把富文本、课时、评论全塞在一个大JSON里返回,前端解析起来很痛苦。我们的项目里按模块拆分接口,三个并行请求:
/api/course/detail:课程基础信息+富文本内容;/api/course/chapters:课时列表;/api/course/comments:热门评论列表。
为什么拆开?因为页面加载时,轮播图和基本信息要优先展示,评论和课时列表可以等主内容渲染完再异步填充。如果三个模块在一个接口里,弱网环境下首屏内容反而要等最慢的那部分数据。拆开之后,我用Promise.allSettled并行请求,各自setState各自渲染,首屏体验明显更好。
详情页的数据我定义成一个明确的TypeScript接口,在团队协作里很有用:
typescript复制interface CourseDetail {
id: string;
title: string;
teacher: string;
coverUrls: string[];
price: number;
originalPrice: number;
studentCount: number;
rating: number;
richContent: string;
chapters: Chapter[];
}
interface Chapter {
id: string;
title: string;
lessons: Lesson[];
}
interface Lesson {
id: string;
title: string;
duration: number;
isFree: boolean;
}
这个模型设计有两点很关键:第一,价格字段用数字类型,不用字符串,否则后面做计算和比较时容易出隐式类型转换的坑;第二,所有ID明确为string,虽然后端返回的是数字,但string类型在跨端处理时更安全,比如Android的Intent传参和鸿蒙的router传参都要求字符串。
2.3 页面状态管理与加载流程
课程详情页的状态分为三层:加载中、加载成功、加载失败。我设计了一个轻量级的页面状态机,没有引入Redux或者Zustand,因为这三个状态用useState就能管理,引入全局状态管理库反而增加心智负担。
typescript复制const [pageState, setPageState] = useState<'loading' | 'success' | 'error'>('loading');
const [course, setCourse] = useState<CourseDetail | null>(null);
加载流程和用户体验直接挂钩。我的实现方式是:进入页面后立即展示骨架屏,同时并行发起三个网络请求。一旦主接口返回,立刻setCourse并渲染首屏;课时接口和评论接口慢一点没关系,各自模块显示自己的loading状态。这里有个值得分享的细节:用户从短时间离开页面再返回,数据其实还在内存里,不需要重新请求。我用useFocusEffect结合useRef做了一次简单缓存,避免每次聚焦都重新拉接口。
3. 核心模块的实操实现
3.1 轮播图组件:手势冲突与性能优化
轮播图是课程详情页的头号视觉组件,如果做得卡顿或者手势不流畅,用户第一印象就会变差。我先说结论:不要自己造轮子,直接用react-native-swiper。我刚开始也觉得这个组件太基础,想用ScrollView+pagingEnabled自己写,但鸿蒙端真机测试时发现,手势滑动偶尔出现方向不稳定,后来排查发现是RNOH对手势响应链的处理跟iOS有细微差异,自己调Gesture Responder太浪费时间。
直接用react-native-swiper,配置也简单:
tsx复制<Swiper
style={{ height: 220 }}
autoplay={true}
autoplayTimeout={4}
loop={true}
showsPagination={true}
paginationStyle={{ bottom: 8 }}
>
{course.coverUrls.map((url) => (
<Image key={url} source={{ uri: url }} style={styles.coverImage} />
))}
</Swiper>
这里我把图片裁剪成16:9的比例,既符合课程封面主流尺寸,又能减少大图加载时的内存压力。RNOH环境下一张宽度750像素、高度超过1000像素的图片,如果原图直接加载,多张轮播图叠加起来内存占用会失控,所以我额外用resizeMethod="resize"配合后端返回的压缩图URL,而不是让客户端去裁原图。
踩过的坑是:自动播放的定时器在页面离开时没有清除,导致从详情页返回后,轮播图还在后台跳动。react-native-swiper自带onScrollBeginDrag和onScrollEndDrag事件,我在组件卸载时手动处理一下,保证页面不可见时轮播不跑:
tsx复制useEffect(() => {
return () => {
// 组件卸载时停止轮播
if (swiperRef.current) {
swiperRef.current.autoplay = false;
}
};
}, []);
3.2 React 18更新批处理机制在收藏按钮上的妙用
讲到React 18,批处理机制(Batching)是这版更新最实用的特性之一。批处理说白了就是:React在同一个事件处理函数里,多次调用setState,不会立刻触发多次渲染,而是合并成一次渲染。React 18之前,只在React事件系统里支持批处理,Promise回调、setTimeout回调、原生事件回调里是不批处理的。React 18把批处理扩展到了所有场景。
这个特性在课程详情页里最有价值的场景是“点击收藏”。收藏按钮的交互逻辑里,一次点击要同时做三件事:更新收藏图标状态、更新收藏数量、发送网络请求。我把这三件事拆成三个状态变量,虽然可以合并成一个对象,但分开写更直观:
typescript复制const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(0);
const [favoriteLoading, setFavoriteLoading] = useState(false);
const handleFavoritePress = async () => {
const nextFavorited = !isFavorited;
// 这三个setState在同一个异步函数里,React 18会自动批处理
setIsFavorited(nextFavorited);
setFavoriteCount((prev) => nextFavorited ? prev + 1 : prev - 1);
setFavoriteLoading(true);
try {
await api.updateFavorite(course.id, nextFavorited);
} finally {
setFavoriteLoading(false);
}
};
在React 18之前,await后面的setFavoriteLoading(false)和前面的三个setState不在同一批里,会触发两次渲染,表现为按钮状态先变一下、loading转圈图标再闪一下,视觉上有轻微抖动。React 18的自动批处理把整个函数内的setState都合并成一次渲染,UI状态切换和loading状态同时完成,交互明显更顺滑。这个细节很多人没注意到,但在真机上对比一下就能感受到差别。
另外,React 18的useTransition在详情页也有一个可用点:课时列表折叠展开时,如果每节课包含大量子内容,展开操作可能短暂阻塞UI。我用了startTransition来标记“展开状态更新”为低优先级,这样用户拖拽页面时,列表展开不会抢滚动动画的帧率:
typescript复制const [expanded, setExpanded] = useState(false);
const { startTransition } = useTransition();
const toggleExpand = () => {
startTransition(() => {
setExpanded(prev => !prev);
});
};
3.3 课时列表:长列表性能与折叠交互
课时列表的数据量不会特别大,一门课几十节课很正常。但每次页面滚动都要逐帧渲染这些行,如果每个行内嵌很多复杂的子组件,还是容易出现掉帧。我用的方案是FlatList套SectionList的思路,但实际实现更简单,直接用一个ScrollView嵌套Chapters,因为课时列表不是虚拟化的核心场景,数据量大到几百条时再换FlatList不迟。
课时列表的行组件设计里有一个性能关键点:每行的展开状态不要在全局state里存,而是控制在子组件内部。因为如果用父组件的state记录expandedChapterId,那么每次展开一个章节,整个FlatList的header和footer都会重新渲染。我把展开状态放在ChapterItem组件自己的useState里,这样切换展开状态只影响当前组件。
tsx复制const ChapterItem = ({ chapter }: { chapter: Chapter }) => {
const [expanded, setExpanded] = useState(false);
return (
<View style={styles.chapterItem}>
<TouchableOpacity onPress={() => setExpanded(prev => !prev)}>
<Text style={styles.chapterTitle}>{chapter.title}</Text>
<Text style={styles.chapterCount}>{chapter.lessons.length}节课</Text>
</TouchableOpacity>
{expanded && (
<View>
{chapter.lessons.map((lesson) => (
<LessonRow key={lesson.id} lesson={lesson} />
))}
</View>
)}
</View>
);
};
如果课时多到需要性能优化,再考虑用React.memo包裹LessonRow,配合useCallback处理onPress回调。这些优化在RNOH上的收益和RN一样明显,因为鸿蒙端的列表渲染同样面临原生渲染树和JS线程通信的瓶颈。
3.4 富文本内容:react-native-render-html在鸿蒙上的兼容性
课程详情里那段“课程大纲、适合人群”介绍,后端返回的是一整段带标签的HTML。React Native原生<Text>组件不支持HTML,之前不少人都手动写正则解析,遇到图片和加粗样式就崩。我用的方案是react-native-render-html,它的核心是把HTML解析成虚拟DOM树,再用RN的Text和Image组件渲染出来。
RNOH环境下这个库基本可用,但有两个兼容问题要处理。
第一,列表样式<ul> <li>默认渲染不出来。react-native-render-html对列表的支持依赖额外的htmlParserRules配置,我在鸿蒙端发现默认的列表规则不生效,需要手动给li标签添加前缀圆点符号。
第二就是图片宽度。富文本里的图片如果没写width属性,在RNOH里会按原图尺寸显示,超出屏幕屏幕直接溢出。我的处理办法是给renderers传入自定义Image渲染:
tsx复制const renderers = {
img: (htmlAttribs, children, convertedCSSStyles, passProps) => {
const { src, alt } = htmlAttribs;
return (
<Image
key={src}
source={{ uri: src }}
style={{ width: '100%', height: 180 }}
resizeMode="cover"
accessibilityLabel={alt}
/>
);
},
};
<HTML source={{ html: course.richContent }} renderers={renderers} />
我在封装富文本组件时特意加了一个containerStyle参数,业务方可以根据页面需要调整富文本整体外边距。这是我在实际项目中经常遇到的需求,不同页面希望富文本内容的间距不一样,写死在组件里后面就不好调。
3.5 底部操作栏:安全区适配与收藏交互
底部操作栏是固定在页面底部的“收藏+立即购买”区域,在任何滚动位置都可见。实现固定底部用绝对定位,这是RN里最直接的方式:
tsx复制<View style={[styles.bottomBar, { bottom: safeAreaInsets.bottom }]}>
<TouchableOpacity onPress={handleFavoritePress} style={styles.favoriteBtn}>
<Text>{isFavorited ? '已收藏' : '收藏'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleBuyPress} style={styles.buyBtn}>
<Text>{course.price > 0 ? `立即购买 ¥${course.price}` : '免费学习'}</Text>
</TouchableOpacity>
</View>
安全区适配这里特别提一下,RNOH上直接读取SafeAreaView有兼容问题,我改用react-native-safe-area-context,用它的useSafeAreaInsetshook来获取底部刘海区域的高度。HarmonyOS设备很多都带底部导航条,如果直接把按钮定位到bottom: 0,会被系统导航条挡住一部分,必须加上安全区的padding。
我踩过的坑是:SafeAreaView在鸿蒙端需要手动设置edges属性,否则上下左右都会加padding,跟我预期的“只在底部加”不一致。改用useSafeAreaInsets后,代码更可控,效果也更准。
4. 常见问题与排查技巧实录
4.1 鸿蒙真机调试:从HDB连接到WiFi无线调试
开发阶段在模拟器上跑通页面只是第一步,真正的问题往往在真机上才暴露出来。鸿蒙的调试工具叫hdc,作用和Android的adb类似。我最初是通过USB线连接真机,在DevEco Studio里直接运行工程。但后面真机来回插拔太麻烦,就配置了无线调试。
HarmonyOS 4.2开启无线调试的路径是:设置-系统-开发人员选项-无线调试,打开后记录下设备IP和端口号。然后在电脑端执行:
bash复制hdc tconn 192.168.1.100:5555
连接成功后,再执行hdc list targets确认设备状态,就可以正常运行RNOH应用。这里有个经验:WiFi调试模式下RNOH应用的hot reload延迟比USB模式高很多,如果频繁改样式,建议还是用USB线,等逻辑调稳定了再切无线调试验证真机交互。
调试过程中最常用的一组命令我整理一下:
| 操作 | 命令 |
|---|---|
| 查看设备列表 | hdc list targets |
| 安装应用 | hdc install /path/to/app.hap |
| 查看应用日志 | hdc hilog |
| 清理应用数据 | hdc shell bm clean -n com.example.app |
| 截屏 | hdc shell snapshot_display -f /data/local/tmp/screen.png |
4.2 细节排查:收藏状态刷新和购买流程的状态同步
收藏按钮有一个典型问题:在详情页收藏后,回到列表页发现列表页的收藏图标没有跟着变。这是因为详情页和列表页各自维护了一套状态,没有共享。React Native的全局状态方案在鸿蒙上,我先用的context,但后面发现页面多了之后context更新导致大量重渲染,性能不行。换成Zustand之后,只订阅收藏ID集合,列表页和详情页都从store里读取收藏态,问题就解决了。
这个问题的根因是“收藏状态到底属于页面还是属于全局”。从产品角度看,收藏是一个用户维度的全局数据,不是页面维度的局部数据,所以应该放全局store。用Zustand维护一个favoriteIds: Set<string>,详情页的收藏点击更新store,列表页组件通过selector订阅变化,两边的UI自然就同步了。
购买流程的按钮状态也有类似的坑。课程的价格可能是0元(免费课)、付费课、或者限时折扣,购买按钮的文案和可用状态不能写死。我的做法是给后端返回一个purchaseStatus字段,枚举值是free、buyable、sold,按钮的文案、颜色、点击行为全部由这个状态驱动,而不是前端去判断价格是不是0。这样后端可以灵活调整营销策略,前端不用发版。
4.3 长页面滚动卡顿的排查方案
课程详情页是一个内容很多的长页面,滚动时如果出现掉帧,最可能的原因是首屏渲染内容过多。第一次遇到卡顿时,我用DevEco Studio自带的性能分析工具抓了CPU profile,发现jsThread的占用率在滚动时持续高位,原因是我把整个页面都放在一个ScrollView里,富文本内容虽然是异步解析,但解析完成后一次性渲染了所有节点,导致JS线程忙不过来。
优化思路其实就一句话:把页面分区,让每个区块只在需要时渲染。我用的是react-native-lazy-view,给富文本、评论列表、课时列表都包了一层,只有滚动到对应区域时才触发渲染。这样滚动首屏时JS只处理轮播图和课程信息,后面区域等接近可视区才开始渲染。再加上React 18的startTransition标记低优先级更新,掉帧问题基本消失。
排查滚动卡顿的方法,我提个建议:不要只看网络面板,要看真机的渲染帧率。DevEco Studio的Profiler里能找到Frame相关的指标,如果帧渲染时间超过16ms就存在掉帧。在RNOH里,掉帧要么是JS线程卡,要么是原生渲染线程卡,用Profiler很容易区分。
4.4 RNOH环境下的图片加载问题
图片加载是详情页里最容易出问题的地方。RNOH的Image组件底层用的HarmonyOS的ImageKit,和RN的加载逻辑有差异。我遇到两个典型情况:
第一个是Gallery图片闪白。原因是图片从网络加载完成前,占位视图是空白。我的做法是在图片加载失败时展示一张本地占位图,加载中时展示一个低分辨率的模糊图,视觉上就不会突然一片白。用Image组件的defaultSource和onError回调配合实现。
第二个是图片内存溢出。课程介绍里的富文本图片,如果HTML里没有带尺寸,解析后加载原图内存占用会非常大。我在渲染富文本时统一限制图片高度,并且优先使用后端返回的压缩图URL(比如拼接?imageView2/1/w/750/h/400参数),这样既保证了显示效果,也控制住了内存峰值。
5. 状态联动与回流数据:详情页向列表页传递状态变更
前面提到收藏状态用Zustand全局store解决,但其实还有一层更细的联动:用户从详情页返回列表页时,列表页的课程卡片应该实时反映最新收藏状态。由于store是全局的,列表页只需要订阅对应课程ID的收藏态:
typescript复制const isFavorited = useFavoriteStore((state) =>
state.favoriteIds.has(courseId)
);
这种方式避免了“详情页回传数据给列表页”的复杂通信。团队里之前有人用navigation参数来回传,一旦页面层级深了就很难维护。Zustand的selector订阅是精准的,只有对应ID的状态变化才会触发组件重渲染,性能也比大范围的context要好。
除了收藏状态,学习进度也需要跨页面同步。用户在详情页看完第3节课,返回课程列表时,列表页应该显示“已学3/15节”。这个数据也不建议通过路由参数传,而是从全局store读取,或者重新拉接口。我倾向于重新拉接口,因为学习进度数据本来就要持久化到服务端,列表页useFocusEffect时重新请求,虽然多一次网络开销,但能保证数据准确,也避免了store维护“服务端数据”和“本地临时数据”的一致性难题。
6. 开源教程的工程化组织与后续规划
课程详情页是《玩转React》教程系列的第十八篇,但这个项目的工程化组织方式本身也值得一说。开源教程最怕的是代码和讲解脱节,读者照着文章敲代码,结果根本对不上。我的做法是每个篇目对应一个独立分支,比如lesson-18-course-detail分支,代码全部可运行,读者直接切分支就能看到当前教程对应的完整状态。
项目根目录的结构大致是这样的:
code复制learn-harmonyos-react/
├── src/
│ ├── pages/
│ │ ├── CourseDetail/
│ │ │ ├── index.tsx
│ │ │ ├── components/
│ │ │ ├── service.ts
│ │ │ └── types.ts
│ ├── store/
│ │ └── favoriteStore.ts
│ └── common/
├── package.json
└── README.md
这个组织方式还有一个好处:零基础的同学可以照着某一篇的commit记录,一篇篇把项目跑起来,逐步加功能。而每一篇的“学习目标”和“验收标准”都写在README里,读者可以先看验收标准,确认自己有没有学会,再去看代码实现。
后续规划里,我准备做两件事:一是把课程详情页的评论模块扩展成完整的评论列表页,涉及分页加载和无限滚动,这样能继续展示FlatList的高级用法;二是加入RNOH环境下的热更新方案,探索鸿蒙端怎么做到不用重新发版就能更新业务代码。这两个方向跟课程详情页都有直接关联,读者从这篇再接下去学习的路径是平滑的。
回到课程详情页本身,我个人在实际操作中最大的感受是:RNOH虽然还在快速演进,但对于“内容展示型页面”已经完全够用了,从轮播图到富文本再到列表交互,核心能力都能覆盖,真正花时间的反而是性能细节和真机兼容。如果你们团队也打算用React技术栈进入鸿蒙生态,建议从这种偏展示的页面切入,把轮子都跑起来,再逐步往复杂业务场景推进。等这个页面完整跑通,你手头其实已经有了一套可以复制到其他业务页面的基础设施。
