Skip to content

保存成功,为什么还会丢输入:编辑会话与本地草稿 ​

用户点击保存时正文是 A,请求还没返回,用户又输入了 B。随后 A 保存成功,页面把 dirty 清成 false,用户关掉页面,B 就丢了。

服务端没有出错,错误在于客户端把“某次快照已保存”解释成“当前表单已保存”。保存状态必须属于一份确定的内容。

本文延续笔记应用实践,根据当前前端实现总结编辑会话、队列和草稿的边界。示例为通用教学代码;文末是建议回归场景。

先分清三条保存路径 ​

当前应用中,普通笔记与知识库文档并不采用相同交互:

路径触发方式持久化位置页面应该怎样解释
普通笔记自动同步本地修改进入同步队列本机缓存及服务端区分待同步、失败与已同步
知识库手动保存点击保存或保存快捷键服务端本次提交成功后才确认
知识库本地草稿输入后防抖写入当前浏览器用于恢复,不能称为已上传

本地草稿不是手动保存的替代品。浏览器配额、隐私模式、清理站点数据和写入前崩溃,都可能让草稿不可用。

把一次编辑作为一个会话 ​

当前实现将表单、加载序号、保存队列、本地草稿和已读版本放进同一个编辑会话。页面调用会话能力,并负责询问用户是否离开、恢复或覆盖。

text
打开内容 → 读取服务端基线 → 检查本地草稿
                                  │ 用户选择恢复
                                  ▼
                              当前表单
                         ┌────────┴────────┐
                         ▼                 ▼
                    本地草稿兜底       手动提交快照
                                           │
                            失败:保留       │ 成功
                                           ▼
                              当前表单仍等于该快照?
                                是:已保存 / 否:仍有修改

初始化数据、恢复草稿和用户输入不是同一种变化。若只用一个无条件 watch,很容易把初始化误判为编辑,或把服务端回填再次上传。

响应只能确认自己的快照 ​

当前实现会保存提交时的标题、正文和主动修改过的父级,响应回来后与当前表单比较;请求期间出现新输入,就继续保留未保存状态,并以新确认的版本保留后续草稿。

下面只演示“快照确认”这一点,假定调用方已串行提交,且 readForm() 返回与载荷一致的字段:

ts
async function saveSnapshot() {
  const owner = currentSession();
  const submitted = structuredClone(readForm());

  const result = await persist(submitted);
  if (owner !== currentSession()) return;

  rememberConfirmedVersion(result.version);
  if (sameEditableFields(readForm(), submitted)) {
    markSaved();
    clearLocalDraft();
  } else {
    markUnsaved();
    keepLocalDraft(result.version);
  }
}

这个片段省略错误处理、服务端版本冲突和队列实现,不能单独当成完整保存器。它强调的约束是:旧提交不能替后来输入宣告成功。

防抖减少次数,串行队列保证顺序 ​

防抖只能推迟任务启动。第一次请求发出去以后,第二次请求仍可能并发,后发的响应也可能先到。

当前保存队列采用“一个在途任务 + 一份最新待提交快照”:

  1. 保存请求到来时,将当时的快照设为最新待提交内容;普通笔记可由输入触发自动同步,知识库输入本身只更新表单和本地草稿。
  2. 有在途任务就等待,避免多个写入并发。
  3. 成功后继续处理等待中的最新快照。
  4. 失败后保留待提交内容,退出本轮执行,允许用户重试。
  5. 切换会话时推进队列代次,清理未启动任务,抑制旧回调。

合并中间快照适合“最终正文是什么”这类状态保存。如果业务要求保留每次操作,例如记账流水或逐条审计,就不能用“只留最新一份”替代操作队列。

取消队列也不意味着撤销已经到达服务端的写入。浏览器的 AbortController 可以终止 fetch 或响应读取,但不能据此推断服务端事务已回滚;这是两端职责边界带来的结论。MDN AbortController.abort

三种过期需要分别判断 ​

当前加载流程同时检查加载序号、会话代次、知识库和条目标识,并在等待草稿确认后再次检查。

交错场景为什么仅检查条目 ID 不够需要的边界
A 加载很慢,切到 B 后 A 才返回A 不该回填 B当前目标 ID
A → B → A,第一次 A 最晚返回ID 又变回 A,但请求已过期加载序号
退出账号后登录另一个账号旧响应可能仍在网络中会话代次
恢复草稿弹窗打开时切换目标用户点击的是旧上下文里的确认弹窗结束后再次校验

这些检查不能只写在请求发起前;每个可能让出执行权的等待之后,都应确认结果仍有归属。

如果用 watcher 驱动异步加载,可以利用 Vue 的清理机制撤销旧任务。onWatcherCleanup 在 Vue 3.5+ 才可用,且要在同步阶段注册;取消之后仍要检查结果归属。Vue watcher 副作用清理

草稿必须记住账号、内容与编辑基线 ​

当前知识库草稿按账号和内容范围隔离,保存标题、正文、父级、草稿时间,以及开始编辑时的服务端版本。新建内容使用独立的新建范围。

通用模型可以写成:

ts
type LocalDraft = {
  owner: string;
  scope: string;
  baseVersion: string | null;
  fields: { title: string; body: string };
  savedLocallyAt: number;
};

只保存正文而不保存基线,恢复后便无法区分“这是最新内容上的编辑”还是“这是几天前版本上的编辑”。当前实现会保留旧基线,让随后的提交继续触发正常冲突处理。

草稿恢复也不是自动覆盖:先读取服务端,再比较草稿,由用户选择恢复。等待选择期间如果账号或文档变化,旧选择不能作用于新页面。

正文保存不该顺手搬回目录 ​

一个标签页打开文档,另一个标签页移动了目录。若第一个页面保存正文时把所有旧字段原样发送,就可能把新目录位置覆盖回去。

当前编辑会话只在用户主动修改父级时才把该字段放入提交载荷。目录刷新可以同步未被用户主动修改的字段,但不能覆盖表单里尚未提交的选择。

“只提交本次修改的字段”减少无意覆盖;服务端的状态版本仍然需要检查,两者缺一不可。具体见快照同步与并发冲突。

离开保护与本地缓存的实际边界 ​

当前页面有站内路由离开与参数切换保护,浏览器刷新或关闭前也有提示,并处理保存快捷键。普通笔记同步层还能区分“有待同步内容”与“待同步内容未成功写入本机缓存”。

知识库草稿的底层存储会返回写入失败,但当前调用链未把所有失败都转为显式提示。因此不能写成“每次输入都有可靠备份”。建议补充本地草稿不可用状态,并在重要离开操作前明确提示。

beforeunload 也不能保证每次崩溃或系统终止都会触发。可靠设计应在编辑过程中持续保留恢复信息,而不能把最后一次网络保存寄托在关闭页面时。

建议优先验证的交错顺序 ​

  • [ ] A 保存中继续输入 B:A 成功后 B 仍显示未保存。
  • [ ] 连续提交多次:同一内容的写入按序执行,最终保留最新快照。
  • [ ] 请求失败后重试:不无限循环,不丢失较新的待提交内容。
  • [ ] A → B → A 快速切换:第一次 A 的迟到响应不会回填。
  • [ ] 草稿恢复弹窗期间换号或换页:旧选择无效。
  • [ ] 另一窗口改正文或移动目录:旧基线提交进入冲突流程。
  • [ ] 本地存储写入失败:不能显示“已安全保存在本机”。

继续阅读:富文本与附件生命周期。

为复用而记录,为理解而整理。