多窗口编辑与冲突
多窗口编辑与冲突 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
同一个浏览器开两个标签页改同一篇笔记,是单人应用最真实的并发场景。本课用「版本号乐观锁」检测冲突:后保存者收到明确提示,先留副本再人工合并——绝不静默覆盖已经落库的改动。
问题场景
你开了两个窗口:左边改开头,右边改结尾。右边先保存;几分钟后左边也点了保存——如果服务端直接落库,左边那份「基于旧版本」的整页内容就把右边的结尾改动无声覆盖了。这比崩溃更糟:它不报错,你甚至不知道丢了东西。
核心解释
解法是乐观锁:每条笔记带一个 revision 数字,读时拿到,保存时必须原样交回。服务端比对:对得上才写入,对不上说明期间有人改过,拒绝写入并返回当前版本。
// app/notes/actions.ts(节选)
"use server";
import { getDb, getNote, nowIso } from "@/lib/db/client";
export async function saveNote(input: {
id: string;
title: string;
body: string;
revision: number;
}) {
const result = getDb()
.prepare(
"UPDATE note SET title = ?, body = ?, revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ?"
)
.run(input.title, input.body, nowIso(), input.id, input.revision);
if (result.changes === 0) {
const current = getNote(input.id);
return {
ok: false as const,
conflict: true as const,
currentRevision: current?.revision ?? 0,
currentBody: current?.body ?? "",
};
}
revalidatePath("/notes");
return { ok: true as const };
}
注意冲突检测靠的是 UPDATE 的 WHERE 条件,而不是「先 SELECT 再比较」——changes === 0 就是原子判定,检查与写入之间不存在时间缝隙。
客户端收到冲突后不能自动覆盖,正确姿势是把两个版本都摆出来:
"use client";
if (result.conflict) {
setConflict({ mine: localBody, theirs: result.currentBody });
// 界面双栏展示「我的改动 / 服务端当前版本」,
// 用户合并后带着返回的新 revision 重新保存
}
策略定三条:
- 拒绝而不代合并:服务端没有义务猜你的意图,冲突必须由人裁决。
- 副本先落地:把本地编辑内容留在编辑器或草稿区,用户合并失败也不能丢字。
- 自动保存要克制:定时静默保存遇冲突必须停下来等确认,否则就是冲突的帮凶。
示例拆解
双窗口冲突复现(本机工程):
- 打开
/notes/[id]两个标签页,记下初始 revision(比如 3)。 - 窗口 A 改标题保存成功——库里 revision 变成 4。
- 窗口 B 不刷新,直接改正文保存——服务端比对发现 3 ≠ 4,返回冲突。
- 确认窗口 B 的本地改动没有丢:编辑器里内容还在,界面上能看到服务端当前版本。
- 窗口 B 把两边改动合并进正文,带着新 revision 再保存——成功。
关键点:冲突不是错误,是协作信号。UI 的责任是把「你这边基于旧版本」翻译成人话,而不是把 409 状态码甩给用户。
动手任务
在本机工程里完成:
- 给笔记更新逻辑加 revision 条件更新,验证 changes=0 时返回冲突结构。
- 双标签页复现完整冲突流,确认后保存者能带着新版本合并重存。
- 若工程里有自动保存,加「遇冲突暂停」逻辑,手动制造冲突验证不会静默覆盖。
常见错误
- 先查后写当乐观锁:SELECT 与 UPDATE 之间存在时间窗,两个请求可能同时通过检查;条件更新一条 SQL 才是原子判定。
- 冲突时直接拿服务端版本覆盖本地:后保存者的字丢了——这恰是要防的事故本身。
- revision 放在用户可编辑的字段里:被随手改掉,锁形同虚设;它应是随表单携带的隐藏字段。
- 只锁正文不锁标题:两个字段共用一个版本号,要么一起更新、要么都不更新,部分更新会绕过锁。
检查与复盘
自查清单:
- 双窗口同时编辑不再发生静默覆盖,冲突能复现也能恢复
- 冲突发生时本地副本始终有处可寻
- 说得出条件更新与「先查后写」在并发上的区别
参考资料
- React 文档:useOptimistic —— 乐观更新 UI 的官方 Hook
- Next.js 文档:Server Actions and Mutations —— Action 返回值与错误处理
- SQLite 文档:UPDATE 语法 —— 条件更新与 changes 计数
读完这一课了?
标记完成后,工作台会安排接下来的学习。