React Native 本地草稿设计:恢复、过期与版本迁移

表单加上自动保存之后,输入丢失的问题解决了一部分。但恢复出来的数据,不一定还能直接使用。

用户可能换了账号,记录可能已经完成,新版本也可能调整了字段含义。还有一种不太容易注意到的情况:提交成功时明明删除了草稿,稍后触发的防抖保存又把它写了回来。

因此,本地草稿不能只按"把页面状态序列化,再原样塞回去"来设计。它保存的是一份待确认的编辑进度,不是页面快照,也不是服务端业务状态。

早期介绍 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 的 createMMKVgetStringsetremove,假设原生依赖已按对应版本完成安装。官方文档

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() 只处理当前待写快照,没有待写内容也返回 truestop() 则永久停止该实例,并丢弃尚未保存的快照,不会代为执行保存;停止后调用 flush() 返回 false

不要每次渲染都创建新的调度器。同一编辑会话只使用一个实例;账号、记录或会话切换时,先结束旧实例,再创建新实例。组件卸载时停止未执行的定时器,但不能把卸载清理当成唯一的保存时机。

主动离开并保留草稿时,可以先 flush(),成功后再导航;明确放弃时则停止调度并删除对应键。若写入失败,应让用户知道当前内容尚未保存。

AppState 可以通知应用前后台变化,适合作为补充保存时机,但它提供的是状态变化事件,不是可靠的最终退出通知。官方说明

防抖期间的输入仍可能在进程突然结束时丢失,持续输入也可能不断推迟保存。需要时可以增加最大等待时间,或在重要操作节点立即保存;不要把这种方案描述成零丢失保证。

提交后的清理

本地草稿只在服务端明确接受提交之后清理。请求失败或结果未知时,先保留输入,再按接口语义确认状态,不能因为用户按过提交按钮就删掉。

另一个顺序也需要固定:先结束保存会话,再删除草稿。只调用 remove(),尚未执行的定时器仍可能把旧快照重新写回来。

下面摘出提交成功路径。约定提交期间锁定编辑与重复提交入口,也不替换当前编辑会话;writerdraft 和存储键都属于提交发起时的同一轮编辑。

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 原样恢复。

本地草稿解决的是同一设备上的输入恢复,不是离线同步协议。跨设备合并、服务端冲突处理和提交幂等,属于另外一层能力。

草稿保存得及时很重要,但同样重要的是:恢复时知道它是否还有效,结束时知道它不会再被写回来。把这两个边界固定下来,自动保存才真正成为可依赖的编辑体验。

相关推荐
光影少年21 小时前
react navite实现全局弹窗、Toast 组件
前端·react native·react.js
武当王丶也1 天前
React Native 扫码事件调度实战:用监听栈解决页面、弹窗与 Sheet 冲突
react native
光影少年1 天前
react navite封装一个RN 通用按钮组件
前端·javascript·react native·react.js·前端框架
杉氧2 天前
页面栈与路由:React Navigation 与 Expo Router 深度实践
android·前端·react native
光影少年2 天前
react navite高频手写/实操题
前端·javascript·react native·react.js·前端框架
杉氧3 天前
状态管理变迁史:为什么我们放弃了 Redux 选择 Zustand?
android·前端·react native
光影少年4 天前
react navite 安卓iOS 打包、签名、环境区分
前端·react native·react.js
墨狂之逸才4 天前
React Native 环境变量方案选型:自定义、react-native-dotenv 与 react-native-config
react native