这两年做React Native开发的同学,基本都绕不开鸿蒙。react-native-harmony(大家习惯叫RNOH)从最早的尝鲜版本到现在,已经能稳定承接不少业务页面了。但真正把项目迁到鸿蒙上之后你会发现,RN那套生态里的好东西,像TanStack Query(React Query)这种纯JS/TS数据层方案,是可以直接平移过来的,配合无限滚动列表,体验做出来相当顺滑。
这篇文章不聊虚的,就围绕“React Native鸿蒙版 + React Query无限滚动”这套组合,从环境准备、useInfiniteQuery的核心机制、完整代码实现,到鸿蒙端特有的坑和排查思路,一次性讲透。适合正在做RNOH迁移的团队,也适合准备在鸿蒙上从零搭建列表类应用的同学参考。
1. 整体设计与思路拆解:为什么这套组合值得用
1.1 鸿蒙端RN应用的数据层选型
先说说我为什么在鸿蒙版项目里坚持用React Query,而不是回到redux + saga那一套老路上。RNOH是基于React Native官方框架的鸿蒙适配分支,它的特点在于:JS层代码几乎不用改,原生侧由OpenHarmony的C++/ArkTS实现桥接。这意味着你在RN生态里积累的纯逻辑库,比如axios、React Query、dayjs,都可以继续用。
React Query在鸿蒙端带来的核心价值有三个:
- 自动管理服务端状态缓存,列表页来回切换不重新发请求
- 请求去重,多个组件同时依赖同一份数据时,只发一次请求
- 无限滚动开箱即用,useInfiniteQuery帮你维护分页数据和加载状态
相比redux-thunk/saga那一套,React Query不需要你手写action、reducer、selector的流水线,对鸿蒙这种需要快速迭代的业务场景非常友好。而且它不依赖任何原生模块,纯TypeScript实现,RNOH环境跑起来没有任何额外适配成本。
1.2 无限滚动的常见技术路线
做无限滚动列表,业内主流有三条路:
- 手动维护page状态 + FlatList的onEndReached回调,自己拼数组、自己处理loading和error
- 用useInfiniteQuery自动管理分页数据,配合FlatList的onEndReached触发fetchNextPage
- 用FlashList这类高性能列表组件 + 自封装分页hook
第三条对鸿蒙RNOH来说暂时不太现实,因为FlashList依赖原生代码,而RNOH的第三方原生组件生态还没有那么全。第一条太原始,代码里到处是setState和useEffect,页面复杂以后很难维护。
所以我选了第二条。useInfiniteQuery把分页加载、缓存、重试、数据拼接都封装好了,我们只需要提供getNextPageParam函数告诉它“下一页怎么取”就行。这种声明式的写法,特别适合鸿蒙上那种页面逻辑复杂但开发周期又紧的场景。
1.3 整页结构拆解
一个完整的无限滚动页面,拆开来看其实就四层:
| 层 | 职责 | 对应实现 |
|---|---|---|
| API层 | 定义请求函数和返回类型 | request.ts |
| Hook层 | 组合React Query的useInfiniteQuery | useArticleList.ts |
| UI层 | FlatList渲染 + 加载触发 | ArticleListView.tsx |
| 状态层 | 缓存、过期、持久化 | React Query Client配置 |
这样分层的最大好处是:UI层只关心数据和回调,完全不碰请求逻辑;Hook层只关心数据获取策略,不关心页面长什么样。后面鸿蒙端出了兼容性问题时,你可以精准定位到底哪一层出了问题,不用从头到尾翻代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:RNOH工程里接入React Query
2.1 版本选择是第一道坎
RNOH的版本和RN官方版本是错位对应的,不要随便装。以react-native-harmony目前最稳定的0.72.x分支为例,它对应的就是React Native 0.72.10。装依赖的时候一定要保持这个对应关系,否则会出现原生侧接口对不上,编译都过不了的情况。
我自己用的是一套经过验证的组合:
bash复制react-native: 0.72.10
react-native-harmony: 0.72.20
@tanstack/react-query: ^5.51.0
react-native-safe-area-context: 4.10.0(鸿蒙适配版)
React Query装的时候留意一下peerDependencies,它要求react >= 18,RN 0.72默认就是React 18.2.0,没问题。
2.2 安装与工程初始化
创建RNOH工程有两种方式:一是直接clone官方脚手架模板,二是在现有RN工程里通过替换依赖的方式接入鸿蒙。如果你是从零开始,推荐用脚手架:
bash复制npx @react-native-community/cli init RNHarmonyDemo --version 0.72.10
cd RNHarmonyDemo
npm install react-native-harmony@0.72.20
npm install @tanstack/react-query
装完后用DevEco Studio打开项目里的harmony文件夹,等它同步完依赖,构建出hap包,就能跑到模拟器或真机上了。首次构建时间会比较长,建议先跑通一个空页面,确认RNOH基本链路没问题,再集成React Query,这样排查问题会简单很多。
我踩过的一个教训是:不要在空页面都没跑通的情况下就堆一堆依赖,否则出问题的时候你根本不知道是RNOH的锅还是某个库的锅。
2.3 QueryClient配置
创建一个专门的queryClient实例,项目里所有页面共用:
ts复制// src/queryClient.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1分钟内不重新请求
gcTime: 5 * 60 * 1000, // 5分钟不用的缓存自动回收
retry: 2, // 失败重试2次
refetchOnWindowFocus: false, // 鸿蒙端不做窗口聚焦刷新
},
},
})
注意refetchOnWindowFocus在鸿蒙上建议关掉。RNOH对AppState的监听在某些版本上有延迟,会导致列表滚动时突然触发刷新,体验很怪。
然后在根组件包一层QueryClientProvider。
3. useInfiniteQuery的核心机制:搞懂它才能用好它
3.1 核心API逐参数解读
useInfiniteQuery和普通useQuery最大的区别是:它维护的是一个“数据页数组”,而不是单个数据对象。核心参数有这几个:
ts复制const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isPending,
isError,
refetch,
} = useInfiniteQuery({
queryKey: ['articles'],
queryFn: ({ pageParam }) => fetchArticles(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) => {
return lastPage.hasMore ? lastPage.page + 1 : undefined
},
})
逐个说:
- queryKey是缓存标识,鸿蒙端页面卸载再进来时,React Query会根据这个key判断是直接返回缓存还是重新请求
- queryFn接收一个pageParam参数,这个参数就是当前要请求的页码或游标
- initialPageParam是第一页的页码,必须给
- getNextPageParam是灵魂函数,它接收最后一页数据,返回下一页的页码;返回undefined就表示没有更多数据了
data.pages是每一页的数据数组,data.pageParams是对应的页码数组。渲染列表时要把pages扁平化成一个数组给FlatList用,这个下面代码部分会写。
3.2 分页模型选择:页码还是游标
分页模型我推荐优先用游标(cursor)。鸿蒙端列表场景一般有下拉刷新和深度跳转,游标的好处是:即使数据源有新增或删除,也不会出现页码错位导致重复或遗漏。
游标分页的返回结构一般长这样:
json复制{
"items": [],
"nextCursor": "MTY5MjUwNzAwMA",
"hasMore": true
}
nextCursor由服务端生成,客户端只负责透传。如果服务端暂时不支持游标,退而求其次用page/pageSize也行,但要注意getNextPageParam里做越界判断,否则最后一页会重复请求。
3.3 缓存与数据新鲜度设计
无限滚动页面最忌讳的是:用户滚到第10页,切到别的页面再回来,数据全没了,或者从第1页重新加载。React Query的缓存机制天然解决了这个问题。
配合staleTime和gcTime的设计逻辑是:
- 列表在staleTime内回来,直接读缓存,不重新请求,滚动位置也可以保持
- 超过staleTime但没超过gcTime,React Query会先返回旧数据渲染,再在后台重新请求,更新后触发UI刷新
这种“陈旧数据立即显示 + 后台静默更新”的体验,比传统的loading转圈要好得多。鸿蒙端网络库如果配置合理,这种体验优势会更明显。
4. 核心代码实现:一个完整可跑的无限滚动页面
4.1 API层定义
先定义一个通用的分页请求函数。这里用fetch,因为RNOH对fetch的支持比较完善,底层会走鸿蒙的socket栈,不需要额外适配。
ts复制// src/api/article.ts
export interface Article {
id: string
title: string
summary: string
coverUrl: string
}
export interface ArticlePage {
items: Article[]
nextCursor: string | null
hasMore: boolean
}
export async function fetchArticles(cursor?: string): Promise<ArticlePage> {
const params = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''
const response = await fetch(`/api/articles${params}`)
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
return response.json()
}
4.2 Hook层封装
ts复制// src/hooks/useArticleList.ts
import { useInfiniteQuery } from '@tanstack/react-query'
import { fetchArticles, Article } from '../api/article'
export function useArticleList() {
return useInfiniteQuery({
queryKey: ['articles'],
queryFn: ({ pageParam }) => fetchArticles(pageParam),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => {
return lastPage.hasMore ? lastPage.nextCursor : undefined
},
// 把pages扁平化成一条列表数据,方便UI层直接使用
select: (data) => ({
pages: data.pages,
pageParams: data.pageParams,
articles: data.pages.flatMap((page) => page.items) as Article[],
}),
})
}
select函数值得单独强调一下。它不只是“方便”,而是能显著减少组件重渲染次数。如果不做select,UI层每次拿到的data.pages都是多层嵌套数组,渲染时都要做一次flatMap,数据量大时会有多余计算。用select提前扁平化后,FlatList拿到的就是一条干净的Article[]。
4.3 UI层:FlatList + onEndReached
核心的列表页面长这样:
tsx复制// src/screens/ArticleListView.tsx
import React from 'react'
import { ActivityIndicator, FlatList, Text, View, Pressable } from 'react-native'
import { useArticleList } from '../hooks/useArticleList'
export function ArticleListView() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isError, refetch } = useArticleList()
const articles = data?.articles ?? []
if (isPending) {
return <LoadingView />
}
if (isError) {
return <ErrorView onRetry={() => refetch()} />
}
return (
<FlatList
data={articles}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <ArticleCard article={item} />}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}}
onEndReachedThreshold={0.3}
ListFooterComponent={
isFetchingNextPage ? <LoadingFooter /> : hasNextPage ? <Text>上拉加载更多</Text> : <Text>已经到底了</Text>
}
/>
)
}
两个容易被忽略但很重要的点:
- onEndReached里必须判断hasNextPage和isFetchingNextPage,否则会重复触发请求,鸿蒙端FlatList在快速滚动时onEndReached有时会连续触发多次
- onEndReachedThreshold设成0.3比较合适,意思是距离底部还剩30%屏高时就提前加载下一页,给用户“无感加载”的体验
4.4 下拉刷新与错误重试
无限滚动页面一般都要配下拉刷新。React Query的refetch函数直接复用:
tsx复制import { RefreshControl } from 'react-native'
<FlatList
...
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={() => refetch()}
colors={['#4A90D9']}
progressBackgroundColor="#FFFFFF"
/>
}
/>
注意这里的refreshing不要用isFetchingNextPage,而是用isRefetching。isFetchingNextPage是加载更多时的状态,如果用它控制下拉刷新动画,用户会看到刷新指示器无限转。isRefetching才是下拉刷新触发的重新请求状态。
错误重试方面,React Query默认会重试2次,但无限滚动场景里,如果用户已经滚到第5页,第6页请求失败,重试2次还不够的话,建议手动提供一个底部重试按钮:
tsx复制function LoadingFooter() {
return (
<View style={{ paddingVertical: 16 }}>
<ActivityIndicator size="small" color="#4A90D9" />
</View>
)
}
function ErrorFooter({ onRetry }: { onRetry: () => void }) {
return (
<Pressable onPress={onRetry} style={{ paddingVertical: 16 }}>
<Text style={{ textAlign: 'center', color: '#4A90D9' }}>加载失败,点击重试</Text>
</Pressable>
)
}
4.5 空态与首屏占位
鸿蒙端的列表页空态不建议直接用ActivityIndicator全屏转圈,因为RNOH首屏渲染本身有一定耗时,全屏loading会让用户觉得“卡死”了。我习惯的做法是:保留页面骨架,只在内容区域显示loading。
5. 鸿蒙端踩坑实录:这些问题你大概率也会遇到
5.1 启动白屏:RNOH的“第一道坎”
热搜词里“react native 启动白屏”排在前面,确实是RNOH刚上手时最劝退的问题。我自己排查过多次,原因集中在几个点:
- RNOH的bundle加载是在原生侧完成的,如果assets目录里的bundle文件没打包进去,启动时就会白屏
- 某些版本上jsvm引擎启动耗时较长,白屏时间会更明显
- 如果你用了Hermes,RNOH部分版本只支持JavaScriptCore,切换引擎也会导致问题
排查方法很简单:DevEco Studio里看logcat日志,如果出现“LoadBundle failed”之类的错误,基本就是bundle路径或格式问题。另外建议在SplashScreen阶段预留一个最小加载动画,让白屏时间从“用户感知”变成“用户无感”。
5.2 模拟器的arm64限制
“运行设备不兼容鸿蒙模拟器目前只能在arm64平台运行jsvm”这条热搜词,很多人没看懂。简单说:OpenHarmony模拟器目前只支持arm64架构,如果你在x86的Windows机器上跑模拟器,jsvm引擎根本起不来,报了各种莫名其妙的错。
我的建议是:有条件直接用鸿蒙真机,开发者模式 + DevEco Studio的自动部署,比模拟器稳定得多。没有真机的话,模拟器必须在Apple Silicon Mac或者ARM架构的Windows机器上跑,否则别浪费时间折腾。
5.3 FlatList在鸿蒙新架构下的性能表现
RNOH 0.72.x在鸿蒙上默认走的是新架构(Fabric),FlatList的事件回调会比旧架构更频繁地触发,onEndReached偶尔会连续触发两次。前面提到在fetchNextPage调用处加保护是第一步,还有一个做法是配合useRef做节流:
tsx复制const loadingRef = useRef(false)
const handleLoadMore = async () => {
if (loadingRef.current || !hasNextPage) return
loadingRef.current = true
await fetchNextPage()
loadingRef.current = false
}
另外getItemLayout可以的话尽量给上,固定高度的卡片能显著减少鸿蒙端滚动时的计算量:
tsx复制getItemLayout={(_, index) => ({
length: CARD_HEIGHT,
offset: CARD_HEIGHT * index,
index,
})}
5.4 官方调试工具在鸿蒙上的局限
React Query的官方Devtools依赖react-native的DevSettings菜单,在RNOH上偶尔调不出来。我的排查方案是:在页面里临时挂一个调试面板,显示当前query状态和缓存信息。项目里可以放一个DebugBanner组件,只在开发环境渲染:
tsx复制// 开发环境调试面板
function DebugBanner() {
if (!__DEV__) return null
return (
<View style={{ backgroundColor: '#FFF3CD', padding: 4 }}>
<Text>hasNextPage: {String(hasNextPage)} | fetching: {String(isFetchingNextPage)}</Text>
</View>
)
}
这种方式比Devtools直观得多,而且不用依赖原生侧菜单。
5.5 React Query离线缓存与鸿蒙存储的对接
React Query的persistQueryClient可以配合AsyncStorage做缓存持久化,但RNOH对AsyncStorage的支持需要单独集成版本。如果你不想引入额外依赖,可以手动把React Query的关键状态存到鸿蒙的Preferences里,页面启动时先读缓存再发请求:
ts复制import { preferences } from '@ohos.data.preferences'
// 简化示例:只持久化文章列表缓存
const cached = await preferences.get('articles_cache', '')
if (cached) {
queryClient.setQueryData(['articles'], JSON.parse(cached))
}
这个方案的优势是:冷启动时用户能立刻看到上次浏览过的列表,不需要等网络请求回来。代价是需要自己控制缓存版本,服务端数据模型变了记得清缓存。
6. 性能优化与后续扩展
6.1 分页粒度怎么定
分页大小建议每页20到30条。太小了onEndReached触发频繁,白白增加请求次数;太大了首屏加载慢,在鸿蒙模拟器上尤其明显。我做过测试,同一批数据每页20条比每页10条,滚动到底部时请求次数减少约40%,整体帧率也更稳定。
6.2 配合har/hsp分包
鸿蒙工程里RN这块业务如果希望作为独立模块提供给宿主App,可以打包成har或hsp。RNOH对这块的支持已经比较成熟,React Query因为是纯JS库,会直接打进bundle里,不需要额外处理原生依赖。这意味着你完全可以把“RN页面 + React Query数据层”封装成一个鸿蒙har包,多个宿主App共用。
6.3 推荐的后续扩展方向
- 列表项曝光埋点:在FlatList的onViewableItemsChanged里实现,注意配合 viewabilityConfig
- 预取下一页:当前页渲染完成后,调用queryClient.prefetchInfiniteQuery预取下一页数据,让翻页更丝滑
- 服务端分页切换为游标:如果目前还是页码分页,建议尽早切换到游标,避免数据量变大后页码漂移问题
- 接入请求取消:React Query 5的signal参数可以配合AbortController,在组件卸载时取消未完成的请求,对鸿蒙省电和内存都有帮助
6.4 我的几点体会
做RNOH这段时间,最大的感受是:鸿蒙适配并没有想象中那么“伤筋动骨”。像React Query这种纯数据层方案,几乎是无缝迁移的。真正的难点在于原生环境差异,比如启动白屏、模拟器架构限制、FlatList的滚动性能,这些都是需要实测才能发现的。
另外,鸿蒙生态迭代很快,热词里那些“鸿蒙模拟器”“鸿蒙打断点”“鸿蒙开发”相关的问题,很多版本更新后解决方案就变了。所以我建议:不要盲目追新版本,认准一个稳定版本组合,先把业务跑通,再考虑升级。我在0.72.x分支上已经把无限滚动、下拉刷新、缓存持久化一条链路全部跑顺,目前稳定运行在生产环境,这也是我为什么敢把这套方案分享出来的底气。
