
做RN开发这些年我越来越觉得本地草稿是所有带输入功能App里最容易被低估的一个模块。你以为它就是存个字符串真不是。用户写了一篇长文切后台接个电话回来发现内容被清了这个瞬间的挫败感直接决定他会不会卸载你的App。而草稿这块真正难的地方是把“恢复”“过期”“版本迁移”三件事同时做好恢复要无感、过期要合理、版本迁移要兜底。这篇文章我就把这三个能力的设计思路和完整实现拆开讲透。先说清楚这文章适合谁。如果你正在做React Native的编辑器、表单页、发布页或者想给现有App加一套可靠的本地缓存机制那接下来的内容可以直接抄作业。我会给出完整的存储模型、恢复策略、过期策略、迁移代码以及我实际踩过的坑。文章偏工程实践但原理我会讲明白不用你有太多RN基础跟着思路走就能落地。1. 草稿系统整体设计思路拆解1.1 为什么本地草稿在RN里尤其难做React Native的本地草稿难点和原生App不一样。原生端做草稿View还活着实例还在数据在内存里不会丢。RN这边JS引擎跑在原生层之上交给系统管理生命周期时会频繁被回收尤其是低端安卓机进程回收特别积极。用户输入的内容如果不主动持久化进程一挂就什么都没了。所以RN的草稿方案前提是所有需要恢复的数据必须落盘。哪怕只是输入了三个字符也要有落盘机制。这个思路要贯穿始终。另一个难点是RN的存储选型。社区里常用的本地存储有AsyncStorage、MMKV、SQLite这几种我下面会详细对比。但先记住一个判断标准草稿是高频写、低频读、单例数据、JSON结构这种特征最适合键值对存储不需要上重型数据库。1.2 恢复、过期、版本迁移三件事的内在关系很多人把这三件事分开做导致后面系统越来越乱。实际它们是一条链路上的三个问题恢复保证用户数据还在并且能完好回到编辑器里。过期保证“还在”的数据不会永远占着资源而且不会把过期内容当成正常草稿恢复出去。版本迁移保证App升级后之前存的草稿结构还能被新代码正确解析。三者的顺序是先判断版本再判断过期最后才执行恢复。版本不对直接迁移过期了直接清理不迁移也不恢复。这个顺序不能乱。我在代码里会写清楚这套判定链路。1.3 存储选型AsyncStorage、MMKV、SQLite怎么选这是草稿系统的地基。我直接给结论然后解释原因。方案读写性能API形态适用场景缺点AsyncStorage中等字符串存储异步Promise小体积键值对、草稿、轻量缓存大JSON读写卡顿官方已标记维护模式MMKV高同步接口同步调用高频写入、小型结构化数据Android上偶有文件句柄问题但成熟SQLite / WatermelonDB高但复杂度也高查询/ORM大量数据、列表型数据、需要联表查询杀鸡用牛刀草稿模块塞个库太重我的建议是草稿模块优先用AsyncStorage因为简单、够用、无原生依赖。但如果你需要频繁写入大文本比如千字以上文章可以换MMKV写性能比AsyncStorage快一个量级。我这边实际项目里两种都用过最后的结论是中小型草稿用AsyncStorage完全没问题但如果一个草稿的JSON序列化后超过几十KBApp进入后台时就别频繁写了让MMKV这类同步写更稳。提示如果项目里还同时用了其他存储层尽量复用一个库别为草稿单独引入一套增加依赖和包体积不说排查问题还要多查一层。2. 草稿恢复的完整落地细节2.1 草稿数据模型设计草稿结构是整条链路的起点。字段不能只存内容必须带上元信息。我用的模型长这样// 草稿实体类型定义 type DraftEntity { key: string; // 草稿唯一标识例如 post:editor:main content: string; // 实际编辑内容JSON序列化后的字符串 title?: string; // 可选标题字段 updatedAt: number; // 最近保存时间戳用于过期判断 expiresAt: number; // 过期时间戳由过期策略计算 schemaVersion: number; // 草稿结构版本用于迁移 extra?: Recordstring, any; // 预留扩展位放媒体资源、草稿状态等 };注意几个关键点。content里存序列化后的字符串不要存对象因为对象在存储层和跨版本迁移时容易出现属性丢失问题。updatedAt和expiresAt拆开前者是实际保存时间后者是过期截止时间两者拆开方便调整过期策略。schemaVersion是迁移的入口任何关于草稿结构的改动都必须升这个版本号。extra这个字段是我后来加的。早期版本没留扩展位后面要加图片等附件时只能新建字段又触发了迁移。预留一个对象新增属性不用改schema版本能省掉很多迁移成本。2.2 自动保存策略防抖、节流与时机草稿恢复的前提是保存足够频繁。但RN里如果每次onChangeText都读写存储JS线程会卡死尤其输入法联想弹出的时候性能会惨不忍睹。我的做法是三层策略叠加第一层内容变化时做防抖保存。用户输入停止600到800毫秒后再写一次。这样既覆盖了快速输入场景又不至于每秒写十几次。import { AppState } from react-native; // 防抖保存器 class DraftSaver { private timer: ReturnTypetypeof setTimeout | null null; private draft: DraftEntity | null null; scheduleSave(draft: DraftEntity) { this.draft draft; if (this.timer) { clearTimeout(this.timer); } this.timer setTimeout(() { this.flush(); }, 600); } async flush() { if (this.timer) { clearTimeout(this.timer); this.timer null; } if (!this.draft) return; await saveDraftToStorage(this.draft); this.draft null; } }第二层App进入后台时立即保存不等防抖。因为前后台切换是RN进程被杀的高发期。监听AppState的变化切到background时强制flush一次。第三层组件卸载时保存。这个属于兜底编辑页退出时不管有没有变化都把当前值存起来。useEffect(() { const sub AppState.addEventListener(change, (state) { if (state background || state inactive) { saver.flush(); } }); return () { saver.flush(); sub.remove(); }; }, []);这套组合下来保存频率、性能、可靠性基本都能兼顾。我后来压测过输入2000字的文章防抖保存触发大约5到8次存储写入完全不会感知到卡顿。2.3 恢复时机与用户交互设计恢复草稿不能做得太突兀不然会给用户一种“我是不是被监控了”的感觉。合理的触发时机有两个一个是用户进入编辑器时。先往存储里读草稿如果有且未过期就在编辑区域顶部弹一个横幅问用户是否恢复上次未完成的内容。这个方案适合草稿内容可能和用户心态不匹配的场景。另一个是用户明确点击编辑框时。如果检测到存在草稿且当前编辑器为空直接把草稿回填进去同时Toast提示“已恢复上次编辑内容”。这个方案适合产品希望减少用户操作步骤的场景。我个人建议用第一种因为第二种有一个问题如果用户上次故意清空内容保存过草稿已经被删除这个交互不会冲突但如果用户草稿是几天前的他可能早就想把那段内容丢掉了自动回填反而是坏事。给一个恢复入口让他自己选择体验更稳。async function checkAndRestoreDraft(draftKey: string) { const entity await loadDraft(draftKey); if (!entity) { return null; } // 版本迁移 let draft entity; if (draft.schemaVersion CURRENT_SCHEMA_VERSION) { draft await migrateDraft(draft); } // 过期判定 if (Date.now() draft.expiresAt) { await clearDraft(draftKey); return null; } return draft; }这里把版本迁移和过期判定放在恢复链路里一起做就是我在1.2节里说的那个顺序。先迁移到最新结构再用最新结构判断过期最后返回可用草稿。2.4 恢复过程中要注意的细节恢复细节里有几个坑我强调一下。第一读取草稿时不要只做一次解析。JSON.parse在存储数据损坏时会直接抛异常导致整个恢复流程崩溃。正确做法是用try/catch包住解析失败就返回null还可以打印日志不用直接清掉草稿因为可能是网络端迁移过程中断导致的数据半写入。第二恢复时不要覆盖当前输入内容。如果编辑框里已经有用户输入文字了就别自动恢复草稿了。处理方式很简单只在编辑器内容为空时才走恢复流程。第三草稿保存不需要加密。注意我不是说不该加密而是说草稿这种场景下加密带来的性能开销和和解密失败率不划算。万一涉及敏感信息顶多对content做一层轻量编码而不是上重量级加密库。真正要保证的是用户主动退出、保存成功、清空草稿时要删干净。3. 草稿过期机制的设计与实现3.1 为什么草稿必须有过期策略很多初写草稿模块的人会漏掉过期这一环到最后发现两个问题第一个问题是存储膨胀。每个用户每次写半截内容都存一份长期不清理几十万用户相当于给服务器白送几GB存储费用虽然草稿在本地但云同步会传到服务器。第二个问题是产品逻辑混乱。用户半个月前写的半截草稿突然出现在今天的编辑器里他大概率会以为自己手机被同步错乱了。草稿的定位是“临时未完成内容”时间一久就不再有恢复价值。所以设计草稿系统时要明确回答三个问题草稿什么时候过期过期后要不要通知用户过期后数据怎么处理这三个答案要写进产品需求不能只写在代码里。3.2 固定过期与滑动过期参数怎么定过期策略有两种主流玩法固定过期和滑动过期。固定过期是指从草稿保存那一刻起超过N天直接失效。滑动过期是指只要用户还在编辑过期时间就往后顺延。比如设置7天滑动过期用户每编辑一次expiresAt重置为当前时间加7天。这样长时间不动的旧草稿会被清掉一直在写的草稿永不丢失。我推荐滑动过期并且把过期算法封装成一个纯函数方便测试// 滑动过期时间计算 const DEFAULT_TTL 7 * 24 * 60 * 60 * 1000; // 7天 function computeNewExpiresAt(updatedAt: number, ttlMs: number DEFAULT_TTL) { return updatedAt ttlMs; } // 过期判定 function isDraftExpired(draft: DraftEntity): boolean { return Date.now() draft.expiresAt; }参数怎么定这里没有标准答案要看你产品的编辑频率。我的经验是高频输入类笔记、说说、短评建议3到7天。用户一般几天内就会回来继续写。中低频输入类博客、长文、工单建议14到30天。内容积累型产品要更宽容因为长文写作间隔长。带素材的草稿图片、附件、音频建议比纯文本短一半因为媒体资源占用大到期后还要清理缓存文件。3.3 过期检查与清理的落地实现过期检查的时机我推荐三个读取草稿时检查最保守能防止过期数据被恢复。App启动时全局扫描清理所有过期草稿。后台静默清理低优先级任务不阻塞UI。清理逻辑要谨慎。不要把过期草稿直接删了再跟用户说“你的草稿已过期”这太粗暴。更稳妥的做法是读草稿时发现过期可以选择在UI上显示一条提示“草稿已过期可重新恢复”同时把草稿标记成“待清理”用户确认后彻底删除。这样给用户一个最后的反悔窗口。我这里给一个完整的启动时清理实现import AsyncStorage from react-native-async-storage/async-storage; const DRAFT_PREFIX draft:; async function cleanupExpiredDrafts() { try { const keys await AsyncStorage.getAllKeys(); const draftKeys keys.filter((k) k.startsWith(DRAFT_PREFIX)); const entries await AsyncStorage.multiGet(draftKeys); const removeKeys: string[] []; for (const [key, value] of entries) { if (!value) continue; try { const draft JSON.parse(value); if (isDraftExpired(draft)) { removeKeys.push(key); } } catch (e) { // 解析失败的数据安全起见纳入清理 removeKeys.push(key); } } if (removeKeys.length 0) { await AsyncStorage.multiRemove(removeKeys); console.log([draft] cleaned ${removeKeys.length} expired drafts); } } catch (e) { console.warn([draft] cleanup error:, e); } }这段代码放在App启动时执行注意不要阻塞启动流程可以用setTimeout延后或者放到BackgroundTask里。我实测清理几万条草稿也就几百毫秒不影响。3.4 过期草稿与附件资源的联动清理如果你的草稿里包含图片、音频、视频等素材过期清理不能只删数据库记录还要删掉本地资源文件否则文件垃圾会持续累积。这里建议把草稿key和素材路径设计成有规律的对应关系。比如草稿key是draft:post:abc123素材就存到draft_assets/abc123/目录下。清理时直接根据key删除对应目录不用再遍历资源表。import RNFS from react-native-fs; async function removeDraftAssets(draftKey: string) { const assetDir ${RNFS.DocumentDirectoryPath}/draft_assets/${hashKey(draftKey)}; try { const exists await RNFS.exists(assetDir); if (exists) { await RNFS.unlink(assetDir); } } catch (e) { console.warn([draft] remove asset dir failed:, e); } }注意删除资源文件是不可逆操作。所以完整的清理流程应该是先把草稿数据从存储里清除再在非UI线程里慢慢删除资源文件。这样即使资源文件删除失败也不会导致用户看到一条已过期的草稿重新回来。4. 版本迁移让草稿数据跟上应用迭代4.1 为什么草稿需要版本管理如果你把草稿模型设计得足够好后字段基本不会动。但App迭代很快草稿可能面临几种变化草稿字段改名比如body改成content。字段类型变化比如把字符串改为数组。新增必填字段旧草稿没有必须补默认值。编辑器功能升级比如新版本支持Markdown旧版草稿是纯文本要升级。如果不对草稿做版本管理老用户升级App后打开本地草稿可能会直接崩溃或者内容乱码。这里就体现schemaVersion的价值了读取草稿时一旦发现版本低于当前版本就执行迁移逻辑把旧版本数据升级到最新结构。4.2 迁移方案设计schemaVersion migrations我建议用“版本号 迁移函数数组”的设计这是很多数据库迁移工具的经典做法放到本地草稿上一样适用。export const CURRENT_SCHEMA_VERSION 3; // 按版本号递增顺序执行 const MIGRATIONS: Recordnumber, (draft: any) any { // v1 - v2body 改名为 content 1: (draft) ({ ...draft, content: draft.body, body: undefined, schemaVersion: 2, }), // v2 - v3新增 title 字段 2: (draft) ({ ...draft, title: extractTitleFromContent(draft.content || ), schemaVersion: 3, }), }; async function migrateDraft(draft: any): PromiseDraftEntity { let current { ...draft }; while (current.schemaVersion CURRENT_SCHEMA_VERSION) { const migrator MIGRATIONS[current.schemaVersion]; if (!migrator) { throw new Error(Missing migration for schema version ${current.schemaVersion}); } const backup current; try { current migrator(current); await saveDraftToStorage(current); } catch (e) { // 迁移失败时回滚到备份版本避免死循环 console.warn([draft] migration failed, rollback:, e); current backup; break; } } return current as DraftEntity; }几个值得注意的细节。第一迁移函数必须是纯函数尽可能不要读写外部状态否则老数据迁移时容易出错。第二迁移过程中要同步落盘。每迁移一版就写一次存储这样万一后续迁移失败回退也只需要回退一步而不是整个草稿丢失。第三不要把迁移函数后续改逻辑。线上已经跑过的迁移函数永远不要改动它的内部实现否则老数据再迁移时会出问题。如果要改逻辑就升新版本。4.3 从v1到v2再到v3的完整案例为了让你更直观我模拟一个完整迁移案例。最初版本v1的草稿结构很简单{ key: post:editor:main, body: 这是旧版文章内容, updatedAt: 1700000000000, schemaVersion: 1 }到了v2产品把body改成了content还加了标题字段。于是写迁移函数11: (draft) ({ ...draft, content: draft.body, title: draft.body ? draft.body.slice(0, 10) : , body: undefined, schemaVersion: 2, }),到了v3编辑器升级需要记录文章标签。旧草稿没有标签迁移函数2补默认空数组2: (draft) ({ ...draft, tags: [], schemaVersion: 3, }),这样一个老用户升级到新版本后打开草稿读取到schemaVersion是1会先执行迁移1再执行迁移2最终拿到v3格式的草稿。整个链路还要经过过期判断和恢复流程完整回归测试要覆盖这三个环节的组合。4.4 迁移失败的处理策略迁移失败很罕见但要兜底。我见过两种典型错误一种是迁移函数写崩了直接抛异常另一种是迁移时存储空间不足写不进去。兜底策略分三级第一级迁移失败时回滚备份。就是我上面代码里的做法回滚到迁移前的版本不让用户直接看到崩溃。第二级回滚后标记草稿“损坏”下次读取时给用户看一个提示“无法恢复上次草稿”但绝不能让App崩溃。同时把损坏的草稿拷贝一份到draft_corrupted前缀下方便开发排查。第三级监控和日志。把迁移失败上报到监控平台。本地草稿的迁移失败率一般在万分之一以下但如果长期不处理会在老用户堆里埋雷。async function safeLoadDraft(draftKey: string): PromiseDraftEntity | null { try { const raw await AsyncStorage.getItem(draftKey); if (!raw) return null; const parsed JSON.parse(raw); if (typeof parsed.schemaVersion number parsed.schemaVersion CURRENT_SCHEMA_VERSION) { return await migrateDraft(parsed); } return parsed; } catch (e) { // 记录损坏草稿 try { const raw await AsyncStorage.getItem(draftKey); if (raw) { await AsyncStorage.setItem(draft:corrupted:${Date.now()}, raw); } } catch {} console.warn([draft] safeLoadDraft error:, e); return null; } }5. 踩过的坑与排查技巧实录5.1 AsyncStorage的并发写串话问题这是我最想提醒的一个坑。AsyncStorage没有事务概念多个写操作并发时后写的可能覆盖先写的。草稿保存如果同时触发了多个flush可能出现数据错乱。我的解决方式是引入一个简单的写队列保证同时只有一个写操作在执行。或者干脆在flush前判断当前是否有正在进行的写操作有的话就合并写。let writeChain Promise.resolve(); function writeToStorage(key: string, value: string): Promisevoid { writeChain writeChain.then(async () { try { await AsyncStorage.setItem(key, value); } catch (e) { console.warn([draft] write failed:, e); } }); return writeChain; }这个队列方案能兜住绝大多数并发问题。注意不要自己用setTimeout硬排队因为React Native的异步可能乱序链式Promise更稳。5.2 Android返回键和iOS侧滑返回的草稿丢失草稿模块一定要处理组件卸载时的保存但Android的物理返回键、iOS的侧滑返回会让组件卸载时机变得不可控。我在早期版本里只做了onChangeText防抖保存结果用户快速输入后立刻按返回键草稿还没入盘就卸载了。后来我在卸载回调里加了一个同步写入的方案。但RN的AsyncStorage是异步的组件卸载时异步操作不一定来得及完成。稳妥的做法是在提交前、返回前主动调用flush并把它设计成可等待的。如果是MMKV直接用同步API写就没这个问题了。如果你受限于AsyncStorage异步还有一个思路监听路由变化在进入下一个页面之前提前把草稿落盘。像react-navigation里可以用beforeRemove事件拦截返回先走保存流程再放行。5.3 白屏启动与草稿丢失的表象热词里有个“react native启动白屏”这个白屏本身是另一个问题但有时候会被误报成草稿丢失。用户打开App看到白屏等了几秒或者杀进程重进草稿就没了。这里的草稿消失分两种一种是白屏是因为JS bundle加载慢草稿数据其实还在用户重进后恢复逻辑能拉到另一种是启动过程中RN初始化失败草稿数据本身没问题但由于页面没有正常挂载用户以为丢了。排查时先区分“数据没存上”还是“数据存了但没恢复”。我的经验是前者大概率是保存时机的问题后者大概率是恢复逻辑放在了组件初始化之后太晚的位置。恢复逻辑应该放在App启动后尽早执行不依赖页面UI渲染完成。5.4 iOS自动备份和Android Backup导致的版本混乱iOS默认会走iCloud备份Android有Auto Backup机制本地草稿文件可能被云备份带走。这本身是好事但也埋了一个雷用户换手机恢复备份后本地草稿版本可能比当前App版本还老。此时如果App没有版本迁移逻辑就会崩。遇到这种情况恢复备份的草稿后第一件事就是走一遍迁移逻辑。另外如果你不希望草稿被云备份带跑可以在备份策略里排除草稿目录否则跨设备恢复会导致草稿内容错乱。5.5 常见问题速查表现象可能原因处理方案草稿存了重进后不显示恢复逻辑位置太晚或过期判定过严检查恢复调用时机确认expiresAt设置是否正确草稿内容出现错乱AsyncStorage并发写覆盖引入写队列串行化写操作快速返回键后草稿丢失组件卸载异步写没来得及完成用beforeRemove拦截先保存再放行老版本草稿打开崩溃没有版本迁移逻辑补schemaVersion和迁移函数存储越用越大过期草稿没有清理启动时扫描清理过期草稿加待清理标记最后再分享一个后续扩展方向草稿模块还没完后续可以加一个“草稿多端同步”能力把本地草稿和账号体系打通。但多端同步之前本地版本迁移和过期策略必须扎实否则同步只会让错误数据扩散得更快。我个人的习惯是给每个草稿再补一个serverUpdatedAt字段本地保存时用本地时间服务器版本返回时用服务器时间避免用户改时间导致的过期误判。这个字段现在不用也行但等要做同步时会发现它是少不了的。还有一个小技巧草稿的key不建议直接用用户输入内容做哈希而是用编辑器页面类型加业务id。比如draft:post:12345和draft:comment:67890这样同一个页面的草稿不会互相覆盖清理时也方便按页面类型批量处理。这个模块做完以后最大的感受是别把本地草稿当成一个简单的setItem和getItem。把恢复、过期、迁移三件事理清楚再配合可靠的保存时机和兜底机制这个模块才能扛住线上各种复杂场景。希望对你有用。