Fabric.js Document Engine

搜索文档

查找页面或章节

EN

保存冲突

查看 Markdown

两个标签页编辑同一份文档时会怎样,以及用户怎么选择。

你打开文档之后,如果别人也保存了同一份文档,就会出现保存冲突。没有检查的话,最后一次保存会悄悄生效,另一个人的内容就没了。引擎用修订号来发现这种情况。

试一试

在 Tab A 里画点东西并保存,再在 Tab B 里画点东西并保存。Tab B 会出现冲突,由你决定保留哪边。

正在加载在线示例,代码在下方。

save-conflict-demo.tsx
"use client";
 
import {
  useDocumentEngine,
  useDocumentState,
} from "fabricjs-document-engine/react";
import { createMemoryStorage } from "fabricjs-document-engine/storage";
import {
  PencilIcon,
  RotateCcwIcon,
  SaveIcon,
  ShieldCheckIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
 
import { DemoCanvas, newShape, Status, ToolButton } from "./demo-ui";
import { useFabricCanvas } from "./use-fabric-canvas";
 
// Both editors save to the same storage, like two tabs of one app.
const storage = createMemoryStorage();
const DOCUMENT_ID = "conflict-demo";
const TONE = {
  saved: "ok",
  saving: "busy",
  unsaved: "idle",
  conflict: "error",
  error: "error",
} as const;
 
function Tab({ name, color }: { name: string; color: string }) {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const engine = useDocumentEngine(canvas, {
    storage,
    document: { id: DOCUMENT_ID },
  });
  const state = useDocumentState(engine);
  const status = state?.saveStatus ?? "saved";
  const conflict = status === "conflict";
 
  return (
    <div className="flex min-w-0 flex-col gap-2">
      <p className="text-[13px] font-medium">{t("conflict.tab", { name })}</p>
      <DemoCanvas elementRef={elementRef} label={t("conflict.tab", { name })} />
 
      {/* The conflict actions are always there, so the layout never jumps. */}
      <div className="grid grid-cols-2 gap-1.5">
        <ToolButton
          icon={PencilIcon}
          label={t("conflict.draw")}
          onClick={() => canvas?.add(newShape("square", color))}
        />
        <ToolButton
          icon={SaveIcon}
          label={t("conflict.save")}
          primary
          disabled={!state?.isDirty || conflict}
          onClick={() => void engine?.save().catch(() => undefined)}
        />
        <ToolButton
          icon={RotateCcwIcon}
          label={t("conflict.reload")}
          disabled={!conflict}
          onClick={() =>
            void engine?.load(DOCUMENT_ID, { discardUnsavedChanges: true })
          }
        />
        <ToolButton
          icon={ShieldCheckIcon}
          label={t("conflict.keepMine")}
          disabled={!conflict}
          onClick={() => void engine?.save({ overwrite: true })}
        />
      </div>
 
      <Status tone={TONE[status]}>
        {t(`conflict.status.${status}`, { revision: state?.revision ?? 0 })}
      </Status>
    </div>
  );
}
 
export default function SaveConflictDemo() {
  return (
    <div className="grid w-full max-w-md grid-cols-2 gap-3 text-left">
      <Tab name="A" color="#f2836f" />
      <Tab name="B" color="#6f00ff" />
    </div>
  );
}

修订号怎么工作

每份保存过的文档都有一个 revision(修订号)。引擎保存时,会通过 expectedRevision 告诉存储适配器它上次看到的修订号。如果存储里的修订号不同,说明中间有人保存过,适配器就以 SAVE_CONFLICT 拒绝。

内置适配器已经这样做了。你自己的后端请看自定义后端。

处理保存冲突

保存会以 SAVE_CONFLICT 失败,status 变为 conflict。冲突永远不会重试。问用户要怎么做:

engine.on("save:error", async ({ error }) => {
  if (error.code !== "SAVE_CONFLICT") return;
 
  const keepMine = await askUser("Someone else saved this document.", [
    "Keep my version",
    "Load their version",
  ]);
 
  if (keepMine) {
    await engine.save({ overwrite: true });
  } else {
    const { id } = engine.getDocumentInfo();
    await engine.load(id, { discardUnsavedChanges: true });
  }
});
  • save({ overwrite: true }) 跳过检查并覆盖。你的存储会收到 expectedRevision: null。
  • load(id, { discardUnsavedChanges: true }) 丢掉你的改动,打开已保存的版本。

同一个人的多个标签页

大多数冲突来自同一个人开了多个标签页,而不是两个人。同一套检查两种情况都能覆盖。如果只开一个标签页也出现保存冲突,说明你的适配器没有返回新的修订号。请在 saveDocument 里返回 { revision },或者保存 document.revision。

崩溃之后

从恢复中还原的文档,会保留它当初基于的修订号。如果期间服务器上的版本已经更新,下一次保存会报冲突,而不是覆盖更新的内容。