表单加上自动保存之后,输入丢失的问题解决了一部分。但恢复出来的数据,不一定还能直接使用。
用户可能换了账号,记录可能已经完成,新版本也可能调整了字段含义。还有一种不太容易注意到的情况:提交成功时明明删除了草稿,稍后触发的防抖保存又把它写了回来。
因此,本地草稿不能只按"把页面状态序列化,再原样塞回去"来设计。它保存的是一份待确认的编辑进度,不是页面快照,也不是服务端业务状态。
早期介绍 MMKV 时,重点是如何读写数据。这篇继续讨论读写之外的规则:哪些内容值得恢复,旧数据如何进入新版本,以及一轮编辑结束后如何停止保存。
草稿内容
草稿应该优先保存用户已经付出成本、又不能从接口直接重新获取的输入,例如备注、尚未提交的数值,以及用户选择的对象 ID。
加载状态、请求错误、弹窗是否打开,不适合一起持久化。接口返回的"已完成""允许提交"等状态,也不应该靠本地布尔值恢复。
选项名称和描述可以作为展示快照保留,但恢复时仍要按 ID 确认对象存在、仍可选择。否则恢复的是旧界面,不是当前有效的编辑内容。
输入值也不必立刻转成数字。用户输入到 1. 时,数值尚未完成,但它仍然是有意义的编辑进度。草稿结构校验与最终提交校验,需要分开处理。
记录范围
只用一个固定的 draft 键,会让不同记录覆盖彼此。只带记录 ID,也可能在切换账号或业务空间后读到不属于当前身份的数据。
下面用一个普通记录编辑场景演示。草稿按空间、账号和记录隔离;如果应用还允许切换数据环境,也应把环境标识纳入范围。
示例使用 Zod 4 定义结构。safeParse() 可以返回校验结果,z.infer 用于从结构推导 TypeScript 类型,不需要在解析后强行断言。官方说明
ts
// draftModel.ts
import { z } from 'zod';
const ScopeSchema = z.object({
spaceId: z.string().min(1),
accountId: z.string().min(1),
recordId: z.string().min(1),
});
const common = {
scope: ScopeSchema,
editedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
note: z.string().max(2000),
};
const LegacyDraftSchema = z.object({
...common,
version: z.literal(1),
lengthCmInput: z.string().max(32),
});
export const DraftSchema = z.object({
...common,
version: z.literal(2),
lengthMmInput: z.string().max(32),
});
export type Draft = z.infer<typeof DraftSchema>;
export type Scope = z.infer<typeof ScopeSchema>;
export function draftKey(scope: Scope) {
const { spaceId, accountId, recordId } = ScopeSchema.parse(scope);
return JSON.stringify(['item-draft', spaceId, accountId, recordId]);
}
用固定顺序的数组生成键,是为了避免简单字符串拼接时的分隔符歧义。键里已经带了范围,正文中仍然保留 scope,读取时再校验一次。
身份尚未就绪时,不创建草稿编辑器。不要用空字符串充当临时账号,等接口返回后再悄悄换一个保存位置。
这些隔离规则防止的是应用逻辑串读,并不等于加密或访问控制。敏感内容仍应遵守独立的存储策略,不要把凭证和完整接口响应一起塞进草稿。
恢复入口
本地数据也需要按未知输入处理。它可能来自旧版本,可能结构不完整,也可能只是一段语法正确、字段类型却不对的 JSON。
JSON.parse(raw) as Draft 只会让类型检查通过,不会验证字段。后续直接对某个字段调用 trim(),仍然可能失败。
更适合把读取拆成一条明确的检查顺序:
text
读取原始值 → 识别版本 → 校验对应结构 → 核对范围和时间 → 必要的迁移
↓
可供恢复的候选草稿,而非立即启用的表单
不要把所有失败都折叠成 null。没有草稿,可以创建新表单;发现不认识的新版本,则不应立刻用空表单覆盖它。
下面的解析函数不操作磁盘,只返回结果。TTL 表示距离最后一次真实编辑的时间,达到七天便不再自动恢复。这是示例的产品规则,不是 MMKV 的内置过期机制。
ts
// 接在 draftModel.ts 的结构定义之后
export const DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export type ReadResult =
| { kind: 'missing' }
| { kind: 'ready'; draft: Draft; migrated: boolean }
| {
kind: 'blocked';
reason:
| 'invalid'
| 'version'
| 'scope'
| 'clock'
| 'expired'
| 'migration';
};
export function readDraft(
raw: string | undefined,
scope: Scope,
now = Date.now(),
): ReadResult {
const expectedKey = draftKey(scope);
if (raw === undefined) return { kind: 'missing' };
if (!Number.isSafeInteger(now) || now < 0) {
return { kind: 'blocked', reason: 'clock' };
}
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
return { kind: 'blocked', reason: 'invalid' };
}
const header = z.object({ version: z.number().int() }).safeParse(value);
if (!header.success) return { kind: 'blocked', reason: 'invalid' };
if (header.data.version !== 1 && header.data.version !== 2) {
return { kind: 'blocked', reason: 'version' };
}
const schema = header.data.version === 1 ? LegacyDraftSchema : DraftSchema;
const parsed = schema.safeParse(value);
if (!parsed.success) return { kind: 'blocked', reason: 'invalid' };
const cached = parsed.data;
if (draftKey(cached.scope) !== expectedKey) {
return { kind: 'blocked', reason: 'scope' };
}
if (cached.editedAt > now) return { kind: 'blocked', reason: 'clock' };
if (now - cached.editedAt >= DRAFT_TTL_MS) {
return { kind: 'blocked', reason: 'expired' };
}
if (cached.version === 2) {
return { kind: 'ready', draft: cached, migrated: false };
}
const lengthMmInput = migrateLength(cached.lengthCmInput);
if (lengthMmInput === null) return { kind: 'blocked', reason: 'migration' };
const { lengthCmInput: _oldInput, ...rest } = cached;
const migrated = DraftSchema.safeParse({
...rest,
version: 2,
lengthMmInput,
});
return migrated.success
? { kind: 'ready', draft: migrated.data, migrated: true }
: { kind: 'blocked', reason: 'migration' };
}
now 保留为参数,便于精确验证过期边界。读取函数不会刷新 editedAt,否则用户每次进入页面,都会给旧草稿重新续期。
如果时间戳落在未来,示例会停止自动恢复,而不是立即删除。设备时钟可能被调整,基于本地时间的 TTL 只能作为恢复策略,不能承担安全期限或业务权限判断。
未识别版本、损坏内容或迁移失败,也先保留原始值。页面可以提示核对、升级应用或明确选择重新开始,再按对应键处理。不要在一个宽泛的 catch 里调用 clearAll()。
版本迁移
版本号应该表达草稿结构和语义的变化,不需要跟随每一次应用发版递增。
例如,旧表单用厘米,新表单改成毫米。直接把旧字符串放进新字段,界面仍然能显示,但含义已经变了。
这里约定旧版本的完整数值是非负十进制文本,必须有整数部分,最多八位整数、一位小数,不接受负数、.5 或指数形式。新版本使用整数毫米。迁移通过拆分十进制文本完成,不把任意字符串交给浮点乘法处理。
ts
// 同样放在 draftModel.ts 中
function migrateLength(input: string): string | null {
const text = input.trim();
if (!text) return '';
const match = /^(\d{1,8})(?:\.(\d))?$/.exec(text);
if (!match) return null;
const millimeters = Number(match[1]) * 10 + Number(match[2] ?? 0);
return String(millimeters);
}
12.3 会变成 123,空输入仍然是空输入。这个范围内的运算使用安全整数,不涉及任意精度的小数换算。
但 1. 属于未完成输入,示例不会替用户猜测其含义。它会停止自动迁移,保留旧草稿,交给明确的核对或重填流程。实际产品也可以提供旧值预览,但不能静默补零或丢掉其它输入。
迁移后再次校验目标结构,并保留原来的 editedAt。结构升级不是用户编辑,不应延长草稿有效期。
migrated 只告诉调用方候选数据发生过转换。接受恢复后,可以把它写回当前版本;仅仅扫描到一份旧草稿,不必立即覆盖原件。
新增版本时,也应为每一步转换保留旧结构和测试样本。应用降级读到更高版本时,停止自动处理,比假装兼容更合适。
恢复选择
解析得到 ready,只表示这份数据在本地规则下可作为候选。它还没有证明对应记录仍然允许编辑。
恢复前,需要重新获取当前记录,确认它存在、身份仍有权限、业务状态仍允许继续。引用的选项也要重新核对。如果草稿依赖服务端的某一版本,还需要记录并比较相应版本标识,决定是继续、合并还是提示冲突。
界面状态可以先区分为"初始化""等待恢复选择"和"编辑中"。只有进入编辑状态、且用户真正改动过内容后,才启用自动保存。
这一点很重要。页面先创建空草稿,再异步检查旧草稿,同时让保存 Effect 正常运行,很容易在用户看到恢复提示前就覆盖原数据。
恢复候选和当前编辑数据应分开持有。用户确认恢复后再建立编辑会话;明确选择重新开始时,才放弃旧内容。关闭提示或离开页面,不应默认等价于放弃。
读取和服务端核验期间如果切换了账号或记录,旧结果也应失效。不能让上一条记录的异步核验结果,初始化当前记录的编辑器。
如果恢复提示停留很久,确认时还应重新检查时效和当前业务状态。恢复的是输入,不是让旧的"已经校验通过"标记直接生效。
保存时机
存储层可以使用 MMKV。以下采用 react-native-mmkv v4 的 createMMKV、getString、set 和 remove,假设原生依赖已按对应版本完成安装。官方文档
ts
// draftStorage.ts
import { createMMKV } from 'react-native-mmkv';
import {
DraftSchema,
draftKey,
readDraft,
type Draft,
type Scope,
} from './draftModel';
export const draftStorage = createMMKV({ id: 'item-drafts' });
export const loadDraft = (scope: Scope) =>
readDraft(draftStorage.getString(draftKey(scope)), scope);
export function saveDraft(draft: Draft) {
const checked = DraftSchema.parse(draft);
draftStorage.set(draftKey(checked.scope), JSON.stringify(checked));
}
这个适配层不负责生成编辑时间。编辑器应在用户修改字段时创建新的不可变快照,并更新 editedAt;保存、读取和重试本身都不改变它。
读取存储失败与读到非法 JSON 也不是同一种错误。存储异常应交给调用方提示或重试,不能伪装成"没有草稿",随后写入默认值。
MMKV 的读写是同步调用,但序列化和频繁写入仍有成本。对于小型表单,可以在输入时防抖保存,在明确的阶段切换或主动退出入口刷新待保存内容。API 说明
下面只保留保存调度器的核心。每个编辑会话持有自己的实例,接收不可变草稿快照;写入函数必须是同步存储操作。
ts
// draftWriter.ts
import type { Draft } from './draftModel';
export function createDraftWriter(
write: (draft: Draft) => void,
onError: (error: unknown) => void,
delayMs = 300,
) {
let timer: ReturnType<typeof setTimeout> | undefined;
let pending: Draft | null = null;
let stopped = false;
const cancelTimer = () => {
if (timer !== undefined) clearTimeout(timer);
timer = undefined;
};
const flush = (): boolean => {
cancelTimer();
if (stopped) return false;
const snapshot = pending;
if (!snapshot) return true;
try {
write(snapshot);
if (pending === snapshot) pending = null;
return true;
} catch (error) {
onError(error);
return false;
}
};
return {
schedule(draft: Draft) {
if (stopped) return;
pending = draft;
cancelTimer();
timer = setTimeout(flush, delayMs);
},
flush,
stop() {
stopped = true;
cancelTimer();
pending = null;
},
};
}
写入失败时,待保存快照仍然保留。onError 用于设置保存失败状态,不应再次抛出异常;之后由用户操作或明确的重试策略再次触发 flush(),不应无休止地自动重试。
对尚未停止的实例,flush() 只处理当前待写快照,没有待写内容也返回 true。stop() 则永久停止该实例,并丢弃尚未保存的快照,不会代为执行保存;停止后调用 flush() 返回 false。
不要每次渲染都创建新的调度器。同一编辑会话只使用一个实例;账号、记录或会话切换时,先结束旧实例,再创建新实例。组件卸载时停止未执行的定时器,但不能把卸载清理当成唯一的保存时机。
主动离开并保留草稿时,可以先 flush(),成功后再导航;明确放弃时则停止调度并删除对应键。若写入失败,应让用户知道当前内容尚未保存。
AppState 可以通知应用前后台变化,适合作为补充保存时机,但它提供的是状态变化事件,不是可靠的最终退出通知。官方说明
防抖期间的输入仍可能在进程突然结束时丢失,持续输入也可能不断推迟保存。需要时可以增加最大等待时间,或在重要操作节点立即保存;不要把这种方案描述成零丢失保证。
提交后的清理
本地草稿只在服务端明确接受提交之后清理。请求失败或结果未知时,先保留输入,再按接口语义确认状态,不能因为用户按过提交按钮就删掉。
另一个顺序也需要固定:先结束保存会话,再删除草稿。只调用 remove(),尚未执行的定时器仍可能把旧快照重新写回来。
下面摘出提交成功路径。约定提交期间锁定编辑与重复提交入口,也不替换当前编辑会话;writer、draft 和存储键都属于提交发起时的同一轮编辑。
ts
const submittedKey = draftKey(draft.scope);
if (!writer.flush()) return;
await submitItem(draft);
writer.stop();
draftStorage.remove(submittedKey);
这段省略了请求错误展示和页面状态切换。submitItem 只有确认服务端接受时才正常返回;失败时保留草稿,不能把未知结果当作成功。这里选择本地保存失败时先暂停提交,产品也可以明确提供不保存直接提交的入口。
如果允许提交过程中退出再重开,就不能直接照搬这个简化路径。需要编辑会话的代际标识,让旧完成回调只能处理所属会话的数据,不能删除同一记录刚产生的新草稿。
删除本地数据本身也可能失败。服务端提交与本地清理不是一个原子事务,所以再次进入时仍要核对服务端状态,不能把残留草稿当作重新提交的依据。
错误处理也应区分这两步。服务端已经接受、本地删除失败时,界面仍应表达提交成功,后续只处理本地清理。不要因为共用一个 catch,把清理异常提示成提交失败,并引导用户重复提交。
验证范围
这套设计先约定同一记录只有一个有效编辑会话。两个页面同时编辑同一份草稿时,防抖和时间戳都不能解决覆盖问题,需要统一写入方,或者额外定义版本冲突规则。
代码测试应优先覆盖那些"看起来能解析,却不该直接恢复"的数据:
- 不同账号、空间和记录之间不能串读,含分隔符的 ID 也不会让键发生歧义。
- 非法 JSON、字段类型错误和未知版本不会进入编辑状态,也不会被解析器自动删除。
- 最后编辑时间恰好达到 TTL 时过期,未来时间戳停止自动恢复。
- 完整旧值正确迁移,未完成数值停止自动迁移,备注和编辑时间保持不变。
- 迁移后的数据再次读取不重复换算,也不会因恢复或重试而延长 TTL。
- 保存失败保留待写快照;停止调度后,旧定时器和新的
schedule()都不能重新写入。
解析、迁移和调度器可以用纯函数与可控定时器测试。恢复选择、账号切换、主动退出、应用切后台,以及真实设备上的存储失败和进程结束,还需要结合编辑器做集成验证。示例没有实现完整恢复界面,也不替代这些验证。
附件应单独处理。本地临时 URI 不代表下次启动仍然可读,上传后的资源 ID 也需要检查有效性,不适合只跟随表单 JSON 原样恢复。
本地草稿解决的是同一设备上的输入恢复,不是离线同步协议。跨设备合并、服务端冲突处理和提交幂等,属于另外一层能力。
草稿保存得及时很重要,但同样重要的是:恢复时知道它是否还有效,结束时知道它不会再被写回来。把这两个边界固定下来,自动保存才真正成为可依赖的编辑体验。