☰
React Native鸿蒙跨平台实战:用Hooks构建疫苗接种记录App
2026/10/18 10:52:12 网站建设 项目流程

前阵子家里小朋友的疫苗本被我翻得起了毛边,每次打针前都要对着墙上的接种时间表反复核对下一针是什么时候。折腾了几次之后,我决定干脆自己做一个儿童疫苗接种记录的小工具,顺便也把一直想试的React Native鸿蒙跨平台方案落地验证一遍。技术栈就锁定了React Hooks + TypeScript,配合React Native在鸿蒙环境下的适配能力,用useState管理界面状态,用useEffect做数据驱动的联动刷新。这篇文章就把我从建模、编码到真机调试的完整过程写出来,希望能给同样想在这个方向尝试的朋友一些参考。


1. 为什么选React Native做鸿蒙App:我的选型复盘

1.1 做这个功能前,我先想清楚了几个问题

很多人拿到"儿童疫苗接种记录"这个需求,第一反应就是"这还不简单,做个表格页面,加个新增按钮就完了"。但真正落到产品层面,会冒出一堆需要提前想明白的问题:接种记录要能区分“该打了但还没打”、“已经打了”、“漏打了”三种状态;同一支疫苗可能有好几针,比如乙肝疫苗有三针,每一针的时间窗口都不一样;家长除了看列表,还可能要补充备注、修改接种日期,甚至换了一台手机之后数据不能丢。

这些需求叠加在一起,功能虽然不复杂,但对状态管理、数据建模和跨平台一致性都有要求。我的目标很明确:这个App要能跑在鸿蒙设备上,同时安卓和iOS也不能落下,毕竟家里人的手机品牌很杂。

1.2 原生、Flutter、React Native的三方对比

我先在本地把三个方案快速过了一遍,整理了一张表:

维度纯原生开发FlutterReact Native + 鸿蒙适配
跨平台覆盖每个平台单独开发iOS/Android/鸿蒙均可iOS/Android/鸿蒙均可
开发效率较低,双倍工作量高,Dart单语言高,复用前端技术栈
团队技术栈匹配度需要另学需要另学Dart前端JS/TS直接上手
鸿蒙生态成熟度最稳适配层在演进社区适配可用,坑要自己踩
状态管理灵活性各自方案Provider/BlocHooks生态丰富

最终选React Native,最重要的原因不是它技术上比别家强,而是我手头的技术栈就是TypeScript + React,Hooks这套心智模型用得很熟。疫苗记录属于典型的数据密集型页面:列表渲染、状态切换、筛选联动,这些场景React Hooks处理起来非常顺手。至于鸿蒙适配,现在社区已经有比较完整的适配工程,基础的RN组件和API在鸿蒙设备上能跑起来,代价是有一些第三方库需要自己排查兼容性——这点我在后面会单独讲。

1.3 这个选型到底解决了什么问题

用React Native还有一个隐性好处:React的声明式UI模型和"数据驱动视图"的思路,和接种记录这种需要根据日期自动推算状态、根据筛选条件动态折叠列表的业务非常契合。我不需要手动写一堆"当点击A按钮时修改B控件的显隐"这类命令式代码,只需要改变状态,界面会自动跟着变。加上TypeScript的静态类型约束,连疫苗状态这种最容易拼错的字符串字段都能在编译期暴露问题。

坦白说,如果你只是给一台鸿蒙设备做个一次性工具,直接上原生效率最高。但如果你和我一样,希望一套代码通吃三个平台,同时后续还想着接入云同步、推送提醒这些能力,那React Native这个路线值得认真考虑。


2. 先建模再写码:疫苗记录的数据结构与类型定义

2.1 把疫苗记录抽象成一张"可执行的待办事项表"

写代码的第一步永远不是敲键盘,而是把业务概念梳理清楚。疫苗接种记录看起来就是个普通的CRUD,但它有一个不太一样的点:每一针都有一条独立记录,而这些记录之间又存在递进关系。比如乙肝疫苗的第二针,必须满足"第一针已经接种且间隔足够"这个逻辑前提才算有效。

为了不让代码里到处散落"疫苗名+第几针"这种if判断,我在建模时把每一条记录定义成独立的实体,用字段表达它自身的属性,而不是靠外部推断。这样做的好处是:后续要自动计算"下一针该打什么",只需要扫描状态为pending的记录里最早的日期即可。

我遇到的一个典型误区是:一开始想用两个布尔字段isFinished和isOverdue来表达接种状态。结果发现这两个字段会组合出"已完成但已过期"这类自相矛盾的数据,逻辑校验写起来很难受。换成一个统一的status枚举之后,所有问题都消失了。

2.2 TypeScript接口与枚举的具体写法

我在项目里定义了一个vaccine.ts文件,核心类型长这样:

export type VaccineStatus = 'pending' | 'completed' | 'overdue'; export interface VaccineRecord { id: string; vaccineName: string; doseIndex: number; doseTotal: number; scheduledDate: string; actualDate?: string; status: VaccineStatus; notes?: string; childName: string; }

逐字段解释一下为什么这样设计。

doseIndex和doseTotal分开存储,而不是直接存一个"第2针"的字符串,是因为后续展示列表时可以直接拼出"第2针/共3针",排序时也可以按doseIndex做数字排序,不会被字符串的字典序坑到。

scheduledDate是计划接种日期,用来判断是否到期;actualDate才是真正接种的那一天。之所以用可选字段,是因为pending状态的记录根本没有实际接种日期,强制必填反而会让代码里到处充满空字符串的妥协。notes同理,给家长留一个记录"接种后发热"这类信息的入口。

status是核心状态枚举。这里特别用了TypeScript的字符串字面量联合类型而不是enum关键字,因为在React Native的编译链路里,字符串字面量联合类型更轻量,不需要额外的运行时枚举对象,而且结构类型系统会让状态值在编译期就被检查一遍。

2.3 初始数据从哪来:内置标准程序 + 手动兜底

对于一个没有后端的小工具来说,第一次打开App时不能给人一个空荡荡的界面。我的做法是在代码里内置一份基础免疫规划疫苗作为初始数据,用户可以在设置里把孩子出生日期录入后,自动生成对应时间线的接种计划。

export const defaultVaccines: VaccineRecord[] = [ { id: 'hep-b-1', vaccineName: '乙肝疫苗', doseIndex: 1, doseTotal: 3, scheduledDate: '2025-01-02', status: 'completed', actualDate: '2025-01-03', childName: '小海', }, { id: 'bcg-1', vaccineName: '卡介苗', doseIndex: 1, doseTotal: 1, scheduledDate: '2025-01-02', status: 'completed', actualDate: '2025-01-02', childName: '小海', }, ];

这里有个实操小技巧:scheduledDate和actualDate这类日期字段,我强烈建议统一存储成YYYY-MM-DD字符串格式,而不是JavaScript的Date对象。原因是Date对象在React Native桥接层序列化时可能会时区混乱,跨平台表现不一致;而纯字符串在渲染、比较、持久化时都不会出幺蛾子。需要计算"还有几天到接种日"时,再动态转成时间戳做差值即可。


3. useState管住全部交互状态:从列表到弹窗的完整方案

3.1 状态拆分:一个Hook管数据,一个Hook管UI状态

React Hooks刚出来的时候,我见过不少人喜欢把整个页面的状态塞进一个巨大的useState对象里,理由是"集中管理好调试"。实际写下来会发现:每改一个字段都要展开嵌套对象,不小心就把别的字段覆盖掉了。

我的做法是把状态按"数据域"和"UI域"拆成两个维度。数据域就是疫苗记录本身,UI域则是"当前筛选条件"、"正在编辑哪条记录"、"弹窗是否可见"这类和界面交互相关的状态。

const [records, setRecords] = useState<VaccineRecord[]>([]); const [filter, setFilter] = useState<'all' | VaccineStatus>('all'); const [editingRecord, setEditingRecord] = useState<VaccineRecord | null>(null); const [modalVisible, setModalVisible] = useState(false);

这样拆完的好处是:更新数据时不会碰UI状态,UI状态怎么变也不会污染数据。而且每个useState的泛型类型都清清楚楚,editingRecord要么是一条完整记录要么是null,根本不会出现"某些字段是空的"这种尴尬情况。

3.2 用自定义Hook封装增删改查

把疫苗记录的增删改查逻辑抽到一个自定义Hook里,是我在整个项目里做的收益最大的一件事。组件里就只剩干净的声明,而所有状态更新逻辑都集中在Hook内部,想复用可以随时拿出去。

export function useVaccineRecords(initial: VaccineRecord[]) { const [records, setRecords] = useState<VaccineRecord[]>(initial); const addRecord = (record: VaccineRecord) => { setRecords(prev => [...prev, record]); }; const updateRecord = (id: string, patch: Partial<VaccineRecord>) => { setRecords(prev => prev.map(item => item.id === id ? { ...item, ...patch } : item ) ); }; const removeRecord = (id: string) => { setRecords(prev => prev.filter(item => item.id !== id)); }; return { records, addRecord, updateRecord, removeRecord }; }

这里有三个新手最容易踩的坑。

第一,永远不要直接操作原数组。比如records.push(newRecord)之后以为状态变了,实际上React根本感知不到数组引用变了,界面纹丝不动。必须通过setRecords传入一个新数组引用。

第二,更新数组内某个元素时,不要直接改item.status = 'completed'这种脏写法。要用map返回一个新数组,同时用对象展开{ ...item }保留原有字段,再覆盖要修改的字段。这样React才能在diff阶段准确对比出变化。

第三,函数式更新setRecords(prev => ...)比直接传值更安全。如果你的状态更新依赖上一次的状态值,函数式写法能保证在你拿到prev值时它一定是最新的,尤其适合出现在异步回调或useEffect里。

3.3 列表与弹窗如何联动

记录列表里每一条都有一个"标记已接种"按钮,点击后要做两件事:弹出一个小弹窗让用户确认日期,然后把日期回写到这条记录里。我一开始直接用两个状态分别管"当前操作的id"和"弹窗里的日期输入值",结果发现不同步的问题特别多。

后来我改成:点击按钮时直接setEditingRecord(records.find(item => item.id === id)),把整条记录对象作为编辑上下文;弹窗内部的所有输入都基于editingRecord这个对象做修改,确认时再调用updateRecord(editingRecord.id, patch)。这样弹窗和列表之间天然就是同一个数据源,根本不存在同步问题。

const handleConfirm = () => { if (!editingRecord) return; updateRecord(editingRecord.id, { status: 'completed', actualDate: selectedDate, }); setModalVisible(false); setEditingRecord(null); };

这个"用整条对象做主状态,局部输入围绕它转"的设计模式,在React Native的各种表单场景里通用性很强。


4. useEffect数据驱动:加载时机、依赖数组与脏数据问题

4.1 useEffect在这里负责的三类事情

useEffect是这个项目里"数据驱动"四个字的具体载体。它在我这套实现里负责三类事情:一是组件挂载时从本地存储或接口加载初始疫苗记录;二是当筛选条件变化时重新组织展示列表;三是当记录数据发生变化时自动触发持久化保存。

很多初学者有个错觉,觉得useEffect是"组件生命周期事件的替代品"。其实它的本质是"同步外部系统和React状态之间的副作用"。你需要做的是告诉React:哪些状态变化会影响这个副作用,而不是在代码里逐个写事件回调。

4.2 初始化加载的代码示例

疫苗记录要从一个本地存储封装模块读取,读不到就用内置默认数据兜底。加载逻辑写在一个useEffect里,依赖数组传空数组,表示只在组件挂载时执行一次:

useEffect(() => { let cancelled = false; loadRecords().then(data => { if (!cancelled && data) { setRecords(data); } else if (!cancelled) { setRecords(defaultVaccines); } }); return () => { cancelled = true; }; }, []);

这段代码里的cancelled标志是我强烈建议养成的习惯。在鸿蒙端,页面切换时组件可能很快被卸载,但异步请求的回调仍然会执行——如果此时还去setRecords,轻则内存泄漏,重则在某些React Native版本上会直接报"组件卸载后更新状态"的警告。一个cancelled变量就能把这种问题从根源上消灭掉。

4.3 筛选展示:让列表数据"跟着状态自动变"

顶部有三个筛选按钮:全部、待接种、已完成。用户点击后只是改变filter这个状态,列表内容必须自动跟着变。这里我做了个关键决策:展示列表用useMemo做派生计算,而不是用useEffect再加一个setVisibleRecords。

const visibleRecords = useMemo(() => { if (filter === 'all') return records; return records.filter(item => item.status === filter); }, [records, filter]);

这里的区别很微妙但很重要:useEffect是用来执行副作用的,比如发请求、写存储;而筛选纯属"根据已有数据计算出展示数据",是渲染期该做的事,交给useMemo最合适。如果用useEffect去筛选,会导致一次筛选触发两次渲染(先旧数据后新数据),还平白引入了一次额外的状态更新。

数据驱动的思维核心就是:筛选条件变化后,React自动重跑useMemo,列表自动更新,全程不需要你手动操作DOM。这个思路在鸿蒙端也一样生效,说明React的状态模型确实做到了跨端一致。

4.4 依赖数组与"脏数据"实战排查

useEffect最折磨人的问题就是依赖数组。我这次开发里就踩过一个非常典型的坑:有个子组件需要监听"当前选中的疫苗记录变化",我在依赖数组里只写了editingRecord,但effect内部却用到了records来校验记录是否存在。结果就是当records变化但editingRecord没变时,effect不会重跑,里面拿到的一直是旧records,形成过期闭包。

排查过程不算复杂:我先在effect首行打了一行日志观察触发时机,又顺手打印了records和editingRecord的长度与id,立刻发现只有editingRecord变化才触发。修正方法很简单——把records加进依赖数组,或者干脆让effect内部不依赖外部records,改用参数传值。这类问题的通解就是:依赖数组应该包含所有在effect内部引用且可能变化的值。

另一个更隐蔽的坑是竞态。有一次我在筛选条件变化后去请求不同状态的数据,因为接口有时快有时慢,先发出去的请求可能比后发出去的晚返回,导致用户看到的数据和当前筛选不一致。修复方案是在effect开头设一个请求序号,只有最新一次请求的结果才会被采纳:

useEffect(() => { let requestSeq = ++seqRef.current; fetchRecords(filter).then(data => { if (requestSeq === seqRef.current) { setRecords(data); } }); }, [filter]);

这个模式在真实项目里几乎每天都能用到,凡是"依赖参数变化触发异步请求"的地方都建议套一层序号保护。

4.5 一个useEffect完成的自动保存

当用户给一条记录标记为已接种之后,数据不能只躺在内存里。我用一个简单的useEffect实现自动持久化:

useEffect(() => { if (records.length > 0) { saveRecords(records); } }, [records]);

这里特意加了records.length > 0的守卫条件,原因是最初从存储加载时,如果存储里恰好没有数据,records会被初始化为空数组,而此时loadRecords的异步结果还没回来,立即触发保存就会把空数组覆盖到存储里,把老数据洗掉。加了守卫后,空数据状态不会误触发覆盖。


5. 鸿蒙端适配实录:一次真机调试暴露的几个兼容性坑

5.1 基础组件能跑,但别直接信第三方库

React Native官方并没有原生支持鸿蒙系统,我能跑起来靠的是社区维护的React Native适配工程。核心的View、Text、ScrollView、TouchableOpacity这些组件在鸿蒙设备上都能正常工作,网络请求通过适配层也能发出去。但第三方RN库就不一定了——很多库内部用了安卓或iOS的原生模块,适配层没覆盖时就会白屏或直接崩溃。

我定的原则是:能用RN内置组件和纯JS实现的,绝不多引第三方库。像疫苗记录的勾选状态,我用View加边框和背景色模拟;弹窗用官方Modal组件;本地存储用一套轻量级封装,而不是直接依赖某个存储库。

5.2 坑一:存储数据在部分鸿蒙设备上"悄悄消失"

现象非常诡异:在模拟器和第一台测试机上,记录保存后杀进程重开都在;换到另一台鸿蒙设备,保存后当天正常,第二天打开发现数据全没了,回到初始内置数据。

我排查的第一步是在saveRecords两端都打日志,确认保存函数确实被触发了;第二步是看读取时的返回值,发现返回的是空数组;第三步才怀疑到存储库在不同设备的隔离机制差异上。最终定位到是:部分鸿蒙系统版本对某些本地存储库的支持不完整,键值写入后没有真正落盘持久化。

解决办法是我绕开了存储库,改用最简单直接的文件写入方案:自己封装一个函数,用RNFS写入一个vaccine-records.json,读取时解析该文件。这套方案在任何设备上都是标准的文件读写,不依赖系统专属API,兼容性一下子稳了。

const STORAGE_FILE = 'vaccine-records.json'; export async function saveRecords(records: VaccineRecord[]) { await RNFS.writeFile( RNFS.DocumentDirectoryPath + '/' + STORAGE_FILE, JSON.stringify(records), 'utf8' ); } export async function loadRecords(): Promise<VaccineRecord[] | null> { try { const content = await RNFS.readFile( RNFS.DocumentDirectoryPath + '/' + STORAGE_FILE, 'utf8' ); return JSON.parse(content); } catch { return null; } }

5.3 坑二:日期选择器在鸿蒙上交互异常

做接种记录绕不开选日期。RN官方维护的日期选择器组件在鸿蒙适配层的表现不稳定,我在一台设备上点击后弹窗不出现,另一台上出现了但确认按钮没反应。面对这种兼容性不确定的组件,我直接换了一种交互思路:不使用原生日期选择器,改成让用户点击一个输入框后,在一个自绘的简易日期面板里选日期。

这个自绘面板不复杂:一个年月标题加左右切换箭头,下面渲染七列日历格子。选中后回填日期字符串到selectedDate。麻雀虽小但完全可控,鸿蒙、安卓、iOS三端表现一致,再也不用为系统组件差异操心。

5.4 坑三:弹窗内键盘遮挡输入框

在弹窗里添加备注时,鸿蒙设备上的软键盘弹出来会直接把输入框挡住,用户看不到自己打的内容。这个问题在安卓上有windowSoftInputMode可以配置,鸿蒙上我手头的适配工程没有暴露对应的配置入口。

我的解法是给弹窗内容包一层KeyboardAvoidingView,并根据平台设置偏移:

<KeyboardAvoidingView behavior={Platform.OS === 'android' ? 'height' : 'padding'} >

然后在弹窗内的ScrollView加上keyboardShouldPersistTaps="handled",让键盘弹起时点完成按钮不会先把键盘收回。实测下来,三端表现都还能接受,虽然弹窗会被键盘顶上去一点,但至少输入内容完整可见。

5.5 多端真机测试清单

经历了上面这些坑后,我给这个功能列了一份测试清单:鸿蒙设备上覆盖"首次启动、杀进程重启、跨天重启"三种场景;安卓和iOS设备上重点验证基础交互流程;三端都跑一遍release包(debug版多了一层调试桥,某些问题会被掩盖)。每一轮测试都要把"修改记录 -> 杀死App -> 重新打开"作为必测动作,因为数据持久化才是最容易被跨平台差异击穿的地方。


6. 这套组合在真实使用中的体感与建议

6.1 值得坚持的做法

整个项目做完,我最满意的部分其实是TypeScript对这类业务模型的约束力。疫苗状态、记录ID、日期字符串这些字段一旦被类型钉死,重构时敢放心改,编译期就能拦下一大批低级失误。Hooks这套组合也让我感到踏实:useState负责状态快照,useEffect负责和外部世界同步,useMemo负责派生数据,三者的职责边界一旦清晰,写新功能时几乎不用思考代码该放哪。

6.2 需要接受的妥协

React Native在鸿蒙上的适配仍在快速迭代期,你能明显感到第三方库的兼容性参差不齐。很多Android上开箱即用的库,在鸿蒙端要么重新封装、要么得自己造轮子。所以我的建议是,在做这类跨端项目时,功能边界要稍微克制一些:核心业务逻辑用纯JS/TS实现,把对原生组件的依赖降到最低,这样才能保证跑到鸿蒙上不至于推倒重来。

6.3 后续扩展方向

这个接种记录功能跑通之后,我打算往里加三个能力:一是接入一个简单的后端存储,让家里几台手机之间数据同步;二是对接系统日历提醒,快到接种日前推一条本地通知;三是在详情页接入疫苗知识库,点开一种疫苗就能看到接种注意事项。这三件事在现有数据模型上扩展起来都还算顺,因为核心接口已经把每针的日期、状态、备注都暴露住了。

6.4 给同样想做鸿蒙跨平台的人一句话

如果让我重新选一次,我依然会用React Native + TypeScript + Hooks这套组合来做这个项目。它的价值不在于某一项技术有多新,而在于让你用一份心智模型、一套代码,把一个真实的小工具带进鸿蒙、安卓和iOS三个世界。踩过的那些兼容性坑,写下来可能就几段话,但真机上一台一台测过去的过程,才是这类跨端开发最有价值的收获。

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

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

立即咨询