1. 项目场景拆解:当 React Native 撞上鸿蒙,状态管理成了第一个坎
“RN鸿蒙跨平台”这个标题,我在立项评审里听了好几遍,第一反应是先评估能不能真跑,第二反应才是状态管理怎么做。真等我把 RN 的鸿蒙适配层跑起来,才发现最大的工程难点既不是打包,也不是桥接,而是“页面的状态”怎么跟着原生端的生命周期一起进退。这个结论值得展开写一写,给同样在这条路上踩坑的团队一个参照。
先说背景。团队原来负责一套基于 React Native 的跨端业务,同时覆盖 Android 和 iOS,现在新增鸿蒙系统的覆盖需求。评估了三个方向:纯鸿蒙原生重写、用 Flutter 平替、让 RN 通过适配层支持鸿蒙。最后选了第三个方案,理由很简单:业务代码已经沉淀了大量 JS 组件库、公共 Hook 和后台逻辑,与其推倒重来,不如把 RN 的渲染层切到鸿蒙的 ArkUI 组件体系上。听起来顺理成章,但方案落地后,页面反复进出、前后台切换、数据拉取这些原本很普通的场景,开始陆续出问题。
问题集中在同一个字眼上:生命周期。RN 在 Android 和 iOS 上各自有成熟的生命周期管理经验,但是到了鸿蒙侧,页面归属在 UIAbility 里,页面路由、窗口转场的时机和 RN 组件的挂载卸载并不同步。如果状态管理还沿用原来的写法,页面关掉以后请求还在跑、监听器还挂着、全局 store 里塞满了再也没有人消费的数据。这类问题有一个共同根因:状态的生命周期没有跟页面的生命周期绑死。
这正好把焦点引到了 React Hooks 的 useState 和 useEffect 上。仔细想,这两个 API 的语义——状态在组件内存活、副作用在清理函数里释放——几乎是教科书级地与“页面进来初始化、页面离开清理”的诉求对齐。下面我会从方案取舍、核心原理、完整代码、踩坑记录四个层面,把这个选型的逻辑讲透。
1.1 这个方案到底解决了谁的痛点
如果你在下面这三种处境里,这篇内容会比较对胃口。
第一种,已经在用 React Native 做跨端业务,正准备覆盖鸿蒙设备。你并不需要重新发明状态管理方案,而是需要一套能在新平台生命周期下稳定工作的轻量方案。Hooks 这套东西用好了,迁移成本极低。
第二种,刚接触鸿蒙应用开发,想搞清楚 RN 与鸿蒙的系统级生命周期怎样衔接。你不需要一上来就啃复杂的底层适配原理,直接看代码里 useEffect 如何对应页面销毁、AppState 如何对应前后台切换,更容易建立直觉。
第三种,正在做技术选型,纠结页面状态是继续交给 Redux/MobX,还是先用 Hooks 收拢。这篇里面关于代价的分析和实测对比,可以作为判断依据的起点。
1.2 “跨端生命周期模型”到底指什么
很多文章爱用“生命周期模型”这个词,但不讲清楚是哪一层生命周期。实际在一个 RN 鸿蒙混合工程里,存在三层独立的生命周期,理解不了这三层的关系,后面所有状态管理问题都会变得很难排查。
第一层是应用级生命周期,对应进程创建、退后台、回前台、销毁。鸿蒙侧挂在 UIAbility 的 onCreate、onForeground、onBackground、onDestroy 等回调上。
第二层是页面级生命周期,对应一个界面从打开到关闭的完整过程。鸿蒙里每个页面实例有自己的路由栈和显示/隐藏回调。
第三层是 React 组件生命周期,也就是每个组件从挂载、更新到卸载的节点。
三层之间并不等长。最典型的情况:用户按 Home 键把应用退到后台,React 组件并没有卸载,整个组件树还活着,只是不可见;用户从列表页 push 到详情页,详情页打开时列表页也没有被销毁,只是被覆盖。这就会产生一个错位——组件层感知不到原生层的可见性变化,原生层的销毁事件也可能比组件卸载来得更快或更晚。
传统的全局状态管理库往往只盯着第三层,对第一层和第二层“睁一只眼闭一只眼”。于是你经常遇到这种诡异现象:页面从后台切回,界面上展示的还是昨天缓存的数据;页面返回到列表,详情页的请求回调突然触发了一次 setState;某个监听器在页面被 push 覆盖后继续收到事件,状态被反复修改。
理解了这三层关系,才能明白标题里“轻量级状态管理方式完美适配 React Native 的跨端生命周期模型”这句话,不是概念拼凑,而是真真切切地把状态作用域和副作用清理机制,收敛到了和页面同生共死的粒度上。
2. 为什么不选 Redux/MobX,Hooks 轻量方案好在哪
2.1 重型状态管理库在跨端场景下的三笔代价
我不否认 Redux/MobX 在大型业务里的价值。多模块共享复杂业务状态时,集中管理确实有优势。但是放在 RN 跨端、尤其加了一个鸿蒙适配层的场景里,代价会被放大。
第一笔代价:全局 store 的生命周期比页面长。Redux 的 store 活在进程周期里,页面关闭后状态不会自动删除。页面 A 拉了一堆列表数据塞进 store,页面关闭后,除非显式 dispatch 清理 action,否则这些数据就一直躺在内存里。B 端应用如果页面多、单页数据量大,这个问题会在频繁进出页面后越来越明显,最后变成“手机内存不足”的背锅侠。
第二笔代价:跨端页面事件的同步链路太长了。页面要响应原生层的 AppState 变化、路由切换、窗口转场,这些事件要派发到全局 store,组件再通过 selector 把需要的片段取回来。中间隔了 action、reducer、selector、连接层好几层,随便一个事件流链路出问题,Debug 时要在 JS 层和原生日志之间来回切换,非常消耗精力。
第三笔代价:样板代码。一个“页面可见时拉取数据、离开时取消请求”的需求,用 Redux 要拆成 action type、action creator、reducer、selector、中间件,写下来要几十行,而且每加一个页面就要重复一遍。MobX 好一点,但依然要定义 observable class、处理 inject、observer。这类高频需求如果都要过全局状态,整个工程的隐性成本非常高。
还有一个更隐隐的代价:新人接手时看不懂状态从哪来、到哪里去。我经手过一个项目,页面从第一版迭代到第三版,全局 store 里残留了三版各自的数据切片,谁都不敢清理,因为不知道是否还有组件在引用。在跨端场景里,页面生命周期复杂度会掩盖状态归属,这个问题尤其容易滋生。
2.2 Hooks 为什么能对齐生命周期
Hooks 给出的答案是:把状态收进组件作用域,把副作用收敛在 useEffect 的清理函数里。组件挂载时,useState 初始化数据;组件卸载时,cleanup 执行释放动作。它不需要一个“全局注册表”去理解页面是否已经关闭,因为组件树的卸载本身就宣告了页面生命周期的结束。
这套模型与 RN 跨端生命周期有三个契合点:
契合点一,组件卸载与页面关闭天然同步。页面销毁时,RN 组件树里对应节点卸载,useEffect 的 cleanup 会被调用。这个机制保证了页面相关的监听、请求、定时器都能在正确的时机释放。
契合点二,组件重新挂载等于页面重新打开。React 会先清理上一次的 effect,再执行新的 effect,状态重新初始化。等于页面每次进入都是“干净”的,不会有上一轮残留。
契合点三,应用退后台时组件不卸载。此时组件树仍然存在,需要借助 AppState 这类原生事件来驱动状态判断。这个在 Hooks 里也可以通过自定义 Hook 封装,把系统事件转译成组件状态。
这三个契合点叠加起来,你会发现页面状态的管理权真正回到了页面自己手里。不像全局 store,状态生命周期和组件生命周期之间永远隔着一层无法逾越的中间层。
3. useState 与 useEffect 在鸿蒙适配下的真实行为
3.1 useState 的跨端更新链路,以及一个容易犯的错
RN 的 setState 不是同步生效。执行 setState 后,React 调度器会先做协调(reconciliation),算出本组件子树里哪些节点的 props 或 state 发生了变化,然后生成更新指令,通过桥接层或 JSI 通知原生端,最后原生端执行真正的布局和绘制。在鸿蒙适配层里,RN 的 View、Text 等基础组件会被映射成 ArkUI 对应组件,更新指令最终落到 ArkUI 的组件上。
这套链路下,useState 最大的优势是作用域小。状态只影响当前组件及其子树,不会引发无关页面的重渲染。我在项目里实测过:页面内如果订阅了全局 store 的数据,store 里任意字段变化都可能触发通知,导致原生端短时间高频刷新,页面肉眼可见地卡顿。改用 useState 后,更新范围被限制在组件子树里,原生端收到的更新指令数量少了一个量级。
但有个错误必须点名:不要把“页面环境状态”复制进 useState。窗口尺寸变化、系统深色模式、字体缩放这类由鸿蒙系统统一管理的参数,不适合放进组件私有状态。它们应该由原生环境事件驱动,通过 useSyncExternalStore 这类订阅机制去消费。我见过一个案例,把系统字体缩放值复制到了 useState 里,结果用户在系统设置里改一次字体大小,页面上所有依赖这个 state 的组件各自刷新,闪动非常明显。这类状态天然属于 UI 环境,不应该由组件复制后再分发。
3.2 useEffect 的时机选择,决定了页面会不会“阴魂不散”
useEffect 的设计本质是把副作用和渲染时机绑定,同时提供清理机制。这个清理机制就是跨端场景最重要的武器。
回到鸿蒙的典型场景:用户从列表页 push 进详情页,再从详情页返回。你的诉求是,列表页从隐藏变为可见时重新拉取可能更新的数据;详情页销毁时,它发出的网络请求被取消,避免响应回来后 setState 到一个已经不存在的组件。
useEffect 可以这样实现:
useEffect(() => { // 页面组件挂载:初始化数据、注册事件 fetchData(); return () => { // 页面组件卸载:清理事件、中止请求 cancelRequest(); }; }, []);依赖数组的用法也要分开讲。第一种,只想在挂载时执行一次、卸载时清理一次,依赖数组传空:
useEffect(() => { const subscription = AppState.addEventListener('change', handleChange); return () => subscription.remove(); }, []);第二种,依赖某个业务参数,参数变化时重新执行副作用:
useEffect(() => { fetchDataByPage(pageId); }, [pageId]);这两种对应完全不同的场景。第一种处理“原生页面级事件”,比如前后台切换、网络状态变化;第二种处理“业务数据更新”,比如筛选条件变化、页面参数变化。混用就会出事:把一个只在更新时改变的局部变量放进依赖数组,会循环触发;漏掉依赖,闭包里使用旧值,可能拿到上一次的页面 id。
还要特别提醒一点:鸿蒙适配层对“页面被覆盖”这个状态,并不一定映射到 React 组件的卸载。比如用户从 A 页 push 到 B 页,A 页组件并没有卸载,只是不可见。此时 A 页的 useEffect cleanup 不会执行。如果业务想在“页面重新可见”时刷新,不能只靠 useEffect,必须配合 AppState 或鸿蒙侧的可视性回调。这个问题我们在第 5 节专门展开。
4. 实操:一套直接能复用的跨端生命周期 Hooks
4.1 先封装 useIsForeground,感知应用前后台切换
跨端应用最常见的一个需求:应用进入后台超过一段时间,回到前台时刷新数据。不处理的话,用户第二天打开 App,看到的还是昨天缓存的内容。
这个 Hook 要解决的核心问题是把 AppState 原生事件转译成组件能消费的状态,同时做好监听器的清理。
import { useEffect, useRef, useState } from 'react'; import { AppState } from 'react-native'; export function useIsForeground(backgroundTimeout = 0) { const [isForeground, setIsForeground] = useState( AppState.currentState === 'active' ); const lastBackgroundTime = useRef(0); useEffect(() => { const subscription = AppState.addEventListener('change', (nextState) => { if (nextState === 'active') { setIsForeground(true); } else if (nextState === 'background') { lastBackgroundTime.current = Date.now(); setIsForeground(false); } }); return () => { subscription.remove(); }; }, []); return isForeground; }这里有几个值得说明的细节。AppState 的 addEventListener 在较新版本的 RN 里会返回一个 Subscription 对象,清理时必须调用 remove()。早期版本用的是 removeEventListener,且传入的处理器必须是同一个引用。如果在新版本上套旧写法,会出现清理失效,监听器越积越多,同一个 AppState 事件触发多次,页面进出几次后事件回调像爆米花一样乱蹦。
backgroundTimeout 参数处理的是“退后台多久之后回来才需要刷新”的业务规则。上面的代码只给出了骨架,实际业务里可以在这个 Hook 里记录退后台的时间,回到前台时用 Date.now() 减去 lastBackgroundTime 判断是否超过阈值,超过就通过一个返回的布尔值触发业务方刷新。
4.2 再封装 useFetchData,带防过期机制的数据请求
第二个基础 Hook 是网络请求。跨端页面的请求比 Web 页面复杂的地方在于:页面销毁是一个异步过程,请求不一定能立刻取消;而且部分后端服务完全不支持请求取消。最稳妥的做法不是强行 abort 请求,而是给每个请求打一个“代次标识”,页面卸载后回来的响应直接丢弃。
import { useCallback, useEffect, useRef, useState } from 'react'; export function useFetchData(fetcher, dependencies = []) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const requestIdRef = useRef(0); const run = useCallback(async () => { const requestId = ++requestIdRef.current; setLoading(true); setError(null); try { const result = await fetcher(); if (requestId === requestIdRef.current) { setData(result); } } catch (e) { if (requestId === requestIdRef.current) { setError(e); } } finally { if (requestId === requestIdRef.current) { setLoading(false); } } }, [fetcher]); useEffect(() => { run(); return () => { requestIdRef.current += 1; }; }, dependencies); return { data, loading, error, refresh: run }; }这个封装的核心思路是用 requestIdRef 做“代次判断”。每次发起请求,自增 requestId;每次组件卸载,requestIdRef 也自增。这样,即使请求还在路上,等 Promise resolve 之后发现 requestId 对不上,就会直接丢弃结果,不再 setState。
为什么不用 AbortController?因为实际项目里,请求被封装在公司自研的请求库中,底层可能走了 WebSocket、可能走了长连接、也可能走了内置的网络模块,并不都支持 abort。而且页面销毁的时机和 Promise 回调的时机存在毫秒级偏差,你无法保证 abort 一定发生在回调之前。用“代次判断”从逻辑上免疫了这类竞态问题,这种做法在跨端生产环境里更稳。
注意事项:fetcher 必须是一个使用 useCallback 稳定包装的函数。如果把每次渲染都会新建的函数直接塞给 useFetchData,dependencies 比对永远不相等,useEffect 就会反复触发,造成死循环。
4.3 组合实战:一个页面同时使用两个 Hook
把上面的 Hook 组合起来,一个典型的跨端页面就是这个样子:
import React, { useEffect } from 'react'; import { View, Text, Button, StyleSheet } from 'react-native'; import { useFetchData } from '../hooks/useFetchData'; import { useIsForeground } from '../hooks/useIsForeground'; function fetchArticleList() { return fetch('/api/articles').then((res) => res.json()); } export default function ArticleListPage() { const isForeground = useIsForeground(30000); const { data, loading, error, refresh } = useFetchData(fetchArticleList, []); useEffect(() => { if (isForeground) { refresh(); } }, [isForeground, refresh]); if (loading && !data) { return <View style={styles.center}><Text>加载中</Text></View>; } if (error) { return ( <View style={styles.center}> <Text>加载失败</Text> <Button title="重试" onPress={refresh} /> </View> ); } return ( <View style={styles.container}> <Text style={styles.title}>文章列表</Text> {(data || []).map((item) => ( <Text key={item.id} style={styles.item}>{item.title}</Text> ))} <Button title="刷新" onPress={refresh} /> </View> ); }整体逻辑链很清晰:进入页面,useFetchData 立即发请求;页面退到后台,isForeground 变为 false;超过 30 秒再回来,isForeground 变为 true,触发 refresh;页面销毁,useFetchData 的 cleanup 递增 requestId,之前请求的响应即使回来也被丢弃。
这套组合在鸿蒙、Android、iOS 三端都能跑,关键是它把原生平台生命周期事件和 React 组件生命周期事件统一到了一套 Hooks 调用模型里,页面里不用再手写各种监听器注册和清理函数。
5. 踩坑实录:跨端生命周期接入中的典型问题与排查方法
5.1 AppState 初始值不准,页面刚启动时状态判断错误
在鸿蒙适配层的某些版本里,AppState.currentState 并不是在页面第一个组件执行时就完成了初始化。如果代码里直接用 AppState.currentState === 'active' 做首屏判断,可能拿到 undefined 或者上一次会话遗留的旧值。
我们的处理办法是:启动时不依赖第一帧的精确值,默认按“前台”处理,等 AppState 第一次 change 事件到达后再修正。这个策略对大多数业务场景足够安全,因为用户刚打开 App 时大概率处于前台,即使判断出错,也只会让首屏多做一次刷新,不会造成漏刷新。
5.2 useEffect 的 cleanup 不执行,请求悬挂在后台
有同事反馈“页面销毁了,但接口还在发”。排查了半天,发现不是 React 的问题,而是鸿蒙适配层的路由在某些场景下没有及时把页面组件从组件树上摘除。尤其是通过系统返回键直接销毁页面时,RN 侧组件树的卸载存在延迟,cleanup 可能没触发。
这个教训很重要:不要迷信 useEffect 的 cleanup 一定会在页面销毁时执行。适配层路由的时序差异客观存在。我们的方案是“双保险”,除了 useEffect,还要配合页面可见性回调,在页面不可见时主动取消或丢弃请求。useFetchData 里的 requestId 代次机制正好能承担这层保障,即使组件树那边没及时卸载,只要页面不可见回调触发代次递增,后续响应也会被丢弃。
5.3 全局 store 的监听残留,页面关了还能收到事件
项目早期沿用旧工程的 Redux 方案,结果出过一次线上问题:用户从 A 页进 B 页再返回,B 页的某个监听器没有注销,导致 B 页每次发消息,A 页都会收到并 setState。最终定位发现是 B 页面在连接层注册的监听依赖框架自动清理,但框架在鸿蒙适配下清理失败。
这就是用 Hooks 治理状态能避免的问题:监听器的生命周期和组件严格绑定,组件卸载必然触发 useEffect cleanup,不存在“在全局 store 里注册了东西但忘记注销”的路径。这属于架构层面的预防,而不是靠排查。
5.4 依赖数组里放了对象或函数,造成死循环
React 对 useEffect 依赖的比对规则很严格,跨端场景里更容易触发死循环。我见过一个页面把 filter 对象直接放进依赖数组:
useEffect(() => { fetchData(filter); }, [filter]);如果 filter 是每次渲染都会新建的对象,那么每次组件更新时比对结果都不相等,useEffect 就会重新执行,执行后又触发 setState,再引起更新,循环往复。在鸿蒙适配下这种循环很难发现,JS 侧没有明显报错,只有 CPU 在快速拉升。
正确做法是:把对象拆成基础字段,例如分别依赖 filter.category、filter.keyword;或者用 useMemo 把对象稳定化。实在要依赖一个复杂对象时,可以自己用 JSON.stringify 做值比对,但要注意字段顺序会让比对结果失准。
5.5 常见问题速查表
把上面的经验浓缩成一张排查表,直接对照着用:
| 现象 | 常见原因 | 建议处理 |
|---|---|---|
| 页面返回后又收到请求回调 | 未在卸载时取消请求 | 用代次标识丢弃过期响应 |
| 后台切回后数据不刷新 | 过度依赖组件卸载,没监听 AppState | 用 useIsForeground 配合超时判断 |
| 监听器重复触发 | 旧 API 的清理方式失效或漏调用 | 使用新版 Subscription 对象并正确 remove |
| 进入页面就死循环刷新 | 依赖数组里有对象或函数引用 | 用 useMemo/useCallback 稳定引用 |
| 页面销毁后请求仍在进行 | 适配层路由未及时卸载组件 | 可见性回调加卸载清理双重保险 |
| AppState 首屏判断错误 | 初始值尚未初始化完成 | 默认按前台处理,等第一次事件修正 |
这张表的每一行,都是我在实际调试中至少花过半天时间才定位到的根因。建议你把这些经验沉淀到团队的纠错文档里,比在群里口头聊一百遍都管用。
6. 还能继续深挖的延伸方向
上面这套 Hook 组合,在工程里还能继续延伸出不少变体。比如做一个 usePolling,本质是把 setInterval 建立在 useEffect 之上,利用 cleanup 清理定时器,页面对应轮询、退出即停;比如做一个 useNetworkStatus,监听网络类型变化,断网时自动清除缓存数据;再比如做一个 usePageVisible,把鸿蒙页面自身的显示/隐藏回调统一封装,供所有页面消费。
我后来在另一个跨端项目里,把页面状态治理收敛成了三个固定模式:页面自有数据用 useState,跨模块共享的业务数据走轻量级的 Storage 缓存层,系统环境状态用 useSyncExternalStore 订阅。这套组合既没有丢掉全局通联能力,又避免了旧式全局 store 带来的生命周期失控。
我的体会是,“轻量级状态管理”的轻,不只是代码量少、依赖少,更在于状态的生命周期边界清晰。用 Hooks 写页面时,每一次数据请求、事件监听、定时器,都清楚地归属于某个页面,页面离开即清理。这种边界感,恰好是跨端生命周期模型最需要的。
如果你手头正在推进 RN 鸿蒙适配,建议先拿一个小页面试水,把 Hooks 这套模型跑顺,再逐步往业务层铺。状态管理的架构一旦理顺,后续加页面、加平台、加业务模块,都会顺畅很多。