资讯详情

OpenHarmony RN中实现useSessionStorage:基于Preferences的会话存储方案

📅 2026/10/14 4:16:40 | 华诺云谱 👁 阅读
OpenHarmony RN中实现useSessionStorage:基于Preferences的会话存储方案
1. 在OpenHarmony的RN运行时里sessionStorage连影子都没有在刚接到这个OpenHarmony RN项目的时候我第一反应是这跟普通React Native没差太多。直到想复用一个Web端的会话逻辑里面写了sessionStorage.getItem(session)页面直接报错我才意识到问题的根源——RN运行环境不是浏览器OpenHarmony上的RN也拿不到浏览器才有的sessionStorage。所以我才决定自己实现一个useSessionStorage让上层业务继续按照Web Storage的语义管理会话状态。这篇文章会从为什么缺、怎么选存储底座、完整代码、跨组件同步、踩坑记录几个方面展开适合在OpenHarmony上开发RN应用、或者想在React Native里做一个通用会话状态Hook的开发者参考。1.1 为什么会直接报错先说为什么会报错。React Native的JavaScript运行环境不管是内置的Hermes还是其他JS引擎都只有globalThis这类基础全局对象没有浏览器里的window、document自然也没有挂在window上的sessionStorage和localStorage。你写sessionStorage.getItem的一瞬间JS引擎会告诉你sessionStorage is not defined。这和RN能不能跑到OpenHarmony设备上没关系是运行时本身缺了这套Web API。有人会想那我直接挂一个Polyfill不就行了比如给全局对象塞一个模拟的sessionStorage实现。这个思路可以做但只解决了一半问题Polyfill把API补上了底层存储通道还是没有。如果Polyfill里只是用一个纯JS对象保存数据那么应用一旦被系统杀掉、JS引擎重新启动这些会话数据就全部清零。大部分业务场景里我们希望会话能撑过一次正常的应用退到后台或者至少能在用户误杀进程之后重新打开时尽量找回现场。1.2 会话这个词在App里需要重新定义Web里的sessionStorage语义非常清楚它属于某一个标签页标签页关闭数据就销毁。但App没有标签页的概念你不能照搬这套定义。在OpenHarmony的RN应用里我们需要自己回答一个问题这个会话的生命周期到底由什么决定我观察下来RN应用里对会话的主流需求其实有两种。第一种是进程级会话应用从冷启动开始到被杀掉为止这个范围内数据有效进程一死就清空。第二种是业务级会话比如用户登录态这个会话有过期时间可能是一个token的过期时间也可能是你自定义的有效期。因为需求不统一我在设计这个useSessionStorage时就没有把生命周期写死而是做成了可配置的默认情况下数据以内存为主需要跨启动恢复的字段再交给原生侧的会话存储落地。这样同一个Hook既能当纯内存会话用也能当成一个带过期机制的持久会话用。1.3 候选存储底座对比在OpenHarmony上做RN的会话存储底座无非三个方向纯JS内存、社区常见的键值存储库、OpenHarmony自己的Preferences。我把它们放在一起对比过结论比较直接。候选方案生命周期读写速度JS侧改造成本适合场景纯JS内存Map随JS引擎生命周期进程被杀即丢最快零成本代码自己维护临时状态、页面间瞬时传递社区键值存储库持久化到应用沙箱快但需要异步接口低但多一层依赖普通持久化配置OpenHarmony Preferences持久化到沙箱按应用隔离快有同步和异步接口需要NativeModule桥接会话级数据、登录态、恢复现场表格里我最推荐第三项Preferences。不是说前两个不能用而是OpenHarmony这套系统自带的键值存储有它独特的好处它和系统生命周期绑定得更好数据落在应用自己的沙箱目录本身还带缓存读写性能对于会话这种轻量级数据完全够用。后续如果你想再加用户级隔离、数据加密这套底座也留了接口不会做到一半发现没法扩展。2. 选型Preferences NativeModule为什么放弃纯JS内存不少人在听闻可以做纯JS内存方案时会觉得没必要再劳动原生侧。但我在模拟项目X里实际跑下来纯内存的坑不是功能性问题而是业务上无法接受的丢失。2.1 内存方案的好处和致命伤纯JS内存方案的好处非常明显同步、快、实现简单一个Map加几个方法就能搞定。如果只是想在两个页面之间传一个临时的筛选条件完全够用。但它的致命伤在于RN应用进程被系统回收。OpenHarmony设备上应用退到后台后进程被系统回收是常态不是偶发。用户填了一半的表单、刚登上的会话、正在浏览的列表位置一旦进程被杀内存方案全部归零。等你重新拉起应用只能看到冷冰冰的空白用户会认为这是个bug。尤其是登录态这类数据你不可能让用户每次都重新登录一遍。所以真正落到工程里会话存储要有持久化能力但持久化又不能简单套用localStorage那种无限保留的模型需要引入会话的管理逻辑。2.2 在OpenHarmony侧封装一个会话存储模块既然选定了Preferences就需要在原生侧暴露一组能力给JS调用。OpenHarmony的Preferences API整体思路和Web Storage有点像都是键值对但它不是浏览器APIJS侧没法直接调用必须通过一个NativeModule桥接。这里最容易被忽略的一步是获取宿主Context。React Native的NativeModule在初始化时不一定有上下文你需要找到当前Ability的Context然后才能创建Preferences实例。我当时的做法是在模块初始化阶段把Context缓存起来后续所有读写都复用同一个Preferences实例避免反复创建带来的性能损耗。原生侧代码的大致形态是这样import { preferences } from kit.ArkData; import { common } from ohos.app.ability.common; let sessionPreferences: preferences.Preferences | null null; function getSessionPrefs(context: common.UIAbilityContext): preferences.Preferences { if (!sessionPreferences) { sessionPreferences preferences.getPreferencesSync(context, { name: session_store }); } return sessionPreferences; } export function setSessionValue( context: common.UIAbilityContext, key: string, value: string ): void { getSessionPrefs(context).putSync(key, value); } export function getSessionValue( context: common.UIAbilityContext, key: string ): string | null { return getSessionPrefs(context).getSync(key, ) ?? null; } export function removeSessionValue( context: common.UIAbilityContext, key: string ): void { getSessionPrefs(context).deleteSync(key); } export function clearSession( context: common.UIAbilityContext ): void { getSessionPrefs(context).clearSync(); }这段代码里我用了同步接口因为会话存储的数据量很小单次读写时间是微秒级不会造成可感知的卡顿。如果你担心卡顿可以把接口改成异步returnJS侧再多包一层Promise。但要注意JS侧初始化Hook时需要同步读到初始值所以内存缓存那一层一定要有不能每次读都走原生。2.3 键值设计与序列化约定Preferences支持的数据类型有限为了统一我规定所有写入的值都必须是字符串。JS侧写入时先JSON.stringify读取时再JSON.parse。键的命名也不要随意拍脑袋我用了一套简单的约定会话相关的键统一加session:前缀例如session:token、session:profile需要跨会话清理的批量数据再单独用一个session:meta记录当前的会话ID和过期时间。这套约定的意义在于后续做启动清理时可以一次性遍历所有以session:开头的键按meta判断是保留还是清除而不是散落各处不好管理。真实的项目里键命名混乱带来的维护成本比存储选型错误更可怕。3. 自定义useSessionStorage从接口到Hook如果说前面是储备工作这一节就是正文。我的目标很明确让业务侧写起来和Web的sessionStorage几乎一样但底层能自动串联内存缓存和原生Preferences。3.1 接口长什么样Web Storage的完整接口有getItem、setItem、removeItem、clear、key、length但在Hook场景下key和length基本不会有人用。我保留了前四个额外加了一个subscribe方法用来做跨组件同步。export interface ISessionStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; removeItem(key: string): void; clear(): void; subscribe(listener: () void): () void; }注意接口里的setItem是同步返回的不用Promise。这不是说底层不落盘而是我们刻意做成内存同步更新、原生异步落盘先让所有订阅者立刻拿到新值再偷偷把数据写进Preferences。用户点一下登录按钮界面要马上反映登录态不能等磁盘写完。3.2 Store实现内存优先异步落盘核心Store长这样type Listener () void; const cache new Mapstring, string(); const listeners new SetListener(); function markChanged() { listeners.forEach((listener) { try { listener(); } catch (err) { console.warn(session storage listener error, err); } }); } export const sessionStore: ISessionStorage { getItem(key: string): string | null { return cache.has(key) ? cache.get(key)! : null; }, setItem(key: string, value: string) { const changed cache.get(key) ! value; if (changed) { cache.set(key, value); markChanged(); } writeThrough(key, value); }, removeItem(key: string) { if (cache.delete(key)) { markChanged(); } writeThroughRemove(key); }, clear() { if (cache.size 0) { cache.clear(); markChanged(); } writeThroughClear(); }, subscribe(listener: Listener) { listeners.add(listener); return () { listeners.delete(listener); }; } };writeThrough走NativeModule如果原生模块当前没准备好就直接忽略下次启动再从Preferences里恢复。这里有一个我认为很重要的细节只有当内存里的值真的变化时才触发markChanged。如果业务侧重复set了同一个值没必要把所有订阅组件重新渲染一遍这是免费的性能优化。3.3 Hook的封装方式Hook层我把useState和subscribe组合起来让组件能自动跟随Store更新import { useState, useEffect, useCallback, useRef } from react; export function useSessionStorageT string( key: string, initialValue?: T ): [T, (value: T | ((prev: T) T)) void] { const readValue useCallback((): T { const raw sessionStore.getItem(key); if (raw ! null) { try { return JSON.parse(raw) as T; } catch { return raw as unknown as T; } } return initialValue as T; }, [key, initialValue]); const [state, setState] useStateT(readValue); const stateRef useRef(state); useEffect(() { stateRef.current state; }, [state]); useEffect(() { const unsubscribe sessionStore.subscribe(() { setState(readValue()); }); return unsubscribe; }, [key, readValue]); const setValue useCallback( (value: T | ((prev: T) T)) { const next typeof value function ? (value as (prev: T) T)(stateRef.current) : value; sessionStore.setItem(key, JSON.stringify(next)); }, [key] ); return [state, setValue]; }这里有个小技巧用stateRef保存最新state是为了在setValue支持函数式更新时能拿到上一帧的真实值而不需要在useCallback依赖里加入state导致子组件频繁重渲染。同时写Store会触发订阅回调组件通过订阅拿到新值完成渲染所以setValue里不需要再手动setState一次。3.4 类型安全和默认值的处理TypeScript的使用者最关心的肯定是类型安全。上面Hook用了泛型T所以可以这样用const [userInfo, setUserInfo] useSessionStorageUserInfo(session:user, { name: , role: guest });读取的时候会用JSON.parse把存储的字符串还原成对象。如果解析失败就回退到初始值。这里需要特别提醒千万不要在初始值里放函数、Symbol、Date实例因为JSON.stringify对这些类型的处理和你期望的不一样。会话存储不是万能序列化工具它只适合承载纯数据。我用了一个parse函数来兜底避免个别脏数据直接让整个组件崩溃function parseSessionValueT(raw: string, fallback: T): T { try { return JSON.parse(raw) as T; } catch { return fallback; } }4. 跨组件同步订阅模型和React状态边界4.1 为什么要同步单个Hook并不稀奇但实际业务里一个会话状态往往要被多个页面、多个组件共享。最典型的是登录页把token写进Store导航栏和我的页面都要立刻从游客变成已登录。如果每个组件各自维护一份本地state登录之后要么手动通知要么刷新页面体验极差。Web端有storage事件可以跨标签页同步React Native里根本没有这个机制所以必须自己做发布订阅。我的方案是让Store维护一个SetListener每个使用useSessionStorage的组件在挂载时注册监听卸载时移除。这样任何一个组件调用setItem所有依赖同一个key的组件都会重新读值并渲染。实现不复杂但生命周期管理很容易翻车。4.2 发布订阅要小心生命周期最容易出现的问题是内存泄漏。如果组件卸载的时候忘了取消订阅listeners集合会越攒越多老组件虽然看不见了但依然会被Store通知轻则白跑渲染重则报错。所以我在subscribe里直接返回unsubscribe函数Hook的useEffect里也严格做了清理。React 18以后开发环境的StrictMode会模拟组件挂载、卸载、再挂载的过程这相当于对订阅清理逻辑白嫖做了一次压力测试。如果你的清理不干净StrictMode下很容易出现重复订阅。我建议把subscribe实现成幂等操作同一个listener如果已经存在Set不会重复添加这比用数组来存储监听者要安全得多。4.3 和Redux这类全局状态的关系有人会问既然要跨组件共享为什么不直接用Redux我的看法是Redux解决的是复杂全局状态管理而会话存储的核心诉求是持久化恢复。如果你为了存一个token就把Redux引进来等于杀鸡用牛刀还得额外做Redux和Preferences之间的同步。实际上我们这个Store本质上已经是一个极简的外部状态容器useSessionStorage是它的React绑定层。如果你项目里React版本支持useSyncExternalStore甚至可以替换成这个更标准的Hook逻辑会更干净。但我用useEffect加useState的方式一是对旧版本RN兼容更好二是代码直白业务同事看一眼就懂。4.4 原生侧事件回传的可选设计还有一种不太常见但值得考虑的场景原生侧主动修改会话数据。比如系统级通知清除了会话或者在冷启动时从某个系统参数恢复现场。这时不能只靠JS侧的Store自己更新需要原生模块主动向JS发事件。在RN框架里原生模块可以通过DeviceEventEmitter或类似的通道把事件发到JS侧。JS侧收到后重新拉取session:meta和受影响字段更新内存缓存再通知订阅者。这个扩展不是必须的但我在设计接口时保留了这条通路避免后续想做原生联动时再返工。5. 踩坑集序列化、竞态与会话过期这块是整篇文章里我认为最值钱的部分。以下问题不是想象出来的而是模拟项目X实际运行中真真切切踩过的。5.1 JSON.stringify的暗坑我规定所有value写入前都要JSON.stringify但序列化本身坑很多。第一坑是undefined对象里的undefined字段会被直接丢掉第二坑是Date对象JSON.stringify会把Date转成字符串反序列化后变成字符串而不是Date第三坑是Map和Setstringify之后会变成空对象{}。我的建议是在项目里明确一条规矩会话存储只放JSON可序列化数据。如果一定要存时间就自己先格式化为number时间戳或ISO字符串如果一定要存列表优先用普通数组而不是Set。定好规矩能少掉80%的诡异bug。下面这张表方便对照数据类型stringify结果反序列化期望建议string / number / boolean正常正常直接用object正常正常用undefined字段被删除丢字段避免Date变成ISO字符串字符串不是Date先转时间戳Map / Set变成{}空对象转数组5.2 并发写的乱序问题会话存储虽然写入频率不算高但并发写还是真实存在的。两个组件几乎同时setItem(session:user, A)和setItem(session:user, B)如果底层Preferences用的是异步接口两次写完成的顺序不一定和调用顺序一致。最后磁盘上的值可能变成A但内存里已经是B下次冷启动恢复时就出现回档。我解决的思路很朴素内存同步更新永远以最后一次调用为准原生侧写入则用一个串行队列排队保证写盘顺序。如果担心串行队列积压可以只保留最后一次写盘任务更精细一点的做法是给每次写入带一个单调递增的版本号旧版本写之前先检查版本号小于当前版本就丢弃。5.3 会话过期和跨会话残留纯Web的sessionStorage关闭标签页就清空不需要处理过期。但在RN应用里应用可能被杀掉而Preferences里的数据还躺着。如果不清理就会出现上一个用户的信息残留到下一个用户的严重问题。我的做法是维护一个session:meta键interface SessionMeta { sessionId: string; expireAt: number; }应用启动时先读session:meta判断当前时间和expireAt。如果已经过期或者sessionId和上次记录的不一样就把所有session:前缀的键全部清空再写入新的meta。这样即使应用异常退出下次启动也能通过过期时间兜底不会让会话数据无限期存活。5.4 Preferences 初始化没就绪怎么办NativeModule不是一开始就能读写Preferences的它需要拿到Context并完成实例化。如果业务侧在初始化阶段就调用useSessionStorage可能读到undefined。我的兜底策略分两层第一层内存缓存永远先存在即使原生没ready会话数据也能在本次进程内正常工作第二层原生ready之后主动触发一次markChanged让所有订阅组件重新从Store读一次把Preferences里的历史数据补齐。这里的关键是不要让业务侧感知到异步过程。Store对外保持同步接口内部自己吞掉Promise的pending状态等就绪事件来了再刷新。这样业务代码从头到尾不需要写await心智负担小很多。6. 落地效果和后续扩展6.1 在模拟项目X里的用法我在模拟项目X里把useSessionStorage用在了三个地方登录态、用户资料、列表筛选条件。登录成功后直接写入token和用户资料导航栏和侧边栏立刻响应切到下一个页面筛选条件也能原样恢复不需要页面间通过路由参数来回传。用法非常直观const [token, setToken] useSessionStoragestring(session:token, ); const [profile, setProfile] useSessionStorageProfile(session:profile, { nickName: , avatarUrl: });会话过期这块我在session:meta里设置了两个小时的有效期。用户把应用退到后台超过两个小时再回到应用中启动时会自动清理会话并让用户重新登录。这个设定和业务方对齐过大家理解一致没有歧义。6.2 可以继续扩展的方向如果项目变大我建议继续做几件扩展。第一是加用户维度隔离比如session:${userId}:token防止多账号登录时数据串味。第二是敏感字段加密Preferences存明文毕竟不太稳妥OpenHarmony有自己的加解密能力写入前对token这类关键字段加密读取时再解密。第三是支持批量过期现在只有一个全局expireAt如果希望不同key有不同的失效时间可以给每个key单独维护过期时间戳读取时惰性清理。另外如果数据量真的超过Preferences适合的范围比如超过几千条或者在页面上频繁查询就应该切换到关系型数据库或者对象关系映射框架。但会话存储这种轻量场景Preferences的简单反而是一种优势。6.3 不要把会话存储当万能状态池最后分享一个我自己的体会useSessionStorage好用但不代表可以什么都往里塞。频繁变化的值比如输入框内容、拖拽位置、临时UI状态都放进去的话每次按键都会触发Store通知、序列化、写盘所有订阅组件全被拖下水。正确边界是数据需要跨页面而不需要持久化可以用纯内存Store需要跨启动恢复的才用带Preferences落地的会话存储高频临时状态就该老老实实放在组件自己的useState或者useReducer里。在我实际项目里这个边界划清之后代码清晰了很多性能也稳住了。开发时调试也很方便因为Store层有一次日志输出任何页面改了会话字段我只要翻日志就能知道是谁改的、改成了什么排查问题比原来靠猜省了不少时间。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑