资讯详情

资讯详情

鸿蒙RN开发中的Jotai状态管理实践:原子化异步处理与踩坑经验

前阵子把手上那个跨端项目往鸿蒙上迁移应用里状态管理用的就是 Jotai。团队里不少人一开始挺担心RN 在鸿蒙上本身就是新环境再加一层原子状态管理会不会冒出各种兼容性问题实际搞下来状态管理这一环反倒比预想中顺真正需要花心思的反而是原生能力桥接、异步时序还有生命周期对齐。这篇文章把整个接入过程、核心写法和踩过的坑完整整理一遍给准备在鸿蒙上做 RN 开发的朋友做个参考。先说结论Jotai 这套“原子 派生 异步”的模型和鸿蒙侧“JS 界面 原生能力模块”的混合架构配合得相当好。你不需要像 Redux 那样写一堆 action 和 reducer也不需要在页面组件里层层透传 props。它本身是个很小的库API 稳定TypeScript 支持也到位放进 RN 工程里几乎零成本。我这次用的是 Jotai 2.x 版本配合某厂商鸿蒙系统适配的 RN 运行时。下文所有代码都来自一个模拟项目 X 的实践我尽量把关键步骤、参数选择和避坑点都写透。1. 为什么在鸿蒙上做 RN 开发偏偏选了 Jotai1.1 鸿蒙端 RN 开发的状态管理痛点先聊一个可能被忽略的背景鸿蒙上的 RN 开发和普通 iOS/Android 开发最大的区别是“双端生命周期不同步”。RN 页面跑在 JS 引擎里但很多数据源和系统能力在原生鸿蒙侧比如电量、网络状态、传感器、账号信息。JS 侧想拿这些数据要经过异步桥接到原生模块回调返回的时机不可控。这时候如果状态管理方案对异步支持很弱代码会迅速变成一堆 useEffect setState 的嵌套地狱。再加上鸿蒙适配层还在快速迭代某些原生模块只在特定版本可用JS 侧必须有“优雅降级”的能力。Jotai 的异步派生原子天然支持 pending / error / success 三态用一个 hook 就能承接整个异步链路这种体验在 Redux 时代要写很多样板代码。1.2 对比 Redux、MobX、ZustandJotai 的原子模型适合什么场景拿一张表快速说下我的选型对比方案状态模型学习成本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 兼容模式鸿蒙系统 APIAPI 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() 返回一个 PromiseDeviceInfo 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 atomDeviceInfo | 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 atomTodoItem[]([]); export const activeTodoListAtom atomTodoItem[]((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 atomLocationInfo | 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 atomDetailData | null(null); }); export const detailLoadingAtomFamily atomFamily((id: string) { return atomboolean(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 会提示“Cant 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 原子联动处理大数据量列表的分页与增量更新。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →