OpenHarmony上React Native持久化:封装useLocalStorage Hook实战
2026/9/23 15:53:38 网站建设 项目流程

如果你在 OpenHarmony 设备上用 React Native 做过业务开发,大概率会遇到一个让人想挠头的问题:明明在 Web 端随便用的localStorage,到了 RN 里突然不能直接用了;等你装好@react-native-async-storage/async-storage,又可能在 OpenHarmony 上发现三方包适配不全、原生模块挂不上,最后应用启动直接白屏。这篇文章就围绕“在 OpenHarmony 上跑 RN 时,如何自己封装一个useLocalStorageHook”这件事展开,把从设计思路、完整代码到排查经验一次讲透。无论你是刚从 Web 转 RN 的前端,还是正在做 OpenHarmony 应用适配的客户端同学,这篇内容都能帮你少踩几个坑。

先说结论:在 OpenHarmony 上,localStorage(Web 里的那个)是不存在的,RN 官方推荐用异步存储方案;但 OpenHarmony 的 RN 适配层和 iOS/Android 不完全一样,直接套社区库未必能跑起来。所以与其到处找“能用”的封装,不如自己写一个具备适配层的useLocalStorageHook,底层存储可替换,上层业务照常用。这样既能保证 App 在 OpenHarmony 上不白屏,又能让代码在其他平台保持一致的体验。

1. OpenHarmony 上的 RN:先搞清楚存储用在哪一层

1.1 RN 的标准存储方案和 Web 的差异

做过 Web 开发的人对localStorage再熟悉不过:同步读取、字符串键值对、整个页面共享。但 React Native 不是浏览器环境,没有window,也没有 DOM Storage API。RN 官方推荐的持久化方案是@react-native-async-storage/async-storage,它暴露的是一组异步方法:getItemsetItemremoveItem,底层在 Android 上对应 SQLite 或 SharedPreferences,在 iOS 上对应原生存储。

这带来两个直观差异:第一,所有读取和写入都是异步的,没法在组件渲染期间直接同步拿到值;第二,键值对虽然也叫“key-value”,但它更像一个“只存字符串”的字典,存对象需要自己JSON.stringify,读出来需要自己JSON.parse。这个差异决定了我们封装 Hook 时不能照搬 Web 的写法。

1.2 OpenHarmony 适配层的现实约束

OpenHarmony 上跑 React Native,目前主要依赖的是 openharmony 社区维护的 React Native 适配层(通常叫react-native-harmony)。它会把 RN 的 JS 层组件映射到 OpenHarmony 的 ArkUI 原生组件上,同时通过一套原生模块机制提供设备能力调用接口。

问题就在于:很多 RN 社区知名的三方库,都是优先适配 Android/iOS,OpenHarmony 的适配往往滞后或者由社区个人维护。AsyncStorage就是一个典型例子。如果你在项目里直接npm i @react-native-async-storage/async-storage,然后跑在 OpenHarmony 设备上,轻则功能失效,重则在原生侧找不到模块直接崩溃,表现就是白屏。这里不是社区库质量不行,而是适配层没到位,JS 侧调用的原生模块不存在。

这个现实约束决定了我们最好自己做一层封装:上层 Hook 的 API 保持稳定,底层存储实现可以随时切换——在 OpenHarmony 上走官方提供的存储接口,在 Android/iOS 上走 AsyncStorage,甚至开发阶段先用内存模拟。

1.3 从“启动白屏”反推 Hook 设计

“React Native 启动白屏”是一个搜索量很高的词,常见原因不少:bundle 加载失败、入口组件没渲染、原生模块初始化异常。但在我实际排查 OpenHarmony 项目时发现,有一个容易被忽略的原因就是应用启动早期同步依赖了本地存储

比如,很多开发者会在入口组件里写类似这样代码:

const user = JSON.parse(localStorage.getItem('user') || '{}');

在 Web 端这没问题,但在 RN 的 OpenHarmony 环境里,localStorage压根不是全局变量,这行代码会直接抛ReferenceError。JS 线程一崩,原生层等不到渲染指令,屏幕自然白在那里。

所以,一个健壮的useLocalStorage首先必须解决“异步初始化”问题:组件先渲染成一个合理的默认态,存储值到位后再触发一次更新,而不是在渲染函数里同步去读存储。这个思路贯穿我们后面的所有实现代码。

2. 动手写 Hook 前:先明确需求边界

2.1 三个绕不开的核心约束

自定义 Hook 前先别急着敲代码,想清楚需求。我在项目里总结了三个约束,几乎决定了所有代码细节。

约束一:读取是异步的,但 UI 必须是同步可靠的。存储读取异步,意味着 Hook 内部必须有状态机:初始化中、读取完成、读取失败。上层组件不能因为存储还没读完就空白,必须给一个合理默认值。

约束二:多个组件可能同时用同一个 key。一个 App 里,设置页改了用户名,首页的头像却还显示旧值,这就是典型的“多实例不同步”。Hook 内部不能只在各自组件内维护状态,需要有一个跨实例的同步机制。

约束三:存的是对象,不是字符串。业务代码希望调用setUser({ name: '张三' }),不希望每次都手动JSON.stringify。所以 Hook 需要在内部做序列化和反序列化,并提供容错处理——因为存储里的数据可能是手改的、旧版本的、甚至损坏的。

2.2 API 设计:从使用方角度倒推

先确定“用起来是什么感觉”,再实现内部逻辑。我的useLocalStorage最终长这样:

const [user, setUser, removeUser] = useLocalStorage<UserInfo>('user', defaultUser);

这个 API 参考了useState的用法,又额外暴露了一个remove方法。参数上分为三块:

  • key:存储键名,全局唯一。
  • initialValue:默认值,在存储尚未读取完成或读取失败时使用。
  • options:可选配置,比如自定义序列化器、存储实例、事件总线。

返回值是三元组:当前值、更新函数、删除函数。更新函数和useState一样,既支持直接传值,也支持传函数prev => next。这个设计有两个好处:一是上层几乎零学习成本;二是函数式更新能避免“读到旧值再写回”导致的竞态覆盖。

2.3 存储适配层:不要写死 AsyncStorage

既然要在 OpenHarmony 上用,底层就不能写死某一个库。我会先定义一个非常简单的接口:

export interface StorageLike { getItem(key: string): Promise<string | null>; setItem(key: string, value: string): Promise<void>; removeItem(key: string): Promise<void>; }

这其实就是 AsyncStorage 的一个子集。Android/iOS 环境直接传入AsyncStorage实例;OpenHarmony 环境传入自己用原生模块封装好的实现;测试环境传一个内存实现。Hook 内部只依赖这个接口,不依赖具体库。

这样一来,你的业务代码不用关心跑在什么系统上,只管调用useLocalStorage。后面如果 OpenHarmony 官方适配好了某个 AsyncStorage 实现,也只需要在入口处替换一行代码,业务侧零改动。

2.4 同步机制与内存缓存的设计取舍

跨组件同步,我采用的方案是“全局缓存 + 订阅通知”。具体来说:

  • 内存中维护一个Map<key, value>,每次读取成功或写入成功后更新缓存。
  • 维护一个监听器集合,setItem时通知所有使用该 key 的 Hook 实例。
  • 组件卸载时自动取消订阅。

这个方案比“每次都重新getItem”快很多,也比“每次向原生存储广播”简单可靠。需要注意:内存缓存只是 UI 层的数据来源,持久化以原生存储为准。App 冷启动后,第一个 Hook 实例挂载时会先读取原生存储,再写入缓存。

3. 完整实现:从最小版本到工程可用

3.1 先写一个能用的最小版本

我习惯先写一个不具备同步机制的基础版,保证逻辑链路是通的。

import { useState, useEffect, useCallback } from 'react'; import type { StorageLike } from './types'; export function useLocalStorage<T>( key: string, initialValue: T, storage: StorageLike ) { const [storedValue, setStoredValue] = useState<T>(initialValue); useEffect(() => { let cancelled = false; storage.getItem(key).then((raw) => { if (cancelled || raw === null) return; try { const parsed = JSON.parse(raw); if (!cancelled) setStoredValue(parsed); } catch (err) { console.warn(`[useLocalStorage] parse key "${key}" failed`, err); } }); return () => { cancelled = true; }; }, [key, storage]); const setValue = useCallback( (value: T | ((prev: T) => T)) => { setStoredValue((prev) => { const next = typeof value === 'function' ? (value as (p: T) => T)(prev) : value; const raw = JSON.stringify(next); storage.setItem(key, raw).catch((err) => { console.warn(`[useLocalStorage] set key "${key}" failed`, err); }); return next; }); }, [key, storage] ); const removeValue = useCallback(() => { storage.removeItem(key).catch((err) => { console.warn(`[useLocalStorage] remove key "${key}" failed`, err); }); setStoredValue(initialValue); }, [key, storage, initialValue]); return [storedValue, setValue, removeValue] as const; }

这里有几个细节值得注意:

  • cancelled标志位避免组件卸载后setState报警告。
  • JSON.parse放在try/catch里,遇到脏数据不能崩应用。
  • setValue使用函数式更新,确保多次连续写入时不会基于旧值计算。

3.2 版本升级:JSON 序列化与错误兜底

最小版本里硬编码了JSON.stringifyJSON.parse,这大多数场景够用,但也有限制:undefined会被JSON.stringify变成空,Date会变成字符串,循环引用直接抛错。所以我增加了可配置的serializerdeserializer

export interface UseLocalStorageOptions<T> { serializer?: (value: T) => string; deserializer?: (raw: string) => T; storage?: StorageLike; defaultValue?: T; }

判断逻辑很简单:options.serializer存在就用自定义的,否则默认JSON.stringify;读取时options.deserializer存在就用它,否则JSON.parse。此外,解析失败时还有一层兜底:

try { const parsed = deserializer(raw); if (parsed !== undefined) { setStoredValue(parsed); cache.set(key, parsed); } } catch (err) { console.warn(`[useLocalStorage] fallback to default value, key="${key}"`, err); setStoredValue(options.defaultValue ?? initialValue); }

这里有个容易被忽略的点:deserializer返回undefined是合法情况,不能当作解析失败。所以判定条件是“是否抛异常”,而不是“返回值是否为空”。

3.3 跨组件同步:事件订阅机制

只有当多个组件同时读写同一个 key 时,你才会发现“各管各的”是有问题的。我在实现里加入了一个轻量级的事件订阅器,不引入额外依赖,三十行代码搞定。

type Listener<T> = (value: T) => void; const cache = new Map<string, unknown>(); const listeners = new Map<string, Set<Listener<unknown>>>(); function subscribe<T>(key: string, listener: Listener<T>) { if (!listeners.has(key)) { listeners.set(key, new Set()); } listeners.get(key)?.add(listener as Listener<unknown>); return () => { listeners.get(key)?.delete(listener as Listener<unknown>); }; } function emit<T>(key: string, value: T) { listeners.get(key)?.forEach((listener) => listener(value)); }

然后在setValue时,写入成功后除了更新本地状态,还要更新缓存并emit

storage.setItem(key, raw) .then(() => { cache.set(key, next); emit(key, next); }) .catch((err) => console.warn(...));

组件挂载时订阅:

useEffect(() => { const unsubscribe = subscribe(key, (value) => { setStoredValue(value); }); return unsubscribe; }, [key]);

这样,设置页保存了用户信息,首页的 Hook 会立刻收到最新的值并触发重新渲染。不会出现改完设置切回首页还要手动刷新的情况。

3.4 在 OpenHarmony 上接入原生存储

OpenHarmony 环境下,原生存储的方案通常有两种:一种是用@ohos.data.storage(首选项)封装一个StorageLike实现;另一种是使用已经适配好 OpenHarmony 的 AsyncStorage 版本。无论哪种,核心都是让getItem/setItem/removeItem真正落到系统存储上。

@ohos.data.storage为例,大致封装思路是:

  • 在 ArkTS 侧创建一个Preferences实例,指定文件路径。
  • 通过 TurboModule 或原生模块把get(key)put(key, value)delete(key)暴露给 JS 侧。
  • JS 侧写一个StorageLike实现,内部调用这些原生方法。

需要注意:Preferences 的写入是异步落盘的,频繁写入要考虑合并;读取尽量在 Hook 挂载时一次性完成;写入失败时要向上抛出错误,让 Hook 里的catch能记录日志。

如果团队暂时没有精力桥接原生模块,也完全可以用“内存存储”临时顶上。开发期能跑通业务,后续再替换成正式实现。这也是我们设计StorageLike接口的收益所在。

3.5 代码组织与文件结构

实际项目中建议把代码拆成下面几个文件,职责清晰:

src/hooks/ useLocalStorage/ index.ts // Hook 主出口 storage.ts // 缓存、订阅、事件发射 types.ts // 类型定义 storages/ asyncStorage.ts // AsyncStorage 实现 memory.ts // 内存实现,测试/开发用 ohos.ts // OpenHarmony 原生存储实现

不要把所有代码塞进一个文件里。后期加单元测试、换底层实现、排查 bug,都方便很多。

4. 实测中踩过的坑:排查与解决

4.1 启动白屏:从哪一步开始查

我在 OpenHarmony 设备上调试时,启动白屏是最常见的问题,也是被热搜词反复提及的。我的排查顺序固定为:

现象可能原因排查方式
点击应用图标后一直白屏bundle 未加载检查 Metro/bundle 路径,查看设备日志是否报 404
白屏但有日志输出JS 入口报错查看 console 错误,定位是否引用未定义全局变量
白屏且原生日志报模块缺失原生模块未注册检查react-native-harmony各模块是否齐全
白屏但过了几秒恢复异步初始化阻塞渲染查看是否在渲染阶段同步读取存储

如果你的useLocalStorage在初次渲染时同步读取存储,大概率会触发第二类问题。一定要确保初次渲染不依赖存储返回值。

4.2 写入后重新进入页面,值又变回去了

这个坑特别隐蔽。场景是:A 页面设置了一个值,B 页面读取,发现还是旧的。排查后发现问题出在异步写入顺序上。

比如连续执行两次setItem

setValue('a'); setValue('b');

如果底层存储没有按调用顺序落盘,后写的b可能先写入,a后写入,最终持久化的是旧值。解决方案有两个:

  • 在 Hook 内部维护一个key粒度的写入队列,串行执行setItem
  • 对只关心最新值的场景,可以在写入前做防抖,比如 200ms 内的多次写入只落一次盘。

相比之下第二种更实用,但要注意:防抖会让“关闭 App 瞬间丢失最后几次写入”。建议在 App 进入后台时调用一次flush,把 pending 的写入立即落盘。

4.3 多个组件用同一个 key,数据不同步

这个我在 3.3 节通过事件订阅解决了,但实际项目里还可能遇到“跨页面不同步”。比如 A 页面用了 Hook,B 页面直接通过原生方法改了存储,A 页面收不到通知。

这种情况,订阅机制救不了,因为通知发生在 JS 层,原生写入不会发事件。我的建议很简单:业务代码统一走 Hook,不直接操作底层存储。如果真有特殊场景必须直接写,那就手动调用一次emit(key, newValue),或者干脆重新刷新页面数据。

4.4 组件卸载后 setState 警告

React 18 之后,卸载后 setState 不再警告,但内存泄漏隐患还在。更重要的是,异步读取存储的回调如果在卸载后才返回,可能触发一次无效渲染。我在 3.1 的代码里用cancelled标志位解决了这个问题。

还有一个更隐蔽的场景:storage.getItem本身很慢,用户已经切换到下一个页面,但旧页面的回调还在执行。虽然不会崩,但如果回调里有复杂的解析逻辑,会占用 JS 线程,导致新页面卡顿。建议在读取大对象时做异步分片解析,或者至少把解析逻辑放到setTimeout里让出主线程。

4.5 OpenHarmony 设备兼容性差异

说到设备兼容,这里有一个绕不开的背景:OpenHarmony 不只跑在手机上,还会跑在带屏带交互的开发板、智能家居设备甚至工业设备上。不同设备的能力差异很大,主要体现在:

  • 系统版本:部分设备还停留在 API 7/8,标准系统能力不完整;新设备可能支持 API 10+。
  • 存储实现:不同设备对 Preferences 的支持程度有差异,有的设备写入偏慢,有的设备对单条数据大小有限制。
  • 内存规划:低端设备 JS 引擎内存紧张,大 JSON 解析更容易触发 GC 卡顿。

我给出的建议是三层防护:

  1. 不要在低端设备上存超大对象,单条数据尽量控制在几十 KB 内。
  2. 为不同设备能力做降级,比如 API 级别不够的设备使用内存模拟存储。
  3. 在真机上做一次基础的性能测试,重点看 Hook 首次挂载到数据可用之间的耗时。

5. 工程化:测试与版本管理

5.1 用 Jest 给 Hook 写单元测试

自定义 Hook 不写测试等于埋雷。我在项目里用@testing-library/react-nativerenderHook,配合一个简单的内存存储实现,覆盖了核心场景:

const memoryStorage: StorageLike = { data: new Map<string, string>(), async getItem(key) { return this.data.get(key) ?? null; }, async setItem(key, value) { this.data.set(key, value); }, async removeItem(key) { this.data.delete(key); }, };

测试用例至少覆盖以下 8 个场景:

用例断言
初始值在读取到存储前展示result.current[0]等于 initialValue
读取到已有值时更新挂载后异步更新为存储值
写入后返回新值act后值更新
删除后恢复默认值act后值等于 initialValue
存储数据损坏时兜底不抛异常,值保持默认
多个实例读写同一 key值保持一致
卸载后不触发状态更新无警告、无内存泄漏
自定义 serializer 生效写出的字符串符合预期

5.2 版本兼容组合参考

结合我的实际操作经验,下面这组版本组合相对稳妥,供参考:

组件版本建议
React Native0.72 及以上
react-native-harmony0.72.x 对应适配版
TypeScript5.0 以上
测试工具@testing-library/react-native 12+

要不要引入@react-native-async-storage/async-storage,取决于你的 OpenHarmony 适配层是否提供了对应支持。如果暂时没有,就先用自研的StorageLike实现,不影响业务。

5.3 后续可以扩展的方向

完成基础useLocalStorage后,还可以继续增强:

  • 加密存储:敏感数据在写入前用系统级密钥加密,暴露encrypt选项。
  • 过期时间:给每条数据加时间戳,读取时判断是否过期,适合缓存类业务。
  • 迁移机制:存储数据结构升级时,通过version字段触发自动迁移。
  • SSR 兼容:如果你的 RN 项目接了服务端渲染,Hook 需要区分“初始化阶段”和“客户端阶段”。

这些扩展不会影响核心 API,都往options里加配置即可。

写在最后的实操体会

我最初设计这个useLocalStorage纯粹是为了解决 OpenHarmony 设备上的持久化问题,后来发现它反而让代码在 Android/iOS/OpenHarmony 三端保持了一致性。这给了我最直接的体会:跨端开发里,真正值钱的不是某个平台的 API,而是你抽出来的那一层稳定抽象。

最后再分享一个小技巧:如果你在 OpenHarmony 上调试时发现存储一直不生效,先别急着看 Hook 逻辑,直接在原生侧写一段测试代码,确认Preferences能不能正常写入和读取。很多时候问题并不在 JS 层,而在原生桥接没有真正打通。底层不通,上层再怎么封装都是空中楼阁。

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

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

立即咨询