
1. 不就是一个“存字符串”的事吗——草稿系统被低估的三个问题有一次线上反馈让我印象特别深一个用户在新版本里写了一篇二十多分钟的长文中途切出去拍了张封面图回来之后 App 已经被系统回收再点进编辑页白茫茫一片什么都没了。他在评论区措辞很激烈但说实话这种场景换成我也得炸。在 React Native 里做一个本地草稿功能乍一看不就是把输入内容塞进 AsyncStorage 嘛一个setItem、一个getItem就完事了。可一旦你认真对待这件事会发现它背后藏着三个互相纠缠的硬问题草稿怎么恢复才不丢、不覆盖草稿什么时候该过期、怎么清理才不会误删App 升级之后老格式的草稿怎么迁移到新代码上这三个问题单拎出来任何一个都足以让你在线上踩一个大坑。标题里的“恢复、过期与版本迁移”不是三个独立模块而是一条链路上的三道关卡——恢复时要做过期判断恢复前要做版本迁移迁移失败还要降级处理。这篇文章我就把完整的设计思路、选型逻辑和踩坑过程写清楚适合正在做内容型 RN 应用、或者准备给现有项目补草稿能力的同学参考。我默认你已经对 React Native 的基础开发不陌生但即使你是刚入门的前端照着思路和代码也能落地。2. 存储层选型AsyncStorage 够用但你要知道它的短板在哪2.1 三个候选方案的横向对比动手写代码之前先得定存储方案。市面常用的就三种AsyncStorage、MMKV、SQLite。我这里说的是“草稿”场景不是通用数据层方案所以评判标准很明确读写够不够快、会不会阻塞 UI、容量是否足够、接入成本高不高。方案读写方式性能感受数据容量接入成本适合场景AsyncStorage全异步一般高频写会有明显延迟通常够用零成本官方包轻量、低频草稿MMKV同步快毫秒级大需要原生构建高频读写、已使用原生模块SQLite同步/异步依赖库实现大适合结构化较高复杂查询、大量记录如果你是纯功能型草稿比如一个发布器的正文编辑单条内容就几 KB 到几十 KBAsyncStorage 的容量完全没压力。但要注意AsyncStorage 的底层实现是序列化整库数据量越大单次写入越慢。我之前在一个社区类 App 里测过当整个库积累了 5MB 以上的数据时一次setItem在 Android 低端机上能飙到几十毫秒甚至上百毫秒如果每次键盘输入都触发一次保存体感就是打字卡顿。MMKV 是腾讯开源的 key-value 存储读写是同步的性能非常夸张而且支持加密。它的同步特性对草稿这种“写入后立刻想确认”的场景特别友好——调用完set你不需要等回调逻辑上更直白。但代价是它是原生模块热更新、灰度包都要跟着适配如果你们团队对原生构建流程不太熟接入时会多花一些时间。SQLite 一般我不推荐用于草稿。草稿的数据模型很简单不需要联表查询用 SQLite 属于杀鸡用牛刀而且数据库初始化、表结构管理、版本升级这些额外的心智负担完全没必要背。2.2 我的选型结论与适用边界我的结论很务实如果项目已经接了 MMKV或者草稿里包含用户敏感信息需要加密存储直接选 MMKV如果项目是纯 JS 的轻量应用选 AsyncStorage 完全没问题前提是你必须做写入节流后面会讲并接受它在极端情况下的缓慢。另外提一句如果草稿内容包含隐私信息比如身份证号、地址等建议在存储前做一层简单的加密或者直接用 MMKV 的加密能力。AsyncStorage 本身是明文存储的iOS 的钥匙串、Android 的 Keystore 倒是有但接入成本高多数场景没必要为草稿做这么重。我最后选了 AsyncStorage一个是因为当时的项目是纯 JS没有引入原生模块的打算另一个是草稿量不大单条内容最多几十 KB频率上靠节流控制住了。这个选型结论放到代码层面就是下面我要讲的 DraftManager 的实现。3. 恢复机制难点不在“读出来”而在“什么时候读、怎么还回去”3.1 恢复触发点与启动白屏的博弈很多人做恢复第一反应是“App 启动时读一次草稿然后塞回表单”。这个思路有一个致命问题启动时同步/过早地去读存储会拖慢首屏渲染。这就是为什么“react native 启动白屏”会成为热词。RN 应用启动本身就要经过 JS Bundle 下载/加载、Bridge 初始化这些阶段如果恢复草稿的逻辑再阻塞在启动链路上白屏时间会雪上加霜。AsyncStorage 的异步读虽然不会直接卡死 render但你在启动早期await它本质上就是在关键路径上增加了一个不可控的耗时点。正确的做法是分层恢复先渲染编辑页的空壳让用户看到页面已经出来了然后在useEffect里异步去读草稿读到之后做校验、迁移、过期判断最后根据结果决定是否提示用户恢复。启动进入编辑页 → 渲染空表单此时不碰存储→ useEffect 异步读取草稿 → 校验/迁移/过期判断 → 弹恢复提示或静默填充这一步是最容易忽略的因为本地读很快很多人在模拟器上跑着没感觉一到低端 Android 真机那个白屏就出来了。我第一次做的时候就是把读草稿和首屏渲染串在一起结果连 iOS 都能明显感觉到启动变慢。恢复的触发点也要想清楚。不是所有页面都需要恢复只有有“编辑器”形态的页面才需要比如发帖页、评论页、个人简介编辑页。不同页面用不同的entryKey区分我习惯用路由名加业务 ID 来拼比如post_editor_123、profile_editor_10086这样不会串数据。3.2 异步恢复的竞态问题别让旧数据覆盖新输入你以为读出来塞回去就完了还有一个隐蔽的坑竞态。场景是这样的用户进入编辑页App 还在异步读草稿的路上用户已经开始噼里啪啦打字了。几毫秒之后草稿读出来了你的恢复逻辑往表单状态里setState直接把用户刚输入的内容覆盖掉了。用户只看到一个现象我打了一个字结果前面凭空多出来一段旧内容。这个问题的根因是恢复动作没有判断“当前表单是否已经被用户修改过”。解决方案也很机械只有用户还没输入过任何内容时才允许自动恢复一旦检测到表单已有值就放弃自动恢复只静默保留草稿数据。实现上我是在表单状态里加了一个isDirty标记首次onChange时置为 true。恢复逻辑读取草稿后先看这个标记const [draft, setDraft] useStateStoredDraft | null(null); const [formData, setFormData] useStateFormData(emptyForm); const isDirtyRef useRef(false); useEffect(() { let cancelled false; restoreDraftFromStorage(entryKey).then((stored) { if (cancelled || !stored) return; if (isDirtyRef.current) { // 用户已经输入不能覆盖 return; } // 通过校验、迁移、过期判断后填充表单 setFormData(migrateAndValidate(stored)); setDraft(stored); }); return () { cancelled true; }; }, [entryKey]);这里cancelled标记也很重要。如果用户进入页面后立刻退出再进入另一个 entryKey 的编辑器上个页面的异步回调如果还没结束时把草稿塞给了新页面就是跨页面的数据污染。组件卸载时把标记位设回true是最便宜的防错手段。3.3 草稿归属用户切换时最容易翻车还有个容易翻车的地方是用户切换。如果你们的 App 支持退出登录再换个账号登录草稿的归属问题就浮出水面了——你不能让 A 用户写的草稿出现在 B 用户的编辑页里。我的做法是在草稿存储结构里加一个ownerId字段恢复和列出草稿时都带当前用户 ID 作为过滤条件。没有登录的场景比如游客模式单独用一个anonymous作为 ownerId登录后可以提示“游客草稿是否合并到当前账号”这个业务决策比较重建议产品和你们一起定技术侧只需要保证数据不串即可。entryKey 的粒度路由名 业务ID 用户 ID这样设计的好处是草稿天然按用户隔离恢复时不需要额外过滤误操作概率大幅下降。4. 过期策略草稿不是越久越好也不是到点就删4.1 为什么必须有过期机制有人觉得草稿嘛留着呗用户哪天想起来了还能继续写。但实际运营中你会发现不过期的草稿是一堆隐性问题存储无限增长。虽然单条不大但如果用户每次进来写一点就退出长期累积下来AsyncStorage 整库序列化的压力会越来越大。内容过时。用户三天前写的东西很可能已经不想要了。如果你不做任何提示就恢复反而让用户觉得莫名其妙“这什么旧东西”合规风险。用户主动删了内容结果过了半年发现草稿里还躺着原文这很容易引发隐私投诉。4.2 TTL 与 LRU 的组合拳过期策略我试过纯 TTL、纯 LRU最后发现组合效果最好。**TTL存活时间**控制的是“这条草稿最多活多久”。我的默认值是 7 天也就是expiresAt updatedAt 7 * 24 * 60 * 60 * 1000。用户每次编辑都会更新updatedAt所以只要用户一直在写草稿就永远不会过期只有真正被遗忘的草稿才会进入过期候选。**LRU最近最少使用**控制的是“同时保留多少条”。即使都没过期如果草稿数量超过上限我设的 50 条就按updatedAt从旧到新淘汰。这个上限主要为了防存储膨胀。为什么不直接用 TTL因为 TTL 只考虑时间不考虑总量。万一某段时间用户高频创建草稿但每一条都顺手改一下续命存储就失控了。LRU 兜住这个量级TTL 管住时间维度两者并不冲突。4.3 清理时机惰性清理优先主动清理兜底过期草稿的清理我的建议是不要单独跑定时器。RN 应用经常处于挂起状态定时器的可靠性本身就打折扣而且没必要。我采用的是“惰性清理 启动时兜底”惰性清理每次读取某个 entryKey 的草稿时先判断expiresAt如果已过期则删除并返回空结果。启动兜底App 启动后在非关键路径上遍历所有草稿 key执行过期删除和 LRU 淘汰。启动兜底这个操作不要阻塞启动流程丢到InteractionManager.runAfterInteractions后面跑或者放个 setTimeout 延后执行。function isExpired(draft: StoredDraft): boolean { return draft.expiresAt Date.now(); } // 惰性清理恢复草稿时调用 async function restoreDraftFromStorage(entryKey: string) { const key ${STORAGE_PREFIX}${entryKey}; const raw await AsyncStorage.getItem(key); if (!raw) return null; const draft JSON.parse(raw); if (isExpired(draft)) { await AsyncStorage.removeItem(key); return null; } return draft; }这里有一个值得注意的边界用户正在编辑的草稿即使超过 7 天也不能删。所以每次保存时都要刷新expiresAt和updatedAt这两者是绑定在一起的。如果一个草稿存了一周数据但用户这周每天都来改一次它永远不过期这就避免了“写得好好的突然没了”的悲剧。5. 版本迁移老草稿碰到新代码不能崩也不能丢5.1 草稿字段每天都在变的现实App 迭代过程中草稿的字段结构一定会变。最常见的几种变化字段改名body改成了content新增字段新增tags、coverImage字段类型变化price从字符串改成数字字段语义变化draftType从normal | topic扩展为normal | topic | poll如果你不做版本管理代码升级后老草稿一旦被读到轻则字段显示为 undefined重则 JSON.parse 能过但访问深层属性直接抛异常严重的情况下会让编辑器整个白屏。而且白屏恰恰是最难排查的用户是老草稿、新代码偶现你本地复现不了。5.2 按版本链迁移一个 migrations 表搞定核心思路是给每条草稿持久化一个version字段然后维护一张从旧版本逐级升级到新版本的迁移表。迁移不是一个“一次性的开关”而是一条链1 → 2 → 3每一步都是可独立验证的小函数。// 当前草稿的最新版本号 export const CURRENT_DRAFT_VERSION 3; // 迁移表key 表示“从 version-1 迁移到 version” const migrations: Recordnumber, (draft: any) any { 2: (draft) { // v1 - v2body 改名为 content新增空 tags const { body, ...rest } draft; return { ...rest, content: body ?? , tags: [] }; }, 3: (draft) { // v2 - v3补充摘要字段 return { ...draft, summary: typeof draft.content string ? draft.content.slice(0, 50) : , }; }, }; export function migrateDraft(raw: any): StoredDraft { let current raw; let version current.version ?? 1; // 逐级升级 while (version CURRENT_DRAFT_VERSION) { const step migrations[version 1]; if (!step) { // 迁移链断了按当前可用版本继续 break; } current step(current); version 1; } return { ...current, version }; }注意version缺省时按1处理。这个设计保证了即使你早期版本没有写 version 字段也能安全地进入迁移链。每一条老草稿被读到时都会走到migrateDraft升级到最新版再返回给业务层。5.3 迁移失败与数据降级迁移代码也是代码它也会出 bug。尤其当草稿数据本身已经被污染时比如某个字段写入了非法值迁移函数很可能抛异常。这时候最忌讳的是让整个 App 崩掉或者让用户陷入“无限白屏”。我处理的办法是给迁移包一层 try-catch失败时做三件事把原始草稿备份到一个本地日志 key 里防止数据彻底丢失也方便排查。删除当前草稿 key让它不阻塞下次进入页面。上报一份日志如果接了监控 SDK。export async function safeMigrateDraft(entryKey: string, raw: any) { try { const migrated migrateDraft(raw); // 迁移成功把新版写回存储 await AsyncStorage.setItem(${STORAGE_PREFIX}${entryKey}, JSON.stringify(migrated)); return migrated; } catch (e) { // 备份原始数据 await AsyncStorage.setItem(${STORAGE_PREFIX}corrupted:${entryKey}:${Date.now()}, raw); await AsyncStorage.removeItem(${STORAGE_PREFIX}${entryKey}); return null; } }这里的关键决策是“宁可不恢复也不能让老数据把新功能带崩”。用户损失一条草稿是小事App 整体白屏才是事故。至少备份的存在给了以后通过运营工具修复数据的可能性。6. 可落地的 DraftManager 实现6.1 类型定义与存储结构把所有逻辑收敛成一个DraftManager类比我最早散落在各页面里的工具函数强很多。核心存储结构做一次完整的定义// 草稿对象固定字段 业务字段 interface StoredDraft { version: number; // 草稿结构版本号 ownerId: string; // 归属用户 entryKey: string; // 页面维度 key createdAt: number; // 首次创建时间 updatedAt: number; // 最近修改时间 expiresAt: number; // 过期时间 revision: number; // 递增版本号用于解决竞态 // 以下为业务字段随版本迁移而变化 title?: string; content?: string; tags?: string[]; summary?: string; [key: string]: unknown; // 预留扩展 }每次保存时这些元信息必须同步更新。很多草稿系统做到后面丢了数据八成是只更新了业务字段忘记了更新updatedAt和revision。6.2 保存链路的节流与去抖高频保存是异步存储方案的大敌。我的做法是500ms 去抖用户连续输入时不立即写盘等输入停顿后再写。这样既保证了不丢数据最坏情况丢最后 500ms 的输入又避免了每敲一个字就触发一次 setItem。private writeTimers: Mapstring, NodeJS.Timeout new Map(); private revisions: Mapstring, number new Map(); save(entryKey: string, payload: PartialStoredDraft, ownerId: string) { const timer this.writeTimers.get(entryKey); if (timer) clearTimeout(timer); const now Date.now(); const revision (this.revisions.get(entryKey) ?? 0) 1; this.revisions.set(entryKey, revision); const draft: StoredDraft { version: CURRENT_DRAFT_VERSION, ownerId, entryKey, createdAt: this.createdAtMap.get(entryKey) ?? now, updatedAt: now, expiresAt: now DRAFT_TTL, revision, ...payload, }; const newTimer setTimeout(() { AsyncStorage.setItem(${STORAGE_PREFIX}${entryKey}, JSON.stringify(draft)) .catch((e) reportError(draft_save_failed, e)); }, 500); this.writeTimers.set(entryKey, newTimer); }注意我在写盘后才把createdAt存进内存缓存避免每次输入都去反复读存储。createdAt的持久化在首次创建时写一次即可后续每次保存都用内存里的旧值。6.3 恢复、迁移、过期的串联逻辑保存是一环恢复是另一环。恢复链路要串起校验、迁移、过期三层async restore(entryKey: string, ownerId: string): PromiseStoredDraft | null { const key ${STORAGE_PREFIX}${entryKey}; const raw await AsyncStorage.getItem(key); if (!raw) return null; let parsed: any; try { parsed JSON.parse(raw); } catch { // JSON 损坏删掉防止后续反复报错 await AsyncStorage.removeItem(key); return null; } // 归属校验 if (parsed.ownerId ! ownerId) return null; // 过期校验惰性清理 if (isExpired(parsed)) { await AsyncStorage.removeItem(key); return null; } // 版本迁移 const migrated await safeMigrateDraft(entryKey, parsed); if (!migrated) return null; return migrated; }调用方拿到结果后再去填充表单。每一层都是独立函数单独可测。恢复失败时返回null页面走“新建草稿”的默认逻辑不会白屏。7. 实测过程中踩过的坑与最终表现7.1 启动白屏排查从“恢复阻塞渲染”到“壳先渲染”我最早那版草稿模块是直接在页面组件初始化里同步调用的用 MMKV 之前还好换了 AsyncStorage 之后问题突然暴露编辑页启动明显变慢Android 上甚至出现白屏。排查时我先怀疑是存储库的问题但把恢复逻辑去掉之后页面秒开。于是我把流程改成了“壳先渲染、异步恢复”详见 3.1。这一改之后不仅启动白屏解决了连带着“用户看到空页面但又快速有内容”的体验也变好了。如果你也遇到启动白屏建议先看启动链路上有没有不必要的同步 I/O 或长耗时 JS 计算不要一上来就怪 RN 本身。7.2 快速输入下的丢失revision 版本号救场有段时间我收到反馈用户快速输入后退出再进来发现内容回退了一段。查了很久发现是 AsyncStorage 的写入时序问题。用户在 500ms 内连续输了两段话第一次去抖定时器已经触发写入第二次输入的保存又启动了一个定时器。如果第二次写入因为某种原因先于第一次完成在低端机异步回调乱序是可能的那磁盘上就是旧数据覆盖新数据。解决思路就是revision递增版本号。写入前比较本次 revision 是否大于上一次已写入的 revision不满足则丢弃从源头保证只写最新async function writeWithRevisionCheck(entryKey: string, draft: StoredDraft) { const key ${STORAGE_PREFIX}${entryKey}; const raw await AsyncStorage.getItem(key); if (raw) { const existing JSON.parse(raw); if (existing.revision draft.revision) { return; // 旧数据不覆盖 } } await AsyncStorage.setItem(key, JSON.stringify(draft)); }这是把“最后写入者胜”改成了“最大版本号胜”。实测之后数据回退的问题再没出现过。7.3 一些值得注意的细节最后分享几个我测了很久才意识到的小细节每一个都值得你注意不要存大图。草稿里如果要存图片建议只存本地文件路径或远端 URL不要 base64 塞进 AsyncStorage。我见过同事把一张 3MB 的 base64 存进去直接导致整库读写瘫痪。清理时避开正在编辑的 key。启动兜底清理时最好跳过那些当前正在被编辑的 entryKey否则会出现用户写一半后台把它的草稿删了的灵异事件。开发模式下不要用真实数据测迁移。你把CURRENT_DRAFT_VERSION改到新版本后本地所有旧草稿都会被迁移函数改写。用测试账号跑完迁移确认字段都正常再上真机。草稿列表页的“继续编辑”入口。如果你的产品需要草稿列表页恢复逻辑尽量复用restore方法不要在列表页另起一套读取逻辑否则很容易出现“列表能看到、编辑页恢复不了”的不一致。这套草稿系统我上线跑了三个版本灰度期间的崩溃率没有因为草稿模块增加用户的“内容丢失”类反馈也基本消失了。回过头看草稿功能难的不是存而是存完之后那一整套生命周期管理。你如果正在设计类似模块建议先从恢复链路和过期策略做起版本迁移可以等第一次字段变更时再加——但你得有这个心理准备它一定会来。