用户写了 2000 字然后丢了:vibe 项目的草稿自动保存实战
用户写的 2000 字,要扛住浏览器崩溃、断网和误关标签页。三层防线——输入防抖、localStorage 兜底、带版本号的服务端持久化,外加版本号乐观锁的冲突 UX(保留两份让用户选)、离线重放与草稿生命周期。

本文只讲一件事:单人场景的草稿自动保存——用户写了 2000 字,浏览器崩溃/断网/误关标签页之后,字还在。先划清边界:
- vs 《两个同时改同一行:多人实时协同编辑实战》:那篇讲多人协同(OT/CRDT 算法);本文讲单人草稿保存——防抖、版本冲突、离线恢复,不讲协同算法。
- vs 《富文本编辑器集成实战》:那篇讲编辑器选型与集成;本文讲编辑器之外的草稿持久化层,复用它的编辑器结论。
- vs 《并发与事务实战》:借用版本号乐观锁的思想,但场景是草稿冲突的 UX 处理,不展开事务隔离级别。
丢稿是 vibe 项目里最伤用户信任的事故之一:它不像 500 错误那样"修好了就行",用户丢的那 2000 字是找不回来的。而 AI 生成的编辑器代码,十有八九只有个 onChange 直接调保存接口——没有防抖、没有本地兜底、没有冲突处理。本文给你三层防线,一套能直接抄的方案。
三层防线:丢稿事故都是某一层缺了
| 层 | 防什么 | 实现 | 恢复体验 |
|---|---|---|---|
| 1. 输入防抖(内存) | 频繁写入打爆接口 | 停止输入 800ms 后触发保存 | 即时,无感 |
| 2. localStorage 兜底 | 浏览器崩溃、标签页误关 | 每次防抖触发时同步写本地 | 重开页面提示"恢复草稿" |
| 3. 服务端持久化 | 换设备、清缓存 | 防抖后异步调接口,带版本号 | 任何设备登录都接着写 |
三层缺一不可,缺哪层、丢哪种稿:
- 缺第 1 层:用户每敲一个字就调一次保存接口,服务端被打爆,或者你为了省钱把保存间隔设成 30 秒——用户在第 29 秒关页面,29 秒的字全丢。
- 缺第 2 层:浏览器崩溃是最常见的丢稿场景。服务端保存再快,也救不了"还没发出去的那 800ms"。
- 缺第 3 层:用户手机上写一半,回家想用电脑接着写——没服务端,草稿只活在手机浏览器里。
// 三层防线的最小实现(伪代码,框架无关)
let timer = null;
let version = 0; // 服务端版本号,见第三节
function onInput(content) {
// 第 1 层:防抖
clearTimeout(timer);
// 第 2 层:每次输入都同步写本地(便宜,可靠)
localStorage.setItem('draft:' + docId, JSON.stringify({ content, ts: Date.now() }));
setSaveStatus('editing'); // "编辑中…"
timer = setTimeout(() => saveToServer(content), 800);
}
async function saveToServer(content) {
setSaveStatus('saving'); // "正在保存…"
// 第 3 层:带版本号的服务端保存
const res = await api.put(`/docs/${docId}/draft`, { content, version });
version = res.version;
localStorage.removeItem('draft:' + docId); // 服务端落盘后清本地
setSaveStatus('saved'); // "已保存 12:03"
}
防抖参数:300ms 还是 2s?看内容类型
防抖时间不是拍脑袋的,给两档:
- 短内容(标题、评论、单行输入):300–500ms。用户停顿即意图明确,保存要快,否则"点了发布才发现没保存"的事故会找上你。
- 长内容(文章正文、代码):800ms–2s。写长文时停顿是思考,不是写完。800ms 是个平衡点:既不会频繁打接口,又不会让"正在保存"转太久。
状态文案是 UX 的一半:编辑中… → 正在保存… → 已保存 12:03。三个状态缺一不可——"已保存"后面必须带时间,否则用户不知道"已保存"的是哪一版。AI 生成的代码经常只有"保存成功"的 toast,缺了时间戳,等于没说。
版本号冲突:多标签页同时编辑的灾难
用户在公司电脑和家里电脑同时打开同一篇草稿(或者只是开了两个标签页),两边都在写,最后保存的覆盖先保存的——last-write-wins,静默丢字。这是草稿系统里最阴险的 bug,因为它不报错,只是"字少了"。
解法是版本号乐观锁,上面代码里的 version 字段就是干这个的:
-- drafts 表
CREATE TABLE drafts (
doc_id TEXT PRIMARY KEY,
content TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1, -- 每次保存 +1
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 保存时带上"我基于的版本"
-- UPDATE drafts SET content = $1, version = version + 1
-- WHERE doc_id = $2 AND version = $3
-- 如果 affected rows = 0 → 说明有人先保存了 → 冲突!
冲突发生时,永远不要静默覆盖,要"保留两份让用户选":
- 把服务端的最新版和本地版都展示出来(diff 高亮更好,没有就并排);
- 给三个按钮:保留我的 / 用服务端的 / 合并后手动改;
- 无论选哪个,都记一条日志(谁在什么时候解决了哪次冲突)。
注意和实时协同的区别:协同编辑用 OT/CRDT 自动合并,因为那是"同时在线"的场景;草稿冲突是"先后离线"的场景,自动合并的风险远大于收益——让用户选,是唯一正确的 UX。
离线与弱网:移动端必做,桌面端别省
断网时用户还在写字(地铁、电梯、咖啡馆 Wi-Fi 抽风),你的保存链路不能断。方案是本地队列 + 恢复后按序同步:
// 离线队列:保存失败时入队,网络恢复后按序重放
const pendingQueue = [];
async function saveToServer(content) {
try {
const res = await api.put(`/docs/${docId}/draft`, { content, version });
version = res.version;
} catch (e) {
// 网络失败:进队列,本地 localStorage 已有兜底,字不会丢
pendingQueue.push({ content, version, ts: Date.now() });
setSaveStatus('offline'); // "已离线,恢复后自动同步"
}
}
window.addEventListener('online', async () => {
// 按时间顺序重放,注意版本号可能已过期 → 走冲突流程
for (const item of pendingQueue.splice(0)) {
await saveToServer(item.content);
}
});
三个细节:
- 状态必须诚实:"已离线,恢复后自动同步"和"已保存"是两种状态,绝不能混。用户在离线时看到"已保存",等于你在骗他。
- 重放可能触发冲突:离线期间,另一台设备可能已经保存了新版本。重放时带上旧
version,服务端一验就知道过期——直接走上一节的"保留两份"流程,不要尝试自动合并。 - 队列要有上限:比如只保留最近 50 次。无限队列 + 长期离线 = 内存爆炸。超限时丢弃最老的,但 localStorage 里的完整快照还在,字不会丢。
草稿生命周期:出生、保留、善终
草稿不是写完就完,它有生命周期,三个问题要在设计时回答:
- 保留多久?建议:未发布的草稿保留 30 天,过期自动清理(参考软删除与回收站的策略:先标删除、7 天后真删,给用户反悔期)。已发布的文档,其草稿在发布成功后立即删除——留着就是垃圾。
- schema 复用:草稿表和正式内容表尽量用同一套
content结构。AI 生成的代码常犯的错是草稿一套格式、正式内容另一套格式,发布时还要"转换"——转换就是 bug 的温床。 - 配额:一人团队初期不用限,但表结构里留个
user_id维度的计数字段。等有人用草稿当网盘时,你才有手段限。
富文本/图片草稿特例
纯文本草稿好做,带图片的草稿有两个坑:
- 没上传完的图片:用户粘贴了一张 5MB 的图,上传进度 30% 时关了页面。下次恢复草稿时,图片是个"上传中"的占位符——占位符必须能续传或重传,而不是裂图。实现:占位符里存本地 blob 的引用 ID,恢复时检查上传状态,未完成的重新触发上传。
- blob URL 转正:粘贴图片时浏览器给的是
blob:https://...临时 URL,关掉页面就失效。草稿保存时,必须把"已上传→转成永久 URL"这一步做完,草稿里只存永久 URL。这个"转正链路"和文件存储的上传链路复用,别自己再造一套。
Solo 上线检查清单
三个必测场景,测不过别上线:
- ☐ 断网测试:写 500 字 → 断网 → 继续写 500 字 → 恢复网络 → 确认 1000 字全在,且状态文案经历了"已离线→正在保存→已保存"
- ☐ 双标签页冲突测试:A 页写"111"保存,B 页(旧版本)写"222"保存 → 确认弹出"保留两份"选择,而不是静默覆盖
- ☐ 崩溃恢复测试:写 500 字 → 直接杀浏览器进程 → 重开 → 确认提示"恢复草稿"且内容完整
- ☐ 防抖参数按内容类型分档(短 300–500ms / 长 800ms–2s),"已保存"带时间戳
- ☐ 草稿保留 30 天 + 发布后删除的生命周期已实现
草稿自动保存是那种"做好了没人夸,做砸了被骂死"的功能。但它衡量的恰恰是你的产品对用户时间的尊重——用户写下的每个字,都值得被认真对待。2000 字的信任,丢一次就很难找回来。
评论 (0)
相关文章

AI 生成的暗色模式有三种固定套路的翻车:颜色写死散落各文件、切换时全屏闪白、系统偏好与用户选择打架。本指南给完整工程解法:语义化 CSS 变量(design token)作为唯一真相源、Tailwind v4 的 @custom-variant 接线、system/light/dark 三态状态机与 localStorage vs cookie 持久化规则、根治 SSR 首屏闪烁的 <head> 内联脚本(含 Next.js App Router 实操与量化验收),以及深水区——图片、canvas 图表、iframe 嵌入、WCAG AA 对比度抽查法,最后附硬编码颜色迁移的 5 步 SOP(含 AI 批量改写 prompt 与回归检查清单)。

prompt 是建议,不是约束。AI 结构化输出可靠性的四层防线:模型原生结构化输出(OpenAI vs Anthropic,官方文档核实)、zod 校验的严格/宽松取舍、把报错原文喂回的修复重试、按成本选的降级兜底。

功能开关把部署和发布解耦:代码先上,功能后开。四种开关类型与选型矩阵、一张表的最小实现、兜底铁律(新功能默认关、止血开关冻结)、开关腐烂治理,以及开关与灰度发布如何配合。