
做React Native本地草稿功能时我踩过最深的坑不是没保存而是“明明存了却恢复不出来”。本地草稿这个需求看起来就是“把用户输入的内容缓存到本地下次打开再填回去”可真要落地你绕不开三件事恢复、过期、版本迁移。恢复解决“怎么把草稿安全找回来”过期解决“草稿堆积后怎么清理”版本迁移解决“App升级后旧草稿还能不能用”。这篇文章就是用RN里的真实实践把这套本地草稿设计拆开讲清楚适合正在做或准备做编辑类App、需要在RN中落地读写草稿机制的同学参考。1. 整体设计思路把“草稿”当成正式数据来设计1.1 为什么本地草稿功能这么容易翻车很多团队一开始做草稿就是把用户输入塞进AsyncStoragekey用draft_${userId}value直接JSON.stringify({title, body})。看起来简单上线后问题全冒出来App升级一次存进去的草稿结构跟新版不兼容用户在后台放了两天草稿还在但打开时已经过期更离谱的是用户改完一段文字切个页面回来内容被旧的本地缓存覆盖了。这些问题不是偶然出现的而是草稿方案的“容量”不够。草稿本质上是正式数据的一个副本它的生命周期、数据结构和写入时机都应该和正式数据一样被认真设计。如果你脑子里只有“存一下、取一下”后面每一个小改动都会变成事故现场。所以我现在的建议是即使第一版只有3个字段也按“独立模块”来做。建一个draft目录里面划分存储层、迁移层、清理层别把读写散落在各个页面里。1.2 先把需求拆清楚恢复、过期、版本迁移各管什么标题里这三个词看着抽象放到真实场景里就是三件事恢复用户编辑到一半App被系统杀掉、用户误触返回、切到后台被回收内存重新进入时要把未提交的内容找回来。这里又分成会话内恢复和冷启动恢复会话内恢复是页面重新挂载时读一次本地草稿冷启动恢复是App整体重启后把位置恢复到上次编辑的草稿。过期草稿不能无限堆积。用户一天产生几十个草稿每个都保留本地存储会膨胀列表页面也会越来越难用。过期策略决定了“多久没动过的草稿算废稿”以及“废稿是直接删还是标记为可恢复”。版本迁移App发版后草稿里的数据结构和旧版本不一样。比如v1.0的content是一个字符串v1.1改成结构化blocks数组如果不做迁移老用户升级后一点开草稿就崩溃。这三个需求不是各自独立的。恢复时要去判断是否过期迁移必须在恢复的读取链路里执行过期清理又要把未迁移的旧草稿一并考虑。所以设计时一定要放在同一套数据模型里处理。1.3 存储选型对比KV还是数据库React Native本地存储方案不少但选型真没那么复杂我先列一个对比表方案数据量读写性能同步API适合场景AsyncStorage中低一般否简单KV、键值量少MMKV中高高是高频读写、冷启动恢复SQLite大高查询强否复杂查询、关系数据redux-persist中低一般否配合Redux状态持久化如果你是个人项目或MVP用AsyncStorage就够了API简单社区成熟。但我更推荐在草稿场景用MMKV原因只有一个它提供了同步读取能力。草稿恢复通常在界面初始化那一帧就要执行如果读取是异步的页面上会出现“先空白、后填充”的闪烁。MMKV读一个几十KB的草稿同步拿回来界面直接渲染体验会稳很多。配合useMemo或useRef缓存几乎不会阻塞JS线程。SQLite则适合草稿数量大、需要按条件查询的场景比如“列出所有一周内编辑过的草稿”。但说实话绝大多数App的草稿量都不会大到必须上数据库用KV加一个索引key就够了。这个后面详细说。2. 数据模型与过期策略设计2.1 草稿模型字段怎么定义我把草稿模型设计成一套通用结构不同业务页面可以复用// draft.types.ts export type DraftStatus active | expired | restored | corrupted; export interface DraftDataT unknown { draftId: string; // 草稿唯一标识建议由业务方传入 ownerId: string; // 所属用户/业务维度多账号切换时可区分 content: T; // 业务内容结构随版本变化 version: number; // 业务内容版本用于并发冲突判断 schemaVersion: number; // 草稿数据结构版本用于迁移 status: DraftStatus; createdAt: number; // 创建时间单位毫秒 updatedAt: number; // 最后更新时间单位毫秒 expireAt: number | null; // 绝对过期时间null表示使用默认策略 }每个字段都有存在的理由。version和schemaVersion容易混淆我解释一下version是同一份草稿的内容修改次数每次自动保存1用来判断“哪个副本更新”schemaVersion是草稿整体结构版本业务内容从字符串变成数组、或者给数组元素加字段时它要升级。二者各管各的缺一不可。status也别省。很多实现只在有草稿和没草稿之间做判断结果无法表达“已过期但不删除、用户还能手动恢复”的中间态。草稿本质上是不重要但不可丢失的数据尽量设计成软状态。2.2 自动保存与恢复的触发时机恢复能不能成功关键在有没有“及时写”。很多人只在输入框onChangeText里保存一旦用户在防抖生效前退出草稿就丢了。我总结了一套比较稳妥的触发时机输入变化后防抖保存onChangeText触发后wait 800ms写入一次。不要每次按键都同步写MMKV虽然快高频写也会占用主线程。组件卸载时强制保存在useEffect的清理函数里把最新内容立即写一次。App进入后台时强制保存监听AppState.change状态切到background或inactive时flush当前草稿。这一步最容易漏因为系统杀进程时不会给JS组件卸载的机会但AppState是有机会拿到的。冷启动恢复时主动读App启动后根据业务需要读取“最近编辑的草稿”或指定draftId的草稿。对应代码// useDraftAutosave.ts useEffect(() { if (!draftId) return; const timer setTimeout(() { saveCurrentDraft(); }, 800); return () { clearTimeout(timer); saveCurrentDraft(); // 卸载时强制写 }; }, [content, draftId]); useEffect(() { const sub AppState.addEventListener(change, (state) { if (state background || state inactive) { saveCurrentDraft(); } }); return () sub.remove(); }, []);一个小坑组件卸载时的saveCurrentDraft如果依赖useState的值可能拿到闭包里的旧值。我建议把最新内容同时存在useRef里保存逻辑只从ref.current取值这样无论回调什么时候执行拿到的都是最新内容。2.3 过期策略TTL、懒清理与后台清理怎么做草稿的过期不能一拍脑袋定个24小时得先想清楚产品定位。输入框里的搜索词保留一天就够编辑器里的长文稿7天合理有明确截止日期任务的草稿建议用绝对时间expireAt。我常用两种过期模式固定过期expireAt直接设一个未来的时间点比如用户设置里勾选了“保留草稿到本周日”。滑动过期每编辑一次过期时间顺延。判断时用updatedAt maxAge now只要用户在持续编辑草稿就不会变废稿。判断逻辑放在读取链路里叫懒清理export function readActiveDraftT(draftId: string, now Date.now()) { const draft readDraftT(draftId); if (!draft) return null; const expired isDraftExpired(draft, now); if (expired) { draft.status expired; saveDraft(draft); return { draft, isExpired: true }; } return { draft, isExpired: false }; }不要直接delete。用户看到“草稿已过期”还能决定要不要恢复比静默消失好得多。如果产品上允许恢复过期草稿点恢复时重新激活即可。后台批量清理也要做但不要每次启动都全量扫描。你可以用AppState监听在App回到前台时执行一次清理同时加一个节流开关比如距离上次清理不足1小时就跳过。清理时只删除status expired且expireAt now的草稿别碰active状态的尤其不要碰最近几分钟刚更新过的数据避免清理线程和编辑线程打架。3. 版本迁移的完整落地3.1 为什么草稿也需要版本号我见过最典型的案例v1.0存草稿时content是stringv1.1编辑器改成了富文本content变成Array{type, text}。老草稿如果直接渲染页面就崩了。这种问题在发版前很难发现因为测试同学通常不会拿旧版本的存档数据来测新版本。所以要给草稿一个schemaVersion字段并且把“读取草稿”和“版本迁移”绑在一起。每次读到旧版本就按版本号逐级升级升级完再存回去。这样用户无感打开草稿看到的还是原来内容只是底层结构已经变成新版。版本迁移最关键的一点是“幂等”。同一个草稿如果被迁移两次结果必须一致。比如给每个block补id如果block已经有id就跳过不能造成重复。3.2 迁移框架的代码实现我建议把迁移定义成一个数组下标就是当前的schemaVersion。读取草稿时如果draft.schemaVersion CURRENT_VERSION按顺序执行所有未执行的迁移// draftMigration.ts const CURRENT_DRAFT_SCHEMA_VERSION 2; type MigrationFn (draft: DraftDataany) DraftDataany | PromiseDraftDataany; const migrations: MigrationFn[] [ // index 0: schemaVersion 0 - 1 (draft) { if (typeof draft.content string) { return { ...draft, content: [{ type: text, text: draft.content }], }; } return draft; }, // index 1: schemaVersion 1 - 2 (draft) { return { ...draft, content: draft.content.map((block: any, index: number) ({ id: block.id ?? block-${index}, ...block, })), }; }, ]; export async function migrateDraftT(draft: DraftDataT): PromiseDraftDataT { let current draft; const start Math.max(0, draft.schemaVersion ?? 0); for (let i start; i migrations.length; i) { current await migrations[i](current); current.schemaVersion i 1; } await saveDraft(current); return current; }这里我统一在迁移完成后设置schemaVersion函数体里不重复赋值避免某个迁移函数忘记更新版本号。注意start使用Math.max(0, ...)因为老数据可能根本没有schemaVersion字段读出来是undefined要按0处理。调用时机放在恢复链路的最前面export async function loadDraftWithMigrationT(draftId: string) { const draft readDraftT(draftId); if (!draft) return null; const migrated await migrateDraft(draft); const normalized readActiveDraftT(migrated.draftId); return normalized; }有一点必须提醒迁移过程里不要因为“数据库里key很多”就偷懒不做备份。尤其是大版本升级你无法预料线上用户手里有多少种奇形怪状的老数据。3.3 迁移失败时的兜底与恢复代码写得再小心也挡不住脏数据。草稿可能在写入一半时App被杀导致JSON不完整也可能被某个旧版本写入了意外结构。所以迁移函数一定要有try/catch且catch里不能简单删key。我采用的兜底方案是迁移前把原始raw字符串复制到一个备份key然后尝试迁移如果抛出异常保留原始数据并给草稿标记status corrupted前端可以弹一个“草稿内容已损坏是否恢复原始数据”的按钮。export async function migrateDraftWithBackupT(draftId: string): PromiseDraftDataT { const key getDraftKey(draftId); const original storage.getString(key); if (!original) throw new Error(Draft not found); let draft: DraftDataT; try { draft JSON.parse(original); return await migrateDraft(draft); } catch (err) { storage.set(${key}:backup, original); const corrupted: DraftDataT { draftId, ownerId: draft?.ownerId ?? , content: null as any, version: 0, schemaVersion: CURRENT_DRAFT_SCHEMA_VERSION, status: corrupted, createdAt: Date.now(), updatedAt: Date.now(), expireAt: null, }; saveDraft(corrupted); throw err; } }“备份key”不需要清理得特别频繁但建议在草稿列表页里展示“已损坏草稿修复”入口时一并把超过30天的备份key清掉避免存储膨胀。4. 实操中的踩坑记录与排查方案4.1 草稿被旧数据覆盖的并发写入问题这个坑最隐蔽也最伤人。用户在A页面编辑草稿切到B页面编辑同一份草稿A页面比B页面早保存了几秒钟结果B页面的保存被A页面的旧内容覆盖了。原因很简单草稿保存时没有做版本判断。我的方案是保存前先读一次当前存储里的version只有传入的版本号不小于存储里的版本号时才写。按这个逻辑上面场景里B页面保存后version5A页面再保存时自己还是version4直接被丢弃export function saveDraftIfVersionUpToDate(next: DraftData) { const current readDraft(next.draftId); if (current next.version current.version) { // 当前存储里已有更新的副本丢弃这次写入 return false; } saveDraft({ ...next, version: Math.max(current?.version ?? 0, next.version), }); return true; }注意这里的version是业务内容版本不是schemaVersion。自动保存时每次内容变化都要1如果一次会话里内容没变化也不要强制递增避免无意义写盘。如果你是AsyncStorage用户还要注意多个setItem并发时的执行顺序未必可靠。经验做法是封装一个简单的Promise队列把每次“读-改-写”串行化。这个坑MMKV因为是同步写天然规避了一部分但版本检查仍然要做。4.2 设备时间不准导致误过期草稿过期判断如果完全依赖Date.now()用户只要把手机时间往后调一年所有草稿当场全过期。这在我们一次灰度测试里真实发生过一个测试机时间错乱一堆草稿列表直接变成空。我的处理办法是尽量使用服务器时间戳至少不要只依赖本机时间。如果项目没有现成的服务器时间接口可以折中用“本机时间时间偏移量”的方式App启动时或网络请求返回到一个可信时间后计算offset serverTime - localTime后续所有过期判断都用localTime offset。这样即便用户修改了本机时间只要下次联网拉回真实时间偏移量会自动修正。另外草稿写入的createdAt、updatedAt也要用同一套时间体系不然不同页面之间比较时间戳会错乱。4.3 数据损坏、JSON解析失败与恢复策略RN本地存储出现JSON解析失败的概率不高但一旦出现整条读取链路会崩溃。尤其是用JSON.parse直接解析AsyncStorage.getItem返回的字符串时任何一点脏数据都会让页面白屏。我在readDraft里加了专门的兜底export function readDraftT(draftId: string): DraftDataT | null { const raw storage.getString(getDraftKey(draftId)); if (!raw) return null; try { return JSON.parse(raw) as DraftDataT; } catch (e) { // 数据损坏备份原始串后返回 null不让上层感知异常 storage.set(${getDraftKey(draftId)}:corrupted, raw); return null; } }这样至少保证主流程不崩。至于损坏的原始串可以在“问题反馈”或“远程日志”里上报也可以提供一个隐藏入口让用户导出原始JSON给客服排查。4.4 常见问题速查与排查方案现象可能原因解决办法重启后草稿丢失写入只发生在内存变量里没有调用持久化存储检查保存逻辑是否真的触达存储层杀掉进程实测恢复出旧版内容防抖保存未触发页面就退出AppState切后台时强制flush组件卸载时再写一次设备时间改变后草稿全部过期过期判断依赖本机时间用服务器时间或本机时间偏移量新版读取旧草稿时崩溃content结构不兼容schemaVersionmigrations逐级迁移迁移前备份草稿迁移失败后被直接删除异常处理里执行了removeItemcatch里保留原始数据标记corrupted多个页面编辑同一草稿互相覆盖没有版本号比较保存前读取现有version不满足条件则丢弃冷启动恢复时白屏闪烁读取是异步的界面先渲染空数据换MMKV同步读取或在读完后显示占位loading我每次负责草稿模块都会在测试用例里加这样几条冷启动后立即杀进程再进编辑到一半强退录音间隙切后台把系统时间调乱后再打开草稿从老版本备份的本地数据直接装新包。这些场景比常规功能测试更能暴露草稿系统的脆弱点。4.5 最后的一些个人建议草稿系统虽然藏在App深处但它是用户安全感的一部分。用户可能不在乎你的详情页做得多么精致但一定在意自己写了一半的文章、填到一半的表单切回来还在不在。我个人的体会是草稿功能的验收标准不是“写了能存”而是“在各种异常退出下都不丢”。所以宁可多写几行兜底代码也别抱侥幸心理。如果后续要扩展这套模型也给你留好了口子草稿列表用draftIndex记录最近修改的id集合避免每次全量扫描多账号切换时用ownerId隔离草稿需要跨端恢复时把本地草稿同步到服务端再把updatedAt作为冲突依据。核心还是那三件事恢复要稳过期要合理迁移要幂等。做扎实了这个模块至少能让你在之后两三年里少睡几个安稳觉。