凌晨一点,孩子发烧,你翻遍手机相册也找不到上次接种记录,群里问了一圈也没人记得清。这种时刻,你会发现一个能随手查的儿童疫苗接种记录功能有多重要。我最近用React Native配合鸿蒙跨平台方案做了这样一个模块,技术栈是React Hooks加TypeScript,核心状态用useState管理,数据刷新交给useEffect驱动,目前已经在多端设备上稳定跑了一段时间。这篇就完整聊聊实现思路、代码结构和踩过的坑,给同样在做跨平台健康类应用的朋友一个可直接参考的样本。
先说清楚这个项目是干什么的。儿童疫苗接种记录,本质是一个围绕“疫苗档案”的数据管理模块:孩子的基础信息、疫苗名称、接种剂次、应种日期、实际接种日期、接种状态、疫苗批号、接种机构……这些信息需要支持录入、查询、编辑、状态流转,并且要在一个跨Android、iOS、鸿蒙三端的应用里保持一致体验。我选择React Native +鸿蒙适配层这套方案,核心原因很直接:业务团队已经有成熟的RN代码库,鸿蒙原生开发起步成本高,而RN的HarmonyOS适配已经能覆盖大部分基础组件和API调用,用一套TypeScript逻辑层代码驱动三端UI,维护成本最可控。
1. 项目背景与需求拆解
1.1 为什么选React Native做鸿蒙跨平台
跨平台方案在2025年的选择其实挺多的:Flutter、React Native、Kotlin Multiplatform、自绘引擎方案。我最终选React Native,不是因为它在性能上碾压谁,而是这个业务场景对性能的要求没有那么极端,但对生态复用和团队上手速度的要求很高。React Native的组件模型和Hooks心智模型足够成熟,TypeScript的类型系统又能把业务数据结构钉得死死的,这两点对医疗健康类数据密集型应用特别重要。
鸿蒙端的支持路径是这样的:React Native官方并没有直接发布HarmonyOS版本,但社区维护的react-native-harmony适配层已经跑通了大部分核心组件和原生模块桥接。实际操作中,我保留了RN标准API的调用方式,只在涉及平台差异的地方用Platform模块做分支处理。这样做的最大好处是未来如果鸿蒙适配层升级或者你要退回纯Android/iOS构建,业务代码几乎不用动。
1.2 儿童疫苗接种记录的业务需求细化
这个功能最核心的使用者是家长,其次是接种医生。站在家长角度,他们想要的东西很具体:下一次该打什么针、什么时候打、有没有漏打、历史接种记录是否完整。站在医生角度,他们需要快速看到孩子的接种档案,判断是否有脱漏种,以及补种计划的安排。
所以需求拆下来大概是这些:
- 儿童档案管理:姓名、出生日期、性别、档案编号,一个家庭可以绑定多个孩子。
- 疫苗目录管理:疫苗名称、可预防疾病、接种程序(几个剂次、每次间隔)、疫苗性质(免费/自费)。
- 接种记录管理:每个剂次的接种日期、接种部位、疫苗批号、生产企业、接种单位、接种医生、下次应种日期。
- 接种状态流转:待接种 -> 已预约 -> 已完成。如果超过应种日期还没接种,需要标记为逾期。
- 提醒能力:基于应种日期提前N天生成提醒条目。
这些需求里,最容易被忽略但实际最重要的,是剂次之间的间隔约束。比如乙肝疫苗第三剂次,必须和第二剂次间隔至少两个月且年满6月龄,单纯的日期记录如果没有逻辑校验,就只是一个记事本,称不上“记录功能”。
1.3 技术选型的取舍思路
在这个项目里,我刻意用了React Hooks的函数组件写法而不是Class组件。原因很简单:疫苗记录的状态流转逻辑天然适合用useState表达,而数据加载、依赖联动、初始化这些副作用场景用useEffect来处理,代码会非常紧凑。TypeScript在这里不是可选项而是必选项——疫苗数据结构里含有日期、枚举状态、嵌套记录,没有类型约束,早晚会有人把一个string塞进Date字段里。
存储层我选了轻量级方案,本地用SQLite存储结构化数据,状态管理用Hooks自带的useState加useReducer,没有引入Redux或者Zustand。这个判断基于一个事实:这个模块的数据流是单方向且局部的,不需要跨页面共享复杂状态,引入重型状态管理库只会增加维护负担。
2. 数据模型设计与TypeScript类型系统
2.1 疫苗记录核心数据结构
在动手写任何UI之前,先把数据模型定死。这是整个项目中最重要的工程决策,因为后续所有组件、工具函数、存储逻辑都会依赖于这套类型定义。我定义了三个核心类型:ChildProfile代表儿童档案,VaccineItem代表疫苗品种定义,VaccinationRecord代表一次具体的接种记录。
// 儿童档案 export interface ChildProfile { id: string; name: string; gender: 'male' | 'female'; birthDate: string; // YYYY-MM-DD,用字符串存日期避免时区问题 fileNo: string; // 接种证编号 createdAt: string; updatedAt: string; } // 疫苗定义 export interface VaccineItem { id: string; name: string; // 如:乙肝疫苗 diseasePrevented: string[]; // 预防疾病列表 category: 'free' | 'selfPaid'; // 免费/自费 doses: VaccineDoseRule[]; // 剂次规则 } export interface VaccineDoseRule { doseNumber: number; // 第几剂次 minIntervalDays?: number; // 与上一剂次的最小间隔天数 minAgeMonths?: number; // 最小月龄要求 defaultDueOffsetDays: number; // 默认在上一剂次/出生后多少天应种 } // 接种记录 export interface VaccinationRecord { id: string; childId: string; vaccineId: string; doseNumber: number; status: 'pending' | 'booked' | 'completed' | 'overdue'; scheduledDate?: string; // 应种日期 actualDate?: string; // 实际接种日期 batchNo?: string; // 疫苗批号 manufacturer?: string; // 生产企业 injectionSite?: string; // 接种部位 hospitalName?: string; // 接种单位 doctorName?: string; // 接种医生 notes?: string; createdAt: string; updatedAt: string; }这套数据结构的设计有几个细节想展开说说。
日期字段统一用字符串,我是在生产环境踩过坑之后改的。JavaScript的Date对象在不同时区和不同平台上的解析行为不一致,鸿蒙上某些情况下new Date('2025-07-01')会被当作UTC时间,而Android上会被当作本地时间,表现完全不一样。用YYYY-MM-DD字符串存储,展示时再解析,避免存储层有时区歧义。
状态字段我设计成了四个值:pending、booked、completed、overdue。有些人可能会觉得booked和pending可以合并,但实际业务里预约了和没预约是完全不同的操作路径:预约了要展示预约时间和接种点,没预约的要展示“去预约”按钮。分开以后,UI渲染逻辑会简洁很多。
2.2 类型守卫与数据校验
有了类型定义,还得保证运行时数据不会偏离类型约束。本地存储读出来的数据,或者鸿蒙原生层返回的数据,都是不可信任的,因为它们可能来自旧版本或者被外部修改。我在数据层入口写了一套类型守卫函数:
function isVaccinationRecord(data: unknown): data is VaccinationRecord { if (!data || typeof data !== 'object') return false; const obj = data as Record<string, unknown>; return ( typeof obj.id === 'string' && typeof obj.childId === 'string' && typeof obj.vaccineId === 'string' && typeof obj.doseNumber === 'number' && ['pending', 'booked', 'completed', 'overdue'].includes(obj.status as string) ); } export function parseVaccinationRecordList(data: unknown): VaccinationRecord[] { if (!Array.isArray(data)) return []; return data.filter(isVaccinationRecord); }这个做法的意义是在告诉你:TypeScript的静态检查保护的是编译期,运行期的脏数据防护要自己写。很多RN项目只在接口层用any一把梭,结果就是某个字段在鸿蒙返回undefined后整个组件崩溃。加了类型守卫以后,至少能保证进入状态管理的数据都是结构合法的,过滤掉脏数据而不是让脏数据渗透到业务逻辑里。
2.3 为什么坚持用interface而不是type
这个细节可能有人觉得无聊,但大型跨平台项目里真的很重要。interface支持合并声明,这在写第三方类型扩展时有用;type则是创建联合类型和工具类型的主要手段。我的习惯是:描述数据结构实体用interface,描述状态枚举或组合类型用type。这样看代码的人一眼就能区分“这是一个有实体的数据表”和“这是一个临时组合出来的视图模型”。
3. 用useState设计状态管理的核心思路
3.1 状态拆分与粒度控制
React Hooks的状态管理,最容易翻车的就是状态拆分粒度。拆太粗,比如把整个接种记录列表塞进一个对象,每次更新都要深拷贝整个结构,性能差且容易改乱;拆太细,每个字段一个useState,逻辑代码会膨胀到难以维护。
我的做法是“按业务实体聚合,按操作频率拆分”。疫苗记录模块最终分成了三个大状态块:
const [childProfile, setChildProfile] = useState<ChildProfile | null>(null); const [vaccineList, setVaccineList] = useState<VaccineItem[]>([]); const [recordList, setRecordList] = useState<VaccinationRecord[]>([]); const [loadingState, setLoadingState] = useState<LoadingState>('idle');childProfile是整个页面的基础数据,通常一次加载后不频繁变化。vaccineList是疫苗目录数据,属于低频静态数据。recordList是用户接种记录,变化最频繁也最关键。loadingState则独立管理请求状态,避免和业务数据混在一起。
有一个实战经验值得说:不要把接口的loading状态和业务数据放在同一个useState对象里。如果它们在一起,每次请求结束都要合并一次对象,而且loading状态刷新时整个业务组件都会重渲染。分开以后,loadingState只供给少数几个骨架屏组件使用,数据变更才触发行列刷新和进度重算,性能差异在小数据量时不明显,但在记录条目超过百条后体感差距很大。
3.2 用useReducer替代多个useState处理复杂状态流转
如果只是单纯的列表增删改查,useState完全够用。但当接种记录的状态流转开始出现“批量更新”“状态联动”时,多个useState之间会产生隐式依赖,很容易写出bug。
举个例子:当一条记录从pending变成completed,需要同时做三件事——更新recordList中该记录的状态、计算对应的疫苗进度、可能还需要刷新overdue统计。这个联动逻辑如果在每个事件处理函数里手工调用三次setState,一旦漏掉其中某一个,UI就会出现数据不一致。
我在处理状态流转时,引入了useReducer来收敛这些联动更新:
type VaccineRecordsAction = | { type: 'LOAD_INITIAL_DATA'; payload: { records: VaccinationRecord[]; vaccines: VaccineItem[] } } | { type: 'ADD_RECORD'; payload: VaccinationRecord } | { type: 'MARK_COMPLETED'; payload: { recordId: string; completedData: Partial<VaccinationRecord> } } | { type: 'MARK_OVERDUE'; payload: { recordId: string; overdueDate: string } } | { type: 'REFRESH_DUE_STATUS'; payload: { today: string } }; function vaccineRecordsReducer(state: VaccineRecordsState, action: VaccineRecordsAction): VaccineRecordsState { switch (action.type) { case 'LOAD_INITIAL_DATA': return { ...state, records: action.payload.records, vaccines: action.payload.vaccines, }; case 'ADD_RECORD': return { ...state, records: [...state.records, action.payload], }; case 'MARK_COMPLETED': return { ...state, records: state.records.map((record) => record.id === action.payload.recordId ? { ...record, ...action.payload.completedData, status: 'completed' as const } : record ), }; case 'REFRESH_DUE_STATUS': return { ...state, records: state.records.map((record) => { if (record.status !== 'pending' && record.status !== 'booked') return record; if (record.scheduledDate && record.scheduledDate < action.payload.today) { return { ...record, status: 'overdue' as const }; } return record; }), }; default: return state; } }这里最核心的是REFRESH_DUE_STATUS这个action:它会扫描所有待接种和已预约的记录,把应种日期早于今天的标记为overdue。这个逻辑用useState无法优雅实现,因为你需要同时读取多条记录的日期和状态再做批量更新,而useReducer把“旧状态+action -> 新状态”的纯函数变换封装在了一个地方,所有联动逻辑都有迹可循。
3.3 状态更新中避免可变数据陷阱
React的setState要求新状态是不可变的。实际操作中,最容易犯的错是在原数组上直接push然后setState同一个引用,这样React会认为引用没变而跳过重新渲染。我在这个项目里要求所有数组更新都用展开运算符或者map/filter生成新数组,禁区就是直接修改原数组。
另一个值得注意的点是嵌套对象的更新。VaccinationRecord对象里有不少嵌套字段,更新一个batchNo如果直接改原对象,会连带影响列表里的旧数据。我要求团队统一用解构展开的方式更新:
setRecordList((prev) => prev.map((record) => record.id === targetId ? { ...record, actualDate: today, batchNo: '20250701A', status: 'completed' } : record ) );用一个map包裹,传入一个record就返回一个新的record,未命中的直接返回原引用。这不仅是React的要求,更是数据可追溯性的保障——每次状态更新都产生新对象,后面排查问题的时候可以对比前后两个状态对象,定位到底是哪一步改坏了数据。
4. 用useEffect实现数据驱动更新
4.1 初次数据加载的生命周期管理
数据驱动是这个项目的主题词,而useEffect就是数据驱动的执行引擎。先说最基础的:页面加载完成后拉取初始化数据。这里有一个常被忽略但很关键的细节——useEffect的async函数写法。
React官方文档明确说明useEffect的回调函数不能直接返回Promise。很多初学者会写成:
// 错误示范 useEffect(async () => { const data = await loadData(); setRecordList(data); }, []);这样写React会在控制台报warning,而且无法正确清理副作用。正确写法是在useEffect内部定义一个异步函数,然后调用它:
useEffect(() => { let isCancelled = false; // 取消标记,防止组件卸载后setState async function fetchInitialData() { try { setLoadingState('loading'); const { records, vaccines } = await loadInitialData(); if (!isCancelled) { dispatch({ type: 'LOAD_INITIAL_DATA', payload: { records, vaccines } }); setLoadingState('success'); } } catch (error) { if (!isCancelled) { setLoadingState('error'); // 这里可以上报日志 } } finally { if (!isCancelled) { setLoadingState('idle'); } } } fetchInitialData(); return () => { isCancelled = true; }; }, []);isCancelled这个标记非常关键。在React 18的严格模式下,useEffect的mount -> unmount -> remount流程会让你的副作用执行两次。网络请求通常是异步的,第一次请求还没返回,组件已经卸载,然后重新挂载发起了第二次请求。这时如果没有取消标记,第一次请求的返回值会触发setState,可以会导致内存泄漏或状态错乱。
我这里用了一个布尔标记而不是AbortController,原因很简单:RN的fetch支持AbortSignal,但鸿蒙适配层的网络库对abort的支持不稳定。用布尔标记做一个软取消,至少在业务层面不会产生无效状态更新。
4.2 依赖驱动的联动更新场景
useEffect比useState强大之处在于它可以声明式地响应依赖变化。疫苗接种记录中有一个非常典型的场景:当选中孩子发生变化时,需要重新加载该孩子的疫苗进度和提醒列表。
useEffect(() => { if (!childProfile) return; // 重置当前状态 dispatch({ type: 'CLEAR_RECORDS' }); const subscription = loadRecordsForChild(childProfile.id).subscribe({ next: (records) => dispatch({ type: 'LOAD_INITIAL_DATA', payload: { records } }), error: (err) => console.error('[vaccine] load child records failed', err), }); return () => subscription.unsubscribe(); }, [childProfile?.id]);这段代码把childProfile的ID作为依赖项,孩子切换时自动触发数据重载。同时返回一个清理函数来释放订阅,避免出现重复订阅导致的内存泄漏。这里有个容易被忽视的注意点:依赖数组里我不建议直接写childProfile对象,而是写childProfile?.id。因为对象引用每次渲染都可能变化,如果依赖对象,只要有父组件重渲染导致childProfile引用变了,effect就会反复触发,导致无限循环请求数据。
4.3 监听数据变化自动刷新逾期状态
接种记录的overdue状态不是静态的——今天看起来还是pending的记录,明天就过期了。所以应用需要在进入页面时自动刷新所有记录的逾期状态。我实现了一个基于日期依赖的effect:
const [today, setToday] = useState(() => formatDate(new Date())); useEffect(() => { // 每分钟检查一次日期是否变化,跨天则刷新 const timer = setInterval(() => { const newToday = formatDate(new Date()); setToday((prev) => (prev === newToday ? prev : newToday)); }, 60000); return () => clearInterval(timer); }, []); useEffect(() => { dispatch({ type: 'REFRESH_DUE_STATUS', payload: { today } }); }, [today]);这两个effect的配合实现了“跨天自动刷新逾期状态”这个业务需求。第一个effect维护一个today状态,只在日期真正变化时才触发更新,不会因为setState而无限循环。第二个effect监听today,一旦跨天就重新计算全部记录的overdue状态。这个方案的巧妙之处在于:它没有跑一个定时器去扫描记录,而是等日期变化后一次性批量处理,对长时间挂机的应用非常友好。
关于清理间隔定时器这一点,我也是吃过亏才记住的。之前有一次遗忘清理定时器,结果在页面切走后又切回来时,同时存在两个定时器,造成today状态反复跳变,界面出现闪烁。React的effect清理机制就是要在这个地方用的,不能偷懒。
4.4 Effect执行时序与竞态问题
useEffect的另一个经典陷阱是异步竞态。假设用户快速切换两个不同的孩子,第一次切换发起的网络请求比第二次切换的请求晚返回,那么界面会显示后选孩子的ID配上先返回孩子的数据。
解决办法是给每次请求做一个“过期标记”:
useEffect(() => { if (!selectedChildId) return; let active = true; setLoadingState('loading'); loadChildVaccineData(selectedChildId) .then((data) => { if (active) { setVaccineData(data); setLoadingState('success'); } }) .catch((err) => { if (active) { setLoadingState('error'); } }); return () => { active = false; }; }, [selectedChildId]);这个active标记的作用是在组件卸载或者依赖项变化后,拦截迟到的异步结果。它是处理竞态问题的最简单方案,不需要引入第三方库,也不依赖AbortController的实际取消能力。实际测试中,在鸿蒙的RN环境里,这个写法非常可靠,至少解决了99%的视图错乱问题。
5. React Native鸿蒙跨平台适配实操
5.1 鸿蒙环境下的React Native工程配置
鸿蒙端的React Native开发,目前社区主流的方案是使用适配了HarmonyOS的RN运行时。工程上的核心差异在于:除了android和ios目录,还需要一个entry目录存放鸿蒙的工程入口。开发和构建流程大致是:启动Metro服务打包JS bundle,鸿蒙端再通过原生加载器读取并渲染。
我的项目工程结构大概长这样:
app/ ├── android/ # Android工程 ├── ios/ # iOS工程 ├── entry/ # HarmonyOS工程入口 ├── harmony/ # 鸿蒙适配相关配置 ├── src/ │ ├── components/ # UI组件 │ ├── hooks/ # 自定义Hooks │ ├── services/ # 数据服务层 │ ├── types/ # TypeScript类型定义 │ └── screens/ │ └── VaccineRecords/ # 疫苗接种记录模块 └── package.json鸿蒙适配层对RN模块的兼容度,实测下来基础组件如View、Text、FlatList、TouchableOpacity都没问题,但有一些第三方RN库是纯JS组件所以也能跑,涉及原生模块的库就需要确认是否有鸿蒙适配版本。这个项目里我尽量把原生交互收敛到最少,避免踩到适配层没覆盖的盲区。
5.2 利用Platform模块处理平台差异
跨平台开发最大的坑就是不自觉写出平台相关代码。儿童疫苗接种记录看似全是UI逻辑,实际还是有不少平台差异点需要处理:
- 日期选择器:三大平台的原生日期组件差异很大,我在鸿蒙上用了一个自绘的日期选择弹窗,避开对原生组件的依赖。
- 存储路径:获取数据文件路径的API在Android、iOS、鸿蒙上完全不同,不能共用同一段代码。
- 状态栏高度:鸿蒙的状态栏行为和RN默认值有差异,需要单独适配。
处理方式用Platform模块进行分支:
import { Platform } from 'react-native'; const isHarmony = Platform.OS === 'harmony'; export function getVaccineDataFilePath(): string { if (isHarmony) { // 鸿蒙沙箱路径逻辑 return `${getHarmonyFilesDir()}/vaccine_records.db`; } if (Platform.OS === 'ios') { return `${getIosDocumentPath()}/vaccine_records.db`; } return `${getAndroidFilesDir()}/vaccine_records.db`; }这个getVaccineDataFilePath函数是我在多个平台文件存储适配中总结出来的:永远不要把平台路径拼接逻辑直接写在业务组件里,而是封装成统一的工具函数。这样业务层调用时可以不感知平台,只需要调用一个函数,平台差异被隔离在函数内部。后期无论新增哪种设备,都只需要修改这一个工具函数。
5.3 构建与调试的完整流程
在实际构建中,鸿蒙端RN项目的流程和标准RN是有差异的。我在调试阶段的做法是:起Metro服务(npm start),然后在鸿蒙模拟器上运行entry模块的调试配置,通过DevTools的远程调试功能查看React组件树和状态。鸿蒙端的日志输出方式和Android的logcat类似,可以在DevEco Studio中查看。
要特别注意的一点是:bundle加载模式。鸿蒙端RN支持开发模式和发布模式两种加载策略。开发模式从Metro读取JS bundle,支持热更新;发布模式则需要把JS bundle打包到应用的resources目录下,应用启动时直接加载本地文件。我遇到过一个很磨人的问题:发布模式下修改了React代码,但重新安装应用后界面没有变化,后来排查发现是鸿蒙工程里的bundle文件没有重新拷贝到resources目录,Metro生成的bundle和打包进应用的旧文件完全不是同一个。
这个问题的解决办法就是在gradle或hvigor的构建配置中增加一个任务,在每次构建前自动执行打包命令,把最新bundle同步到鸿蒙工程的resources目录。我把构建脚本固化了下来,后续团队任何人构建都会自动同步,不会再出现旧bundle的坑。
5.4 鸿蒙上的权限申请与数据隔离
儿童疫苗接种记录涉及孩子的健康隐私数据,权限和合规必须设计得比较严谨。鸿蒙的权限模型和Android的运行时权限有些相似但又不完全相同。在鸿蒙上,权限需要在module.json5里声明,而且部分敏感权限需要在运行时动态申请。
这个项目需要的基础权限主要是网络权限和本地存储读取权限,因为数据先存储在本地,后续做数据同步才需要网络。我最深刻的印象是:鸿蒙对沙箱路径的规范比Android更严格,不能假设应用可以随意访问存储空间中的任意目录。早期版本我在开发时直接把数据库文件写在了一个公共目录,结果在部分设备上出现数据读取失败,后来把这个行为改到应用沙箱目录内才解决。
对于这类数据安全合规问题,我的建议是:尽早确认鸿蒙的沙箱规则,不要在开发后期才来做适配。医疗健康类的数据,越早按平台安全规范设计越省事。
6. 完整实操:从空工程到可用的疫苗记录模块
6.1 用自定义hook封装业务逻辑
业务逻辑不应该散落在组件内部,尤其是这种需要在多个页面复用的记录管理逻辑。我封装了一个自定义hook,把整个疫苗记录的核心操作都收敛起来:
export function useVaccineRecords(childId: string | undefined) { const [state, dispatch] = useReducer(vaccineRecordsReducer, initialState); const [loadingState, setLoadingState] = useState<LoadingState>('idle'); const [today, setToday] = useState(() => formatDate(new Date())); // 跨天检测 useEffect(() => { const timer = setInterval(() => { const newToday = formatDate(new Date()); setToday((prev) => (prev === newToday ? prev : newToday)); }, 60000); return () => clearInterval(timer); }, []); // 初次加载和childId变化时重置 useEffect(() => { if (!childId) return; let active = true; setLoadingState('loading'); loadVaccineRecords(childId) .then((data) => { if (!active) return; dispatch({ type: 'LOAD_INITIAL_DATA', payload: { records: data.records, vaccines: data.vaccines } }); setLoadingState('success'); }) .catch((err) => { if (!active) return; setLoadingState('error'); console.error('[useVaccineRecords] load failed:', err); }); return () => { active = false; }; }, [childId]); // 逾期状态自动刷新 useEffect(() => { dispatch({ type: 'REFRESH_DUE_STATUS', payload: { today } }); }, [today]); const addRecord = useCallback((record: VaccinationRecord) => { dispatch({ type: 'ADD_RECORD', payload: record }); }, []); const markCompleted = useCallback((recordId: string, data: Partial<VaccinationRecord>) => { dispatch({ type: 'MARK_COMPLETED', payload: { recordId, completedData: data } }); }, []); return { ...state, loadingState, today, addRecord, markCompleted, }; }这个hook封装了三个核心逻辑:数据加载生命周期、跨天自动刷新、业务操作API。组件层只需要从这个hook解构出需要的状态和函数,完全不需要关心底层的数据加载和状态流转。这也是为什么我说Hooks适合数据驱动场景——状态的来源、触发时机、清理逻辑全都被封装进hook内部,业务代码只需要声明“我要什么”和“我改什么”。
useCallback在这里是必要的,因为addRecord和markCompleted会被传递给子组件,如果每次渲染都生成新函数引用,子组件如果用React.memo包裹就会失效。useCallback保证函数引用在依赖不变的情况下保持不变,避免无意义的子组件重渲染。
6.2 通过React Hooks连接加载状态与UI渲染
自定义hook封装好业务逻辑后,整个页面组件的核心UI就会变得非常简洁。数据加载和状态展示主要由一个核心组件驱动,配合LoadingState实现不同阶段的界面切换:
export function VaccineRecordsScreen({ route }: { route: { params: { childId: string } } }) { const { childId } = route.params; const { records, vaccines, loadingState, today, markCompleted, addRecord } = useVaccineRecords(childId); if (loadingState === 'loading') { return <RecordSkeleton />; } if (loadingState === 'error') { return <ErrorView retry={...} />; } return ( <View style={styles.container}> <VaccineSummary records={records} /> <SectionDivider title="接种记录明细" count={records.length} /> <VaccineRecordList records={records} vaccines={vaccines} today={today} onComplete={markCompleted} /> </View> ); }这段代码属于看完就懂的层级:loading状态显示骨架屏,error状态显示错误视图,正常情况渲染摘要组件和记录列表。所有业务细节都被hook和子组件消化了,这个页面组件的职责就只剩“视图编排”。
关于加载状态机,我强烈建议至少设计三个明确状态:idle、loading、success、error,而不是一个boolean的loading。boolean只能表达“加载中/非加载中”,但非加载中还有成功和失败两个子状态,不区分的话,错误提示和空态都只能用隐式判断来实现,代码会变怪。就这个项目而言,加载失败的情况确实存在(比如鸿蒙端文件被清理),有明确的error状态,用户至少能看到可用的退路。
6.3 应种日期与逾期判断的核心函数
疫苗接种记录里最核心的业务计算,是应种日期的推导和逾期状态的判断。我写了两个纯函数来处理:
// 根据出生日期和疫苗规则计算应种日期 export function calcScheduledDate(birthDate: string, rule: VaccineDoseRule, prevRecord?: VaccinationRecord): string { const birth = parseDate(birthDate); const baseDate = prevRecord?.actualDate ? parseDate(prevRecord.actualDate) : birth; if (rule.minIntervalDays && prevRecord) { const intervalDate = addDays(baseDate, rule.minIntervalDays); return formatDate(intervalDate); } if (rule.minAgeMonths) { const ageDate = addMonths(birth, rule.minAgeMonths); return formatDate(ageDate); } return formatDate(addDays(baseDate, rule.defaultDueOffsetDays)); } // 判断一条记录是否应标记为逾期 export function isOverdue(record: VaccinationRecord, today: string): boolean { return ( (record.status === 'pending' || record.status === 'booked') && !!record.scheduledDate && record.scheduledDate < today ); }calcScheduledDate的逻辑说明:如果疫苗规则里定义了与上一剂次的最小间隔天数,就用上一针的实际接种日期加间隔天数;如果没有上一针就参考出生日期结合最小月龄要求;两者都没有就退到默认应种偏移天数。优先级从强约束到弱约束,这样才能保证计算出的日期尽量贴合真实接种程序。
isOverdue的判断是纯函数,任何时间点传入记录和今天都能独立判断出是否逾期。这样设计的好处是便于单元测试——不需要模拟组件生命周期,直接喂数据进去测输出就行。我在项目里为这个函数写了20多个测试用例,覆盖边界日期、同日判断、状态枚举等场景,跑一遍就能确认逾期逻辑在跨平台上的行为一致。
6.4 多端数据持久化实现
数据持久化方案我选了SQLite,原因在于记录数据是结构化且带查询条件的。之前用过 AsyncStorage 存JSON,但当记录超过一定量级后,全量加载和过滤的性能开始掉链子,而且并发写多标签也容易丢数据。后来换成SQLite,用事务包裹写入,崩溃恢复机制也完整很多。
在鸿蒙端,SQLite的库选择需要确认是否有对应的原生实现。目前社区比较成熟的方案是统一封装的SQLite插件,在Android/iOS上使用系统自带SQLite,在鸿蒙上通过适配层调用系统SQLite接口。我在存储层做了一层仓库模式:
export interface VaccineRecordRepository { queryByChildId(childId: string): Promise<VaccinationRecord[]>; insert(record: VaccinationRecord): Promise<boolean>; update(record: VaccinationRecord): Promise<boolean>; deleteByChildId(childId: string): Promise<boolean>; }这样封装的目的很明确:业务层依赖的是接口而不是具体实现。将来不管换数据库、加缓存层还是改为网络同步,只需要替换实现类,不需要改调用方。这在跨平台工程里是一个很重要的架构原则。
7. 实战问题排查:开发中遇到的坑与解法
7.1 状态不刷新:useState引用未变更
有一次我在录入新疫苗记录后,页面列表没有出现新数据。排查后发现,我在调用addRecord时直接对旧数组push,然后把同一个数组引用传给了setState。React的Object.is比较认为引用没变,直接跳过了重新渲染。
修复方式就是规范了所有数组更新必须返回新引用:
// 修复前 state.records.push(newRecord); setRecordList(state.records); // 修复后 setRecordList(prev => [...prev, newRecord]);这类问题最典型的特点就是:数据明明变了,界面纹丝不动。排查办法也很直接:在setState前后分别打印数据引用和内容,对比一下引用地址是否一致。如果一样,基本就是这个坑了。
7.2 useEffect依赖数组引用的死循环
还有一个高频问题——useEffect死循环。比如我写过一个统计数据用的effect,依赖了外部传入的statistics对象:
useEffect(() => { calculateAndRender(statistics); }, [statistics]);这个statistics对象是父组件每次渲染时重新计算生成的,引用每次都不同。于是子组件每次渲染都会触发effect,effect又setState,setState又触发父组件渲染,形成无限循环。打印日志时能看到接口请求被不断发起,应用卡死。
修复方案是让依赖收敛到原始值而不是整对象。我把statistics里真正关心的字段(比如totalDoses、completedDoses、overdueDoses)单独取出,依赖数组改成这些基础数据类型。React对比基础数据类型时只需要值相等,不再被引用不稳定困扰。
7.3 鸿蒙端字体与布局的系统性问题
鸿蒙端的RN渲染在默认字体上如果不去显式设置fontFamily,会发现字号渲染出来的视觉效果比Android偏大偏粗。这个问题的根子是系统字体渲染参数不一致,排查了很久才发现是默认字体解析差异导致。
解决办法是在应用的根组件统一设置默认文本样式,或者给需要精确控制的Text组件指定fontFamily和lineHeight。布局上还有一个常见的差异——安全区域(SafeArea)的计算结果,鸿蒙返回的顶部安全区高度和iOS的刘海屏逻辑不一样,如果沿用iOS的适配方式,在鸿蒙上会出现状态栏遮挡内容的问题。后来是在布局计算里用Platform分支,分别对鸿蒙、iOS做了不同的paddingTop值。
7.4 异步请求竞态问题的隐蔽表现
竞态问题还有一个比较隐蔽的表现:输入关键词搜索疫苗然后快速切换孩子,搜索结果会串。我没有用防抖来解决,而是用前面提到的active标记加了一个“请求序号”来做到后发先至:
const requestSeqRef = useRef(0); async function loadData(childId: string) { const seq = ++requestSeqRef.current; const result = await api.fetchRecords(childId); if (seq === requestSeqRef.current) { // 只有最新请求才能更新状态 dispatch({ type: 'LOAD_INITIAL_DATA', payload: { records: result } }); } }用自增序号代替布尔标记的好处是:即使连续发起10个请求,也只有序号最大的最新请求能真正更新状态,前9个迟到的响应全部被丢弃。这在业务上保证了界面永远展示最新的查询结果,而且在竞态场景下不用借助取消机制就能保证逻辑正确。
7.5 结语:项目复盘与个人踩坑心得
这个儿童疫苗接种记录模块做下来,我最大的体会是:React Hooks这种“状态 + 副作用”的心智模型,和数据驱动的业务场景配合度确实很高。useState守住数据边界,useEffect管理数据流动,useReducer规约状态变更方式,TypeScript在整个过程中发挥的类型约束和重构保护作用很难被替代。
有几个实际的开发建议可以供同行参考。第一,不要迷信复杂状态管理库,先问自己的数据流是不是局部的、单向的,如果是,Hooks本身就够用了;第二,所有日期字段用字符串存储,不要在存储层引入时区歧义;第三,平台差异处理要集中收敛,不要让Platform.OS散落在业务组件中;第四,用纯函数封装所有业务计算逻辑,让它们可以在任意平台做测试和复用。
如果你下一步想扩展功能,可以考虑增加基于应种日期的本地通知提醒,或者做接种数据的家人共享。核心状态逻辑已经稳定的时候,加通知并不难——在记录状态流转的action里加一个notification调度就行。这个项目已经证明了Hooks加TypeScript的组合在鸿蒙跨平台上完全走得通,剩下的就看你怎么把业务价值做到位了。
最后再分享一个小技巧:如果你也在鸿蒙上调试RN,记得把鸿蒙工程里的bundle更新命令集成到项目构建脚本里,直接通过一条命令完成bundle生成、资源拷贝、应用构建全流程。每次手工拷贝bundle,总有一天会忘记,然后就会遇到“改了代码但界面没变”的尴尬场景。自动化这个流程后,我后来再也没因为这个原因浪费过一个晚上。