
很多做内容社区类App的团队都遇到过这种反馈用户写了一篇长文或者填了一长串表单切到后台回个微信再回来内容全没了更糟的是App升级之后连历史草稿都打不开了。第一个问题靠“重新存”能解决第二个问题就牵扯到草稿数据的结构兼容也就是版本迁移。这篇文章我想复盘一下在React Native里做本地草稿的完整方案重点拆解三件事恢复、过期、版本迁移。适合正在做内容型App、想把草稿功能做成通用模块的React Native开发者参考涉及的技术点包括存储引擎选型、序列化设计、定时清理和幂等的迁移框架下面全部按可落地的代码级别来讲。1. 草稿功能的设计思路与整体架构1.1 草稿的三个核心问题恢复、过期、迁移草稿功能很多人第一反应是“用AsyncStorage存一下进页面读一下”但这只是最粗的一层。真正把草稿做成一个可靠功能需要同时回答三个问题第一个是恢复。用户写了半天的内容App被杀掉、崩溃、或者用户主动切换了页面又退回来能不能完整无损地出现在原来的表单里这要求写入频率足够高、读写链路足够稳以及恢复时机足够合理。第二个是过期。草稿本质上是一种临时数据不是永久数据。如果用户三个月前写了一篇只有标题的草稿再点开时这个草稿对App来说可能已经没有意义了占着存储空间不说还可能因为结构太老导致页面崩溃。所以必须有明确的过期策略什么情况下保留、什么情况下清理、什么情况下提醒用户。第三个是版本迁移。这是最容易踩坑的部分。App发了2.0版本文章的草稿里新增了图片字段或者把原来的纯文本content改成了富文本结构那用户手机里那几百条老草稿怎么办不处理就会出现两种结果要么老草稿直接打不开要么打开后结构错乱。版本迁移就是给草稿数据的“结构升级”修一条安全的通道。这三个问题不是彼此独立的。过期策略要依赖时间字段而时间字段从第一天设计就要埋进去版本迁移要作用于恢复流程每次读取时都要检查当前版本和目标版本是否一致。所以设计草稿模块第一件事不是写存储代码而是把这三条主线在脑子里先串起来。1.2 存储引擎选型AsyncStorage还是MMKVReact Native里做本地草稿主流选择就两个老牌的AsyncStorage和腾讯开源的MMKV。我两个都深度用过结论很明确新项目直接上MMKV老项目如果已经统一用了AsyncStorage也完全做得起来只是有些场景要额外处理。先放一个对比表格对比维度AsyncStorageMMKV读写方式异步基于文件存储有序列化开销同步读写mmap内存映射性能高一个量级存储上限无硬性限制受设备磁盘空间影响单个文件默认无上限受设备磁盘空间影响启动恢复速度有IO延迟可能造成首屏空白同步获取启动时读草稿基本无感加密能力不支持完全明文支持AES加密适合敏感草稿Rust/Native依赖不需要额外原生编译需要原生依赖不过自动link比较成熟社区维护官方维护稳定腾讯开源社区活跃为什么我把恢复速度单拎出来说因为恢复草稿是“用户刚进页面就要看到内容”的高频路径。用AsyncStorage的话读取是异步Promise组件挂载后先渲染空表单等接口回来后突然把草稿内容塞进去体感上会闪一下。而MMKV同步读取可以在表单渲染之前就拿到草稿数据直接初始化state不会出现“先空白后填充”的情况。另外MMKV的存储单位是KV原生底层自己管内存映射不会在JS层把整个文件加载进来。对于单条草稿几十KB、几百条草稿的情况性能和磁盘占用都更可控。1.3 草稿数据结构的统一设计不管底层用哪个存储引擎草稿的数据结构我建议全App统一不要每个业务自己搞一套。一个统一的结构长这样interface DraftT { id: string; // 草稿唯一ID一般用时间戳随机数 bizType: string; // 业务类型post/comment/profile等 version: number; // 草稿结构版本号从1开始 payload: T; // 业务数据由各业务自己定义 createdAt: number; // 创建时间戳 updatedAt: number; // 最后更新时间戳过期判断靠它 }这里有几个细节值得强调。第一bizType必须要有。因为草稿的恢复和清理逻辑是通用的但不同业务的payload结构完全不一样。有了bizType我们可以在存储层用统一的key前缀区分不同业务例如draft:post、draft:comment读取时也能精准定位。第二version字段从第一天就必须埋进去。哪怕你现在只有一个业务、一个版本也把这个字段写上。不然等App发了三个版本之后你想加字段所有的存量草稿都没有版本号迁移逻辑根本无从谈起。我见过不少项目一开始图省事没写版本号后面做迁移时只能靠“猜”那真是灾难。第三时间戳建议用Date.now()直接存number不要存字符串更不要直接用Date对象。因为JSON序列化Date对象会变成字符串反序列化后还得再parse一次特别容易出错。直接用number时间戳最干净。2. 草稿的写入与恢复落地实现2.1 写入策略防抖保存与写队列草稿写入最大的坑是“写太频繁”。用户一边打字一边触发保存如果每打一个字就全量序列化一次草稿很快就能看到掉帧和存储文件急剧膨胀。我试过把内容型草稿直接绑定到onChangeText事件上结果输入中文时整个输入框都开始卡。正确做法是防抖加写队列。防抖的核心思路是用户输入过程中不立即保存而是等用户停顿一段时间后再写。这个间隔我一般取300到500毫秒。300毫秒内继续输入就重置计时器只有真正停止输入了才触发一次真正写入。const saveTimerRef useRefReturnTypetypeof setTimeout(); const contentRef useRef(); const handleContentChange (text: string) { contentRef.current text; setContent(text); clearTimeout(saveTimerRef.current); saveTimerRef.current setTimeout(() { draftManager.save(post, { title: titleRef.current, content: text, images: imagesRef.current, }); }, 400); };这里我额外用了一组ref目的是在保存时能拿到最新的title、images等字段而不必依赖闭包里的state。用state直接参与定时器回调容易出现拿不到最新值的问题因为setState是异步的且防抖回调里闭包捕获的是旧state。写队列的意思是不要把并发写入直接堆给存储引擎。简单做法是维护一个“当前是否正在写入”的标志如果保存请求来的时候上一次还没写完就记一个pending等前一次完成后再执行最新一次写入。不要用Promise链无限叠加否则草稿内容多的时候Promise数量会爆炸。private writeQueue: Promisevoid Promise.resolve(); save(bizType: string, payload: any) { const key buildDraftKey(bizType); const draft this.buildDraft(bizType, payload); const json JSON.stringify(draft); this.writeQueue this.writeQueue.then(() storage.set(key, json)); }2.2 恢复流程读取、校验、填充草稿恢复不是简单一句“从存储里读出来”就完了而是三步读取、校验、填充。读取就是要拿到原始数据。如果用了MMKV就同步getString用AsyncStorage就要await getItem。这里我强烈建议在页面真正渲染前就把草稿读出来放在state初始化阶段而不是mounted之后再setState这样能避免表单闪烁。校验是最重要但最容易被忽略的一步。从存储里读出来的字符串要尝试JSON.parseparse成功不代表数据是对的。至少要做三件事function parseDraftT(raw: string): DraftT | null { try { const draft JSON.parse(raw); if (!draft || typeof draft ! object) return null; if (typeof draft.version ! number) return null; if (typeof draft.bizType ! string) return null; if (typeof draft.updatedAt ! number) return null; return draft as DraftT; } catch (e) { return null; } }解析失败或者字段缺失时不能崩溃也不能直接返回null后什么都不做。我项目里的做法是记录一条日志然后删除这条脏数据让用户拿到一个干净的表单。如果频繁出现某类草稿解析失败说明可能发布了不兼容版本要赶紧查版本迁移逻辑。填充就是把payload里的字段映射到表单组件上。这里的问题是payload里某些字段可能为null或者缺key。我的习惯是在填充前做一次“归一化”把默认值补进去。比如文章草稿的payload可能是{ title: xxx, content: }但新版页面还需要images数组归一化时直接默认成空数组。const draft draftManager.loadPostDraft(post); const title draft?.payload.title ?? ; const content draft?.payload.content ?? ; const images draft?.payload.images ?? [];2.3 多类型草稿的隔离与key规划一个App里不可能只有一个草稿场景。除了发布长文章还可能有评论框草稿、个人资料编辑草稿、视频文案草稿。这些场景如果混着存就会互相覆盖。我统一用draft:${bizType}这样的格式作为存储key。bizType由业务方自己定义但在草稿模块里要维护一个常量表避免重复export const DraftBizType { POST: post, COMMENT: comment, PROFILE: profile, VIDEO_DESC: video_desc, } as const;有了统一前缀和业务常量清理逻辑也简单了。遍历getAllKeys()时凡是前缀是draft:的key都属于草稿模块管理范围可以统一做过期扫描。读草稿时直接传bizType也不用担心拿到别的业务的数据。这里还有一个容易忽略的点有些业务下用户可能有多条草稿。比如一个用户可能在草稿箱里攒了好几篇未写完的文章。这种场景下draft:post这种单key结构就不够用得加后缀区分比如draft:post:${draftId}。恢复的时候要么让用户从草稿箱里选要么默认加载最新一条。多草稿场景下我建议把key再包一层存一个“草稿索引”其他不变。3. 草稿过期策略不是简单加个时间戳3.1 什么时候该让草稿过期很多人理解“草稿过期”就是加一个updatedAt然后判断天数但这只是基础。真正要思考的是什么情况下一条草稿对用户和App都失去了价值。第一种是时间上失活。用户半年前写了两行字的草稿今天大概率不会再继续写一直留着反而让草稿箱越来越乱。时间阈值怎么定要看你业务的用户使用频率。日活高的内容社区用户可能两三天就回来一次草稿存30天比较合理低频的工具类App用户可能一个月才打开一次阈值就要放宽到90天甚至半年。第二种是版本上失效。如果草稿数据的结构版本比当前App支持的版本旧了太多比如跨了三个大版本那么即使勉强通过迁移中间的数据可能也已经无法正确展示。这时候不如直接判定这条草稿不可恢复提醒用户后清理。第三种是数量上泛滥。有些业务用户会大量产生草稿比如给上百个商品写简介。这时要在草稿模块里设置一个上限比如最多保存20条超限后删除最早更新的一条。这个策略和“按时间过期”是互补的。3.2 检查过期懒清理和定期清理配合过期检查我建议做两层懒清理和定期清理。懒清理的意思是每次读取某条草稿时先判断它是否过期过期就直接删掉并返回null。这种方式的好处是逻辑简单、实时性好而且只涉及单条数据不需要全量扫描。但它有个问题如果一条草稿从来没被读取过它就永远不会被删会一直躺在存储里。定期清理就是解决这个问题的。我常用的方案是在App启动后延迟几秒做一次全量扫描或者在App从后台回到前台时做一次。扫描时遍历所有draft:前缀的key按统一规则判断是否过期过期就删除。export function cleanupExpiredDrafts(options: { maxAge: number; now?: number }): number { const now options.now ?? Date.now(); const keys storage.getAllKeys().filter(k k.startsWith(DRAFT_PREFIX)); let removedCount 0; for (const key of keys) { const raw storage.getString(key); if (!raw) { removedCount; continue; } try { const draft JSON.parse(raw); if (now - draft.updatedAt options.maxAge) { storage.delete(key); removedCount; } } catch (e) { storage.delete(key); removedCount; } } return removedCount; }注意定期清理不要在启动时同步跑否则可能阻塞启动流程。放在setTimeout或者InteractionManager.runAfterInteractions里更稳妥。如果希望用户体验更好一些还可以在过期前提醒用户。比如草稿还有3天过期时下次进入页面弹个Toast“您有一条草稿将于3天后自动过期”。这个提示信息可以放在草稿箱页面的列表项上不打扰主流程。3.3 清理实现与存储空间控制清理实现本身不难但要注意一个问题删除操作也可能有开销。如果用AsyncStorage的multiRemove批量删除比挨个removeItem高效很多尤其是累积了几百条草稿的情况下。const expiredKeys keys.filter(...); if (expiredKeys.length 0) { await AsyncStorage.multiRemove(expiredKeys); }如果是MMKV就分别调delete它底层是文件映射单条删除本身已经很快。存储空间控制上除了删除过期草稿还要控制单条草稿的体积。我之前遇到过用户往文章里粘贴了一张几MB的图片base64之后直接存进了草稿瞬间把存储撑大了。后来我们把图片类数据单独抽出来存缩略图或者本地文件路径草稿payload里只存引用体积一下就降下来了。另外清理逻辑不要只依赖定时扫描。如果用户手动删除草稿、或者发布成功后应该在业务层主动调用一次remove(bizType)马上清除对应草稿而不是等过期扫描。发布成功后清理这一点尤其重要否则用户会发现刚发布完草稿箱里那条旧草稿还在体验特别奇怪。4. 版本迁移数据升级的安全通道4.1 从一次线上事故说起我们曾经遇到过一个问题1.0版本的帖子草稿结构是{ title, content }2.0版本为了支持多图payload改成了{ title, content, images: string[] }。当时想着老草稿没有images读取时默认空数组就行结果没这么简单——产品在3.0版本又加了定位字段location同时把images从string数组改成了对象数组每个对象带uri、width、height。结果是什么老草稿读取后images里全是字符串页面里却要取image.uri直接渲染崩溃。这就是典型的“没有版本迁移”事故。如果从1.0版本开始就在每次读取时做版本判断把v1结构统一迁移到v3结构这类问题根本不会发生。4.2 迁移器注册机制与执行流程迁移方案的核心是一个“迁移注册表”。每个迁移项定义为从哪个版本开始升到哪个版本以及如何转换payload。interface MigrationT any { from: number; to: number; migrate: (payload: T) T; } const postMigrations: Migration[] [ { from: 1, to: 2, migrate: (payload) ({ ...payload, images: [], }), }, { from: 2, to: 3, migrate: (payload) { const images payload.images || []; return { ...payload, images: images.map((url: string) ({ uri: url, width: 0, height: 0, })), }; }, }, ];执行时从草稿当前的version开始沿着迁移链一路升级直到达到当前App支持的版本。function migrateDraftT(draft: DraftT, targetVersion: number, migrations: Migration[]): DraftT { let current { ...draft }; while (current.version targetVersion) { const migration migrations.find(m m.from current.version); if (!migration) break; try { const newPayload migration.migrate(current.payload); current { ...current, payload: newPayload, version: migration.to, }; } catch (e) { // 迁移失败保留原数据等待降级处理 return draft; } } return current; }这里有几个关键设计第一迁移必须幂等。同一个迁移项不能因为重复执行就产生不同结果。比如“把images从string变成对象”这个操作如果第二次执行时images已经是对象就不能再.map要加isArray判断。第二while循环必须有跳出机制。如果迁移链断裂比如注册表缺了2-3的迁移不能无限循环。上面代码里find不到对应迁移项就break这是兜底。第三迁移失败时返回原始草稿而不是抛错。这样上层可以决定是提示用户还是走降级移除至少不能让App崩溃。4.3 迁移容错与测试要点容错是迁移框架里最容易被低估的部分。我踩过的坑包括迁移函数里的字段存在但类型不对、迁移后新增字段和页面的默认值冲突、迁移链跨了多个版本但中间版本没有注册。这些都可能导致用户打开草稿时白屏或闪退。容错策略我总结成三条第一条读取草稿时如果迁移失败不要覆盖原始存储。保留原数据同时给用户一个“草稿无法打开”的提示并提供一个“导出原始文本”的入口至少让用户把还能看到的内容复制出去。第二条迁移不能做破坏性操作。比如老版本payload里有content新版本改成了blocks结构迁移时不要直接删掉content可以在新结构里保留一个legacyContent备份字段。这样如果新版本也有问题还能靠老字段兜底。第三条要控制一条草稿的迁移跨度。如果当前版本是5而草稿是v1中间跨了4个版本这种“连跳”很容易出问题。我的做法是设置一个最大迁移跨度比如只支持连续迁移最多3个版本超过就直接放弃迁移并清理同时上报数据看哪些草稿被弃用了。迁移逻辑一定要写单元测试。每个迁移项独立测迁移链组合测迁移失败场景也要测。一个比较实用的测试方式是把旧版草稿的JSON做成fixture放在测试目录里每次改版本号就新增一个fixture跑完整迁移链路后和预期快照对比。这样后续再改结构时老数据不会成为黑盒。5. 常见问题与排查技巧实录5.1 白屏、闪烁与启动期竞态React Native启动白屏是个老话题草稿模块虽然不一定是白屏的首因但它会在启动早期参与数据读取如果处理不当会放大视觉上的“空窗”。用AsyncStorage时一个非常典型的体验是用户打开编辑器先看到空白表单过了200毫秒草稿内容才setState进来视觉上闪一下。如果草稿内容很大间隔会更长用户甚至以为App坏了。解决办法我在前面提过优先选择同步读写的MMKV。如果项目确实只能用AsyncStorage那就在读取期间用一个loading状态罩住表单等解析完成后再显示。不要让用户看到“空表单一瞬间再填充内容”的过程。启动期的竞态问题也可能出现在草稿保存上。比如用户进入页面时恢复草稿是异步的但用户很快就输入了第一个字触发了立即保存这时保存和恢复交叉了可能出现旧内容覆盖新内容。解决方式是给草稿加上“恢复完成后才允许保存”的开关恢复没结束就不允许写入。5.2 序列化丢失与写入失败JSON序列化有不少隐藏坑尤其在草稿这种“用户可能塞各种内容”的场景下。最常见的是日期对象。如果你在payload里直接存了DateJSON.stringify会把它转成ISO字符串但下次parse出来是字符串不是Date页面里一调getTime()就崩。所以草稿里所有时间统一存时间戳数字这个前面已经强调过。另一个坑是undefined和NaN。JSON.stringify会把undefined键直接抹掉把NaN转成null。如果页面代码没有对这两个值做保护很容易出现字段缺失。我的习惯是在保存前做一个payload的sanitize把非法值统一替换成默认值避免入库。写入失败也是要处理的。设备存储满了、MMKV文件被系统清理、或者文件权限异常都可能让storage.set失败。写入失败时不能什么都不做要让草稿数据还在内存里待着然后延迟重试。我项目里的降级策略是保存失败就弹一个轻提示“草稿保存失败将重试”同时把草稿保留在内存缓存下一次App前台时再次尝试写入。5.3 草稿冲突与覆盖问题多端同步场景下草稿冲突很难完全避免。同一用户在一台设备的草稿还没上传又在另一台设备改了同一篇内容最后都是“后写的覆盖先写的”。在纯本地草稿阶段我们通常接受last-write-wins也就是谁最后更新的就生效。但为了减少覆盖带来的损失我在草稿管理器里加了一个updatedAt比较保存前先读一次当前存储里的草稿如果当前草稿的updatedAt大于本次要保存的updatedAt说明有其他更新写入了这时候可以选择丢弃本次保存或者生成一条冲突副本。这个策略很简单但至少能避免“旧数据把新数据盖掉”的问题。真正的多人协同草稿需要diff和合并那是另一个复杂度量级只做本地草稿的话不建议深入成本太高。5.4 测试与监控方案草稿模块看着小但属于“低频高价值”的业务路径出了问题用户感知特别强。我至少会做三类测试一是迁移单测。把旧版本草稿fixture跑迁移链路断言输出结构和字段正确。这个最值得做因为迁移逻辑是纯函数好测且容易回归。二是存储层集成测试。在测试环境里分别造一条未过期草稿、一条过期草稿、一条脏数据草稿跑load和cleanup验证恢复、过期、删除的行为都正确。三是手动回归清单。记录几个必测场景写一半杀进程再进、升级版本后打开老草稿、清空草稿再退出、连续快速输入时是否丢最后几个字。这些手动场景虽然琐碎但确实能抓出自动化测试发现不了的真实问题。监控方面可以做两类埋点一类是草稿保存失败率另一类是迁移失败率。迁移失败率如果突然升高往往意味着发布了一个有问题的版本需要快速回滚或者发布补丁不能等问题积累到用户投诉才处理。最后分享两个我在实际项目里反复体会到的点。第一个是version字段一定从第一天就加哪怕现在只有一个版本也要加。这行代码的成本几乎为零但没有了它后续所有迁移都无从下手这个坑我踩过一次非常难受。第二个是草稿保存的体验本质上是在“保存频率”和“资源开销”之间找平衡300到500毫秒的防抖加写队列实测下来是我觉得最稳的组合。草稿功能听起来不大但把恢复、过期、迁移这三件套做成通用模块之后新业务接入草稿的成本会变得非常低而用户那边收到的信号很简单这个App我写到一半的内容永远都在。