☰
鸿蒙RN开发中的Jotai状态管理实践:原子化异步处理与踩坑经验
2026/10/12 23:51:33 网站建设 项目流程

前阵子把手上那个跨端项目往鸿蒙上迁移,应用里状态管理用的就是 Jotai。团队里不少人一开始挺担心:RN 在鸿蒙上本身就是新环境,再加一层原子状态管理,会不会冒出各种兼容性问题?实际搞下来,状态管理这一环反倒比预想中顺,真正需要花心思的反而是原生能力桥接、异步时序还有生命周期对齐。这篇文章把整个接入过程、核心写法和踩过的坑完整整理一遍,给准备在鸿蒙上做 RN 开发的朋友做个参考。

先说结论:Jotai 这套“原子 + 派生 + 异步”的模型,和鸿蒙侧“JS 界面 + 原生能力模块”的混合架构配合得相当好。你不需要像 Redux 那样写一堆 action 和 reducer,也不需要在页面组件里层层透传 props。它本身是个很小的库,API 稳定,TypeScript 支持也到位,放进 RN 工程里几乎零成本。

我这次用的是 Jotai 2.x 版本,配合某厂商鸿蒙系统适配的 RN 运行时。下文所有代码都来自一个模拟项目 X 的实践,我尽量把关键步骤、参数选择和避坑点都写透。

1. 为什么在鸿蒙上做 RN 开发,偏偏选了 Jotai

1.1 鸿蒙端 RN 开发的状态管理痛点

先聊一个可能被忽略的背景:鸿蒙上的 RN 开发和普通 iOS/Android 开发,最大的区别是“双端生命周期不同步”。

RN 页面跑在 JS 引擎里,但很多数据源和系统能力在原生鸿蒙侧,比如电量、网络状态、传感器、账号信息。JS 侧想拿这些数据,要经过异步桥接到原生模块,回调返回的时机不可控。这时候如果状态管理方案对异步支持很弱,代码会迅速变成一堆 useEffect + setState 的嵌套地狱。

再加上鸿蒙适配层还在快速迭代,某些原生模块只在特定版本可用,JS 侧必须有“优雅降级”的能力。Jotai 的异步派生原子天然支持 pending / error / success 三态,用一个 hook 就能承接整个异步链路,这种体验在 Redux 时代要写很多样板代码。

1.2 对比 Redux、MobX、Zustand,Jotai 的原子模型适合什么场景

拿一张表快速说下我的选型对比:

方案状态模型学习成本TypeScript 支持异步处理和 RN 鸿蒙的配合度
Redux Toolkit单一 Store + reducer中高好需要额外处理 thunk/RTK Query通用,但样板代码多
MobX可变对象 + 依赖追踪中好用 flow 或 async action通用,响应式心智有一定门槛
Zustand单一 Store + 函数更新低好支持 async action通用,但跨模块状态共享不如原子轻便
Jotai原子状态 + 派生 + 异步 atom低极好原生支持异步 atom原子粒度契合“原生模块一个数据点对应一个 atom”的思路

Redux 不是说不行,而是它把“状态集中”和“状态共享”绑定得太紧。鸿蒙 RN 场景下,很多状态其实带有明显的生命周期边界,比如某个原生页面把它自己的数据传给 JS 侧,这个状态只在这个页面存活。Jotai 的 Provider 机制可以做到“页面级 store”,页面销毁即释放,不需要全局 store 里留着过期的数据。

MobX 的可变对象在跨原生边界传输时有个坑:原生模块回调返回的通常是普通 JSON 对象,你把它塞进 observable 之后,如果之后原生侧又发来一份新数据,要手动 reconcile,很容易出现部分字段已更新、部分字段没更新的中间状态。Jotai 的原子不可变更新语义,配合 immer 或手动 set,更像“重新赋值整个原子”,心智负担小很多。

Zustand 其实也不错,但它默认是全局 store,写 hooks 的时候要从 store 里 selector 拖字段。原子化场景里,我更希望“一个原生能力点对应一个独立状态单位”,Jotai 的 atom 在代码组织层天然做到了这一点。

1.3 另一个隐性收益:心智负担低

团队里新来的同学看 Jotai 代码基本不需要专门培训。atom 就是一个变量,useAtom 就是 useState 的平替,派生 atom 就是一个计算函数。这种渐进式认知曲线,对一个还要同时学习鸿蒙原生适配的团队来说太重要了。

2. 工程接入:从一张白纸到第一个原子

2.1 工程基础版本选择

先交代我这边测试的基线环境,方便你对号入座:

  • RN 版本:0.72 系鸿蒙适配版,JS 引擎走 Hermes 兼容模式
  • 鸿蒙系统 API:API 12 级别
  • 开发语言:ArkTS 写原生模块,TypeScript 写 RN 业务层
  • 包管理器:npm

不同版本的 RN 鸿蒙适配层在 native module 导出方式和 metro 配置上略有差异,但 Jotai 本身不依赖 RN 内部 API,所以版本兼容性主要看你能不能顺利把依赖装进工程,以及 Babel 插件是否和现有配置冲突。

2.2 项目初始化与依赖安装

在 RN 工程的根目录执行:

npm install jotai

这里有个小建议:Jotai 有个可选 Babel 插件,用来优化原子更新时组件重渲染的范围。官方文档建议加,但我在鸿蒙适配层上实测发现,部分版本开启后 HMR 会出现原子缓存不刷新的问题,所以最终选择不加。

如果你也想用,常规配置如下:

// babel.config.js module.exports = { presets: ['module:@react-native/babel-preset'], plugins: [ // 其他插件 ['jotai/babel/plugin'], ], };

不加插件时 Jotai 也能正常工作,性能差距在绝大多数业务页面可以忽略。优先保证构建链路简单,先把状态管理跑通,再回来做精细优化。

2.3 打通第一个数据链路

我习惯把所有原子定义收敛到src/atoms/index.ts,这样团队看状态定义时只需要找一个文件。

先模拟一个原生鸿蒙侧模块,通过全局对象暴露给 JS:

// src/native/Bridge.ts // 假设原生侧已经在 globalThis 上挂了一个 NativeBridge // 其中 getDeviceInfo() 返回一个 Promise<DeviceInfo> export const NativeBridge = globalThis.NativeBridge;

然后定义第一个原子:

// src/atoms/deviceAtom.ts import { atom } from 'jotai'; export interface DeviceInfo { deviceName: string; systemVersion: string; battery: number; } export const deviceAtom = atom<DeviceInfo | null>(null);

在最外层根组件包上 Provider:

// src/App.tsx import { Provider } from 'jotai'; import { HomeScreen } from './screens/HomeScreen'; export default function App() { return ( <Provider> <HomeScreen /> </Provider> ); }

在页面组件里读取并更新:

// src/screens/HomeScreen.tsx import { useAtom } from 'jotai'; import { useEffect } from 'react'; import { deviceAtom } from '../atoms/deviceAtom'; import { NativeBridge } from '../native/Bridge'; import { Text, View } from 'react-native'; export function HomeScreen() { const [device, setDevice] = useAtom(deviceAtom); useEffect(() => { NativeBridge.getDeviceInfo().then((info) => { setDevice(info); }); }, [setDevice]); if (!device) { return ( <View> <Text>加载中...</Text> </View> ); } return ( <View> <Text>设备名:{device.deviceName}</Text> <Text>系统版本:{device.systemVersion}</Text> <Text>电量:{device.battery}%</Text> </View> ); }

到这里已经打通了“原生鸿蒙模块 → Promise → 写入 atom → RN 界面消费”的第一条链路。这一段代码在打包到鸿蒙设备上运行时,延迟主要来自 Bridge 调用本身,Jotai 层开销基本可忽略。

3. 核心玩法拆解:原子粒度、派生计算与异步

3.1 原子粒度怎么设计才不后悔

很多人第一次用 Jotai 会走向两个极端:要么一个页面就一个 atom,里面塞一个大对象;要么一个字段建一个 atom,建了几百个。

我的经验法则很简单:一个原生数据点,一个独立变更源,对应一个 atom。

举例来说,从鸿蒙侧推送过来的网络状态变化,是一个独立变更源,那么networkStatusAtom单独存在。页面里用户输入的搜索关键词,是另一个独立变更源,那么searchKeywordAtom单独存在。而“根据搜索关键词过滤后的列表”,不是独立变更源,它是派生数据,不应该单独维护,而应该用派生 atom 去计算。

这个原则能避免大量“状态不同步”的 bug。我见过有人把过滤后的列表也单独存一个 atom,然后每次搜索都手动同步两份数据,一旦某个分支忘了更新过滤列表,UI 就展示旧数据,排查起来非常痛苦。

3.2 派生 atom:把计算逻辑收拢到状态层

派生 atom 是 Jotai 比 Redux selector 更好用的地方。它直接在状态定义层完成数据变换,组件只需要消费结果。

// src/atoms/todoListAtom.ts import { atom } from 'jotai'; export interface TodoItem { id: string; title: string; completed: boolean; userId: string; } export const todoListAtom = atom<TodoItem[]>([]); export const activeTodoListAtom = atom<TodoItem[]>((get) => { return get(todoListAtom).filter((item) => !item.completed); });

组件里直接读派生结果:

import { useAtomValue } from 'jotai'; import { activeTodoListAtom } from '../atoms/todoListAtom'; export function TodoList() { const activeTodos = useAtomValue(activeTodoListAtom); return ( // 渲染 activeTodos ); }

这里注意一个细节:Jotai v2 的atom派生函数接收的get参数,可以读取任意原子,并且框架会自动追踪依赖。当todoListAtom变化时,activeTodoListAtom自动重新计算结果,所有用到它的组件自动更新。你不用手动在组件里写 useMemo 依赖数组,也不用考虑“这个列表该在哪个模块里更新”。计算逻辑收拢在状态层以后,页面代码干净一大截。

3.3 异步 atom:承接原生异步能力的正确姿势

鸿蒙场景下大量数据都是异步来的,比如原生模块返回的定位信息、账号 token、剪贴板内容。Jotai 的异步 atom 可以直接返回 Promise,配合useAtomValue使用时会自动进入 pending 状态,也可以配合 Suspense 用。

// src/atoms/locationAtom.ts import { atom } from 'jotai'; import { NativeBridge } from '../native/Bridge'; export interface LocationInfo { latitude: number; longitude: number; timestamp: number; } export const locationAtom = atom<LocationInfo | null>(null); export const loadableLocationAtom = atom(async (get) => { const current = get(locationAtom); if (current) { return current; } const result = await NativeBridge.getLocation(); return result; });

然后组件里用 loadable 方式读取,避免 Suspense 带来的布局闪烁问题:

import { useAtomValue } from 'jotai'; import { loadable } from 'jotai/utils'; import { loadableLocationAtom } from '../atoms/locationAtom'; export function LocationView() { const locationLoadable = useAtomValue(loadable(loadableLocationAtom)); if (locationLoadable.state === 'loading') { return <Text>定位中...</Text>; } if (locationLoadable.state === 'hasError') { return <Text>定位失败:{locationLoadable.error.message}</Text>; } return ( <Text> 经度:{locationLoadable.data.longitude},纬度:{locationLoadable.data.latitude} </Text> ); }

注意一个坑:loadableLocationAtom里的get(locationAtom)只会在原子值变化时重新触发异步函数。但上面这段代码有一个问题,如果第一次调用getLocation()失败,locationAtom仍然是 null,第二次再依赖loadableLocationAtom时不会自动重试。我建议在失败场景下由用户触发重新加载:

function handleRetry() { // 写入一个有意义的标记值,触发重新执行异步 atom // 比如用 atomWithRefresh,或者简单点,set 一个递增计数 setRetryCountAtom((count) => count + 1); }

如果你想看更优雅的方案,Jotai 官方工具提供了atomWithRefresh,专门解决这个场景,直接用即可。

3.4 用 atomFamily 管理列表页的动态原子

列表页是另一个高频场景。进入某个消息列表明细,每个详情都有自己的加载状态、表单数据、错误信息。如果把所有详情状态塞进一个大数组 atom,切换详情页时需要手动按 id 查找并更新,代码繁琐还容易漏。

Jotai 的atomFamily正好处理这种按参数创建原子态的场景:

// src/atoms/detailAtom.ts import { atomFamily } from 'jotai/utils'; import { atom } from 'jotai'; import { NativeBridge } from '../native/Bridge'; export interface DetailData { id: string; content: string; status: string; } export const detailAtomFamily = atomFamily((id: string) => { return atom<DetailData | null>(null); }); export const detailLoadingAtomFamily = atomFamily((id: string) => { return atom<boolean>(false); });

详情页加载数据时:

const [, setDetail] = useAtom(detailAtomFamily(id)); const [, setLoading] = useAtom(detailLoadingAtomFamily(id)); async function loadDetail() { setLoading(true); try { const response = await NativeBridge.getDetail(id); setDetail(response); } finally { setLoading(false); } }

好处是每个 id 独立维护状态,互不干扰。列表页滚到一半时切出去再回来,详情数据还在,不会因为某个全局对象的改动全部失效。

要注意atomFamily默认会缓存所有创建过的原子,如果不做清理,长时间停留在列表页可能积累大量原子。官方提供了unstable_setAtomFamily或者其他手动清理方案,我这边实际业务里每页最多几十条详情,量级不大,暂时没做极端清理。

4. 鸿蒙场景下的状态边界与数据桥接

4.1 原生侧的数据进入 Jotai 的两条路径

在鸿蒙 RN 工程里,原生侧数据进入 Jotai 通常走两条路:

第一条是页面主动拉取。RN 页面通过 Bridge 调用原生模块的 Promise 方法,拿到结果后写入 atom。适合设备信息、账号信息这类一次性数据。

第二条是原生侧主动推送。鸿蒙原生模块在某些事件发生时(比如网络切换、系统音量变化)主动向 JS 侧发起事件。RN 适配层一般通过DeviceEventEmitter或自定义事件通道传过来。这时需要在 JS 入口监听事件并写入对应 atom:

// src/events/networkEvent.ts import { DeviceEventEmitter } from 'react-native'; import { networkStatusAtom } from '../atoms/networkStatusAtom'; export function registerNativeEventListeners() { const subscription = DeviceEventEmitter.addListener('onNetworkStatusChanged', (isConnected: boolean) => { networkStatusAtom.update(() => isConnected); }); return subscription; }

注意 Jotai v2 中atom本身可以通过 store API 直接更新。如果你在非组件文件里需要更新原子,有几种方式:

import { getDefaultStore } from 'jotai'; import { networkStatusAtom } from '../atoms/networkStatusAtom'; const store = getDefaultStore(); // 在原生事件回调里 store.set(networkStatusAtom, false);

这样就避免了把事件监听逻辑硬塞进 React 组件生命周期。我把所有原生事件注册收拢在src/events目录,一个文件对应一个原生事件,入口调用一个registerAllEvents()即可。

4.2 页面级 Provider 还是全局 Provider

刚开始我把所有原子都挂在全局 Provider 下,后来发现一个隐患:某些原子数据带有明显的页面边界,比如当前正在编辑的草稿内容。如果页面 A 正在编辑,用户退回首页再进入页面 B,全局 Provider 里的草稿状态还残留着,稍不留神就会把页面 A 的草稿展示到页面 B 上。

我的建议:全局 Provider 只放“真正全局”的状态,比如登录 token、用户信息、设备能力集。页面内部状态使用独立 Provider 包裹。

// src/screens/EditorScreen.tsx import { Provider, useAtom } from 'jotai'; import { draftContentAtom } from '../atoms/editorAtom'; export function EditorScreen() { return ( <Provider> <EditorContent /> </Provider> ); }

draftContentAtom定义不变,但它只存在于EditorScreen这个 Provider 子树里。页面卸载,Provider 销毁,草稿原子自然消失,不需要手动重置。

这个模式和鸿蒙原生页面栈的关系比较契合:每个原生页面承载一个 RN 视图树,按页面对应 Provider,生命周期跟随原生页面走。

4.3 JS 引擎与原生模块之间的内存释放问题

鸿蒙适配层如果开启 Hermes 模式,JS 对象和原生对象之间的引用要特别注意。我之前遇到一个现象:从一个包含滚动列表的 RN 页面返回后,页面已经卸载,但列表项里的图片对象没有被释放,内存一直涨。

排查后发现,问题不出在 Jotai,而是我把某个列表数据原子定义成了全局原子,页面卸载了但全局原子还持有列表项引用。局部状态放全局,是内存泄漏最常见的来源。后来把所有和页面 UI 相关联的列表数据改成页面级 Provider 内的原子,返回操作后内存显著回落。

另外开发阶段可以打开鸿蒙侧的 DevTools 和内存快照对比,仔细看 RN 页面节点和原生视图节点是否都释放。很多内存问题要结合两侧快照一起判断。

5. 常见问题与排查实录

5.1 几个高频报错与解决方案

现象可能原因解决办法
HMR 后原子值停留在旧状态Babel 插件缓存了原子的初始值移除 jotai/babel/plugin,或者重启 metro 缓存
使用 atomFamily 时控制台警告内存增长没有调用 atomFamily 的清理函数在页面卸载时监听 unmount,调用 family 的 remove 或采用带清理模式的定制封装
异步 atom 的 Promise 一直 pending原生模块的 Promise 在异常路径没有 resolve/reject在原生模块侧补齐异常分支处理,JS 侧写超时兜底
多个 RN 页面共享状态错乱同一个全局 atom 在不同页面被复用将页面级数据迁移到页面级 Provider
组件在卸载后调用 setAtom,触发 React 警告异步回调未取消使用 AbortController 或手动记录 mounted 标记,卸载后不再 setAtom
原生事件重复注册,一次事件触发多次更新DevTools 热重载导致副作用重复注册在入口用一个 module-level 的布尔变量保证只注册一次

第五个问题我在实际项目里反复遇到。RN 页面从鸿蒙原生页面栈返回,组件已经卸载,但之前在异步回调里还有setOrderList的调用,React 会提示“Can't perform a React state update on an unmounted component”。Jotai 的setAtom本身不会像 React setState 那样警告,但它会导致一个隐藏问题:原子已经被改了,如果之后有页面重新挂载并读取这个原子,会读到“上一次残留的数据”。所以数据清理逻辑一定要写在页面卸载阶段。

5.2 实测性能与优化建议

我这边用一个模拟的 200 行列表页面做了简单对比,每个 cell 里都通过useAtomValue读取各自详情原子,同时列表顶部有一个筛选条件原子。实际操作里,筛选条件一变,所有 cell 理论上都应该重新计算过滤结果。但如果每条 cell 都读整个筛选 atom,确实会造成 200 个组件同时重新渲染。

优化方式是selectAtom或者把筛选结果做派生原子,让每个 cell 只读自己那一份派生结果:

import { selectAtom } from 'jotai/utils'; const filteredIdSetAtom = selectAtom(todoListAtom, (list) => { return new Set(list.filter((item) => item.active).map((item) => item.id)); });

这里selectAtom会在底层用相等性判断决定是否需要触发组件更新。需要注意:如果你返回的是new Set()或新数组,每次都不同,那么selectAtom约等于没用。所以要么返回基础类型,要么配合结构比较。我的实际做法是直接返回一个用JSON.stringify拼出来的字符串,再在 cell 里做解析,虽然有点绕,但性能稳定。

最终 200 行的列表在滚动时,帧率稳定在 60 帧附近。Jotai 原子更新本身带来的额外损耗,相比冗长的 bridge 调用和原生视图更新,几乎可以忽略。

5.3 团队协作里的几个约定

跨端项目最怕状态管理风格不统一。下面几条规则是我走了不少弯路后总结出来的,分享出来给你们参考:

  • 所有原子定义统一放src/atoms目录,按业务域拆文件,比如authAtom.ts、orderAtom.ts,不按页面拆。
  • 组件内部不要新建 atom。一开始图省事有人会把 atom 写在组件函数里,导致每次渲染都创建一个新原子,Provider 里的状态根本不生效。所有 atom 必须是模块级变量。
  • 原子命名统一用xxxAtom后缀,派生原子用derivedXxxAtom或xxxAtom配合注释。大家看到loadableXxxAtom就知道大概率是异步原子。
  • 页面级数据必须放页面级 Provider,全局数据才放根 Provider。每个页面组件顶部用一个常量数组统一声明自己需要的原子,方便 review。

这些约定没有技术上的强制性,但能显著降低跨端联调时的沟通成本。

最后一点小经验

我在这个项目里最大的体会是,Jotai 不是“另一个状态管理库”,它更像一套组织异步数据和派生逻辑的思维框架。在鸿蒙这个双端混合环境里,把异步、加载态、错误态这些原本散落在组件里的逻辑,统一收拢到原子层以后,页面代码变得非常可预测。

如果你正打算在鸿蒙 RN 项目里选状态方案,或者已经把 Jotai 装进去但用不太顺,希望这篇文章能帮你减少试错。尤其是“页面级 Provider”和“原生事件非组件文件更新原子”这两个点,建议尽早用在自己的项目里,后面会少踩很多坑。

后续如果大家对这个话题感兴趣,我可以再拆一篇,专门讲讲鸿蒙侧 ArkTS 原生模块如何和 Jotai 原子联动,处理大数据量列表的分页与增量更新。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询