做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序列化后超过几十KB,App进入后台时就别频繁写了,让MMKV这类同步写更稳。
提示:如果项目里还同时用了其他存储层,尽量复用一个库,别为草稿单独引入一套,增加依赖和包体积不说,排查问题还要多查一层。
2. 草稿恢复的完整落地细节
2.1 草稿数据模型设计
草稿结构是整条链路的起点。字段不能只存内容,必须带上元信息。我用的模型长这样:
// 草稿实体类型定义 type DraftEntity = { key: string; // 草稿唯一标识,例如 'post:editor:main' content: string; // 实际编辑内容(JSON序列化后的字符串) title?: string; // 可选标题字段 updatedAt: number; // 最近保存时间戳,用于过期判断 expiresAt: number; // 过期时间戳,由过期策略计算 schemaVersion: number; // 草稿结构版本,用于迁移 extra?: Record<string, any>; // 预留扩展位,放媒体资源、草稿状态等 };注意几个关键点。content里存序列化后的字符串,不要存对象,因为对象在存储层和跨版本迁移时容易出现属性丢失问题。updatedAt和expiresAt拆开,前者是实际保存时间,后者是过期截止时间,两者拆开方便调整过期策略。schemaVersion是迁移的入口,任何关于草稿结构的改动都必须升这个版本号。
extra这个字段是我后来加的。早期版本没留扩展位,后面要加图片等附件时只能新建字段,又触发了迁移。预留一个对象,新增属性不用改schema版本,能省掉很多迁移成本。
2.2 自动保存策略:防抖、节流与时机
草稿恢复的前提是保存足够频繁。但RN里如果每次onChangeText都读写存储,JS线程会卡死,尤其输入法联想弹出的时候,性能会惨不忍睹。
我的做法是三层策略叠加:
第一层,内容变化时做防抖保存。用户输入停止600到800毫秒后再写一次。这样既覆盖了快速输入场景,又不至于每秒写十几次。
import { AppState } from 'react-native'; // 防抖保存器 class DraftSaver { private timer: ReturnType<typeof 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: Record<number, (draft: any) => any> = { // v1 -> v2:body 改名为 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): Promise<DraftEntity> { 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,还加了标题字段。于是写迁移函数1:
1: (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): Promise<DraftEntity | 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): Promise<void> { 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。把恢复、过期、迁移三件事理清楚,再配合可靠的保存时机和兜底机制,这个模块才能扛住线上各种复杂场景。希望对你有用。