保存成功,为什么还会丢输入:编辑会话与本地草稿
用户点击保存时正文是 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);
}
}这个片段省略错误处理、服务端版本冲突和队列实现,不能单独当成完整保存器。它强调的约束是:旧提交不能替后来输入宣告成功。
防抖减少次数,串行队列保证顺序
防抖只能推迟任务启动。第一次请求发出去以后,第二次请求仍可能并发,后发的响应也可能先到。
当前保存队列采用“一个在途任务 + 一份最新待提交快照”:
- 保存请求到来时,将当时的快照设为最新待提交内容;普通笔记可由输入触发自动同步,知识库输入本身只更新表单和本地草稿。
- 有在途任务就等待,避免多个写入并发。
- 成功后继续处理等待中的最新快照。
- 失败后保留待提交内容,退出本轮执行,允许用户重试。
- 切换会话时推进队列代次,清理未启动任务,抑制旧回调。
合并中间快照适合“最终正文是什么”这类状态保存。如果业务要求保留每次操作,例如记账流水或逐条审计,就不能用“只留最新一份”替代操作队列。
取消队列也不意味着撤销已经到达服务端的写入。浏览器的 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 的迟到响应不会回填。
- [ ] 草稿恢复弹窗期间换号或换页:旧选择无效。
- [ ] 另一窗口改正文或移动目录:旧基线提交进入冲突流程。
- [ ] 本地存储写入失败:不能显示“已安全保存在本机”。
继续阅读:富文本与附件生命周期。