SVeditor 文档
Persistence

实时协作

从 Next.js 接入、协作 API、Resync、快照、参数到 ProseMirror Step 原理的实时协作指南。

实时协作

SVeditor 的协作模式让多个编辑器实例同时编辑同一份文档。它基于 ProseMirror Step 同步HTTP 轮询,不要求你部署 WebSocket。

最常见的接入路径是:

  1. 服务端保存一份权威文档状态:docversionstepsclientIDshistoryStart
  2. 实现协作接口:docstepsevents,可选实现 versionresetsnapshots
  3. 客户端先拉取完整文档和版本号,再创建 SVeditor,并传入 extensionsOptions.collaboration
  4. 如果服务端返回 Resync,客户端销毁并重建编辑器,用服务端完整文档重新进入协作。

下面的示例假设你已经有一个 Next.js App Router 项目,并且已经能导入 SVeditor。这里不重复讲如何创建 Next.js 项目。

在 Next.js 项目中接入

1. 先理解 SDK 会请求哪些地址

extensionsOptions.collaboration.apiUrl 是协作 API 的前缀,不是完整的 /collab 地址。SDK 会拼出这些 URL:

GET  {apiUrl}/api/docs/{docId}/collab/version
GET  {apiUrl}/api/docs/{docId}/collab/doc
POST {apiUrl}/api/docs/{docId}/collab/steps
GET  {apiUrl}/api/docs/{docId}/collab/events?since={version}
POST {apiUrl}/api/docs/{docId}/collab/reset

如果你的 Next.js API 就在同一个站点下,例如 /api/docs/:docId/collab/doc,客户端可以传:

collaboration: {
  enabled: true,
  docId,
  apiUrl: "/",
}

不要传 apiUrl: ""。空字符串会让协作扩展禁用。若服务端在另一个域名或子路径下,则传类似 https://api.example.com/sdk-demo 的前缀。

2. 安装服务端共享 Schema

协作服务端需要用和编辑器一致的 ProseMirror schema 解析 docsteps。服务端或其他协作参与方请安装共享包:

pnpm add @wztlink1013/sveditor-collab-schema
import {
  getSveditorCollabSchema,
  ProseMirrorNode,
  Step,
} from "@wztlink1013/sveditor-collab-schema";

const schema = getSveditorCollabSchema();

const doc = ProseMirrorNode.fromJSON(schema, incomingDocJson);
const step = Step.fromJSON(schema, incomingStepJson);

所有协作参与方必须使用同一版本的 schema 包。否则客户端产生的 Step 可能无法在服务端反序列化或应用。

3. 准备服务端协作状态

真实项目里应把状态放到数据库、Redis 或你的协作服务中。为了先跑通流程,下面用内存 Map 模拟一个最小 Authority。可以把它放在 lib/collab-memory.ts

import {
  getSveditorCollabSchema,
  ProseMirrorNode,
  Step,
} from "@wztlink1013/sveditor-collab-schema";

const MAX_STEPS_WINDOW = 100;

const emptyDoc = {
  type: "doc",
  content: [{ type: "paragraph" }],
} satisfies Record<string, unknown>;

interface CollabState {
  doc: Record<string, unknown>;
  baseDoc: Record<string, unknown>;
  version: number;
  historyStart: number;
  steps: Record<string, unknown>[];
  clientIDs: Array<string | number>;
}

const documents = new Map<string, CollabState>();

function cloneDoc(doc: Record<string, unknown>) {
  return structuredClone(doc);
}

function getOrCreateState(docId: string): CollabState {
  const existing = documents.get(docId);

  if (existing) {
    return existing;
  }

  const doc = cloneDoc(emptyDoc);
  const state: CollabState = {
    doc,
    baseDoc: cloneDoc(doc),
    version: 0,
    historyStart: 0,
    steps: [],
    clientIDs: [],
  };

  documents.set(docId, state);
  return state;
}

function compactHistory(state: CollabState) {
  if (state.steps.length <= MAX_STEPS_WINDOW) {
    return;
  }

  const schema = getSveditorCollabSchema();
  const overflow = state.steps.length - MAX_STEPS_WINDOW;
  let baseDoc = ProseMirrorNode.fromJSON(schema, state.baseDoc);

  for (let index = 0; index < overflow; index += 1) {
    const step = Step.fromJSON(schema, state.steps[index]);
    const result = step.apply(baseDoc);

    if (result.failed || !result.doc) {
      throw new Error(result.failed || "Failed to compact collaboration steps");
    }

    baseDoc = result.doc;
  }

  state.baseDoc = baseDoc.toJSON() as Record<string, unknown>;
  state.steps = state.steps.slice(overflow);
  state.clientIDs = state.clientIDs.slice(overflow);
  state.historyStart += overflow;
}

export function getCollabDoc(docId: string) {
  const state = getOrCreateState(docId);

  return {
    doc: state.doc,
    version: state.version,
  };
}

export function applyCollabSteps(input: {
  docId: string;
  version: number;
  steps: Record<string, unknown>[];
  clientID?: string | number;
}) {
  const state = getOrCreateState(input.docId);

  if (input.version !== state.version) {
    return {
      accepted: false,
      version: state.version,
      error: `Version mismatch: client ${input.version}, server ${state.version}`,
    };
  }

  if (input.steps.length === 0) {
    return {
      accepted: true,
      version: state.version,
    };
  }

  const schema = getSveditorCollabSchema();
  let pmDoc = ProseMirrorNode.fromJSON(schema, state.doc);
  const appliedSteps: Record<string, unknown>[] = [];
  const appliedClientIDs: Array<string | number> = [];

  for (const stepJson of input.steps) {
    const step = Step.fromJSON(schema, stepJson);
    const result = step.apply(pmDoc);

    if (result.failed || !result.doc) {
      return {
        accepted: false,
        version: state.version,
        error: result.failed || "Failed to apply step",
      };
    }

    pmDoc = result.doc;
    appliedSteps.push(stepJson);
    appliedClientIDs.push(input.clientID ?? "anonymous");
  }

  state.doc = pmDoc.toJSON() as Record<string, unknown>;
  state.steps.push(...appliedSteps);
  state.clientIDs.push(...appliedClientIDs);
  state.version += appliedSteps.length;
  compactHistory(state);

  return {
    accepted: true,
    version: state.version,
  };
}

export function getCollabEvents(input: { docId: string; since: number }) {
  const state = getOrCreateState(input.docId);

  if (input.since < 0 || input.since >= state.version) {
    return null;
  }

  if (input.since < state.historyStart) {
    return {
      resync: true,
      doc: state.doc,
      version: state.version,
    };
  }

  const offset = input.since - state.historyStart;

  return {
    steps: state.steps.slice(offset),
    clientIDs: state.clientIDs.slice(offset),
    version: state.version,
  };
}

export function resetCollabDoc(docId: string, doc = cloneDoc(emptyDoc)) {
  documents.set(docId, {
    doc,
    baseDoc: cloneDoc(doc),
    version: 0,
    historyStart: 0,
    steps: [],
    clientIDs: [],
  });

  return { success: true, version: 0 };
}

内存版只适合本地验证。生产环境至少需要按用户和文档做权限校验,并把状态持久化到可靠存储。

4. 实现协作 API

在 Next.js App Router 中,可以按下面的文件结构放置接口:

app/api/docs/[docId]/collab/version/route.ts
app/api/docs/[docId]/collab/doc/route.ts
app/api/docs/[docId]/collab/steps/route.ts
app/api/docs/[docId]/collab/events/route.ts
app/api/docs/[docId]/collab/reset/route.ts

doc 接口返回完整文档和当前版本,客户端首次进入协作前也应使用它来 bootstrap:

import { NextResponse } from "next/server";
import { getCollabDoc } from "@/lib/collab-memory";

export async function GET(
  _request: Request,
  context: { params: Promise<{ docId: string }> },
) {
  const { docId } = await context.params;
  return NextResponse.json(getCollabDoc(docId));
}

steps 接口接收客户端提交的 Step。建议版本冲突也返回 JSON,方便客户端拿到服务端当前版本和错误原因:

import { NextResponse } from "next/server";
import { applyCollabSteps } from "@/lib/collab-memory";

export async function POST(
  request: Request,
  context: { params: Promise<{ docId: string }> },
) {
  const { docId } = await context.params;
  const body = (await request.json()) as {
    version: number;
    steps: Record<string, unknown>[];
    clientID?: string | number;
  };

  return NextResponse.json(
    applyCollabSteps({
      docId,
      version: body.version,
      steps: body.steps,
      clientID: body.clientID,
    }),
  );
}

events 接口根据客户端当前版本返回增量 Step,或者返回 Resync 包:

import { NextResponse } from "next/server";
import { getCollabEvents } from "@/lib/collab-memory";

export async function GET(
  request: Request,
  context: { params: Promise<{ docId: string }> },
) {
  const { docId } = await context.params;
  const url = new URL(request.url);
  const since = Number(url.searchParams.get("since") ?? "0");

  return NextResponse.json(getCollabEvents({ docId, since }));
}

versionreset 是辅助接口:

import { NextResponse } from "next/server";
import { getCollabDoc, resetCollabDoc } from "@/lib/collab-memory";

export async function GET(
  _request: Request,
  context: { params: Promise<{ docId: string }> },
) {
  const { docId } = await context.params;
  return NextResponse.json({ version: getCollabDoc(docId).version });
}

export async function POST(
  request: Request,
  context: { params: Promise<{ docId: string }> },
) {
  const { docId } = await context.params;
  const body = (await request.json().catch(() => ({}))) as {
    doc?: Record<string, unknown>;
  };

  return NextResponse.json(resetCollabDoc(docId, body.doc));
}

实际放文件时,GET /versionPOST /reset 分别放在自己的 route.ts 中。

5. 创建客户端协作编辑器

协作模式下,客户端要先拉取完整文档和版本号,再创建编辑器。不要只配置 collaboration 就直接挂载空编辑器,否则本地初始内容和服务端版本可能不一致。

"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { SVeditor } from "@wztlink1013/sveditor";
import type {
  CollabStatus,
  EditorInstance,
  EditorOptions,
} from "@wztlink1013/sveditor";
import "@wztlink1013/sveditor/style.css";

type EditorInitOptions = Omit<EditorOptions, "el">;

interface CollabBootstrap {
  doc: Record<string, unknown>;
  version: number;
}

interface CollabEditorProps {
  docId: string;
  userId: string;
}

async function loadCollabDoc(docId: string): Promise<CollabBootstrap> {
  const response = await fetch(`/api/docs/${docId}/collab/doc`, {
    cache: "no-store",
  });

  if (!response.ok) {
    throw new Error(`Failed to load collaborative document: ${response.status}`);
  }

  return (await response.json()) as CollabBootstrap;
}

export function CollabEditor({ docId, userId }: CollabEditorProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const editorRef = useRef<EditorInstance | null>(null);
  const [bootstrap, setBootstrap] = useState<CollabBootstrap | null>(null);
  const [status, setStatus] = useState<CollabStatus>("disconnected");
  const [error, setError] = useState<string | null>(null);
  const [resyncContent, setResyncContent] = useState<string | null>(null);
  const [resyncVersion, setResyncVersion] = useState<number | null>(null);
  const [editorKey, setEditorKey] = useState(0);

  useEffect(() => {
    let cancelled = false;

    setBootstrap(null);
    setError(null);
    setResyncContent(null);
    setResyncVersion(null);
    setEditorKey((current) => current + 1);

    void loadCollabDoc(docId)
      .then((data) => {
        if (!cancelled) {
          setBootstrap(data);
        }
      })
      .catch((reason) => {
        if (!cancelled) {
          setError(reason instanceof Error ? reason.message : "Load failed");
        }
      });

    return () => {
      cancelled = true;
    };
  }, [docId]);

  const options = useMemo<EditorInitOptions | null>(() => {
    if (!bootstrap) {
      return null;
    }

    return {
      content: resyncContent ?? JSON.stringify(bootstrap.doc),
      extensionsOptions: {
        collaboration: {
          enabled: true,
          docId,
          apiUrl: "/",
          clientID: userId,
          initialVersion: resyncVersion ?? bootstrap.version,
          pollInterval: 800,
          timeout: 5000,
          onStatusChange: setStatus,
          onSyncError: (syncError) => {
            setError(syncError.message);
          },
          onResyncRequired: (doc, version) => {
            editorRef.current?.destroy();
            setResyncContent(JSON.stringify(doc.toJSON()));
            setResyncVersion(version);
            setEditorKey((current) => current + 1);
          },
        },
      },
      onCreate: (instance) => {
        editorRef.current = instance;
      },
      onDestroy: () => {
        editorRef.current = null;
      },
    };
  }, [bootstrap, docId, resyncContent, resyncVersion, userId]);

  useEffect(() => {
    if (!containerRef.current || !options) {
      return;
    }

    const editor = SVeditor.create({
      el: containerRef.current,
      ...options,
    });

    return () => editor.destroy();
  }, [editorKey, options]);

  if (error) {
    return <p>协作加载或同步失败:{error}</p>;
  }

  if (!bootstrap) {
    return <p>加载协作文档中...</p>;
  }

  return (
    <section className="collab-editor">
      <div className="collab-editor__status">协作状态:{status}</div>
      <div key={editorKey} ref={containerRef} style={{ minHeight: 640 }} />
    </section>
  );
}

6. 在页面里使用

页面只需要传入文档 ID 和当前用户 ID。真实项目中,用户 ID 应来自你的登录会话:

import { CollabEditor } from "@/components/collab-editor";

export default async function Page({
  params,
}: {
  params: Promise<{ docId: string }>;
}) {
  const { docId } = await params;
  const userId = "user-123";

  return <CollabEditor docId={docId} userId={userId} />;
}

同一份 docId 在两个浏览器窗口中打开,就可以看到 Step 通过轮询同步。

常见接入模式

同一个页面放两个协作编辑器

用于本地验证时,可以在同一页挂两个实例。重点是:两个连接的 clientID 必须不同,但 docIdcontentinitialVersion 必须来自同一次 bootstrap。

const editorA = SVeditor.create({
  el: "#editor-a",
  content: JSON.stringify(bootstrap.doc),
  extensionsOptions: {
    collaboration: {
      enabled: true,
      docId,
      apiUrl: "/",
      clientID: `${userId}-a`,
      initialVersion: bootstrap.version,
    },
  },
});

const editorB = SVeditor.create({
  el: "#editor-b",
  content: JSON.stringify(bootstrap.doc),
  extensionsOptions: {
    collaboration: {
      enabled: true,
      docId,
      apiUrl: "/",
      clientID: `${userId}-b`,
      initialVersion: bootstrap.version,
    },
  },
});

协作正文 + 单独保存元数据

协作模式负责正文同步,不建议同时给同一份正文再开 autoSave.onSaveContent。标题、Emoji、封面等文档元数据可以继续走 extensionsOptions.meta

SVeditor.create({
  el: "#editor",
  content: JSON.stringify(bootstrap.doc),
  extensionsOptions: {
    collaboration: {
      enabled: true,
      docId,
      apiUrl: "/",
      clientID: userId,
      initialVersion: bootstrap.version,
    },
    meta: {
      onFetchMeta: () => fetchDocMeta(docId),
      onSaveMeta: (meta) => saveDocMeta(docId, meta),
      onMetaChange: (meta) => setTitlePreview(meta.name ?? ""),
    },
  },
});

搭配版本历史

versionHistory 可以和协作同时启用。当前 SDK 会根据 collaboration.apiUrlcollaboration.docId 自动派生快照接口,不需要在 versionHistory 里传 collabConfig

SVeditor.create({
  el: "#editor",
  content: JSON.stringify(bootstrap.doc),
  extensionsOptions: {
    collaboration: {
      enabled: true,
      docId,
      apiUrl: "/",
      clientID: userId,
      initialVersion: bootstrap.version,
    },
    versionHistory: {
      enabled: true,
      autoSnapshot: {
        enabled: true,
        debounceMs: 15000,
      },
    },
  },
});

这会访问:

GET    {apiUrl}/api/docs/{docId}/snapshots
POST   {apiUrl}/api/docs/{docId}/snapshots
DELETE {apiUrl}/api/docs/{docId}/snapshots/{snapshotId}
GET    {apiUrl}/api/docs/{docId}/snapshots/{snapshotId}/restore
DELETE {apiUrl}/api/docs/{docId}/snapshots

示例:同一文档挂载多个编辑器

本地验证协作时,用相同 docId 挂载两个实例,并将 apiUrl 指向你的后端前缀:

const sharedExtensions = {
  collaboration: {
    enabled: true,
    docId,
    apiUrl: "https://api.example.com", // 或通过代理使用 "/api"
    pollInterval: 2000,
  },
};

SVeditor.create({
  el: "#editor-a",
  content: bootstrap.content,
  extensionsOptions: {
    ...sharedExtensions,
    collaboration: {
      ...sharedExtensions.collaboration,
      clientID: `${userId}-a`,
      initialVersion: bootstrap.version,
    },
  },
});

请求路径为 {apiUrl}/api/docs/{docId}/collab/*,见上文 API 说明。

参数配置

extensionsOptions.collaboration

interface CollaborationExtensionOptions {
  enabled: boolean;
  docId: string;
  apiUrl: string;
  timeout?: number;
  clientID?: string | number;
  pollInterval?: number;
  initialVersion?: number;
  onSynced?: () => void;
  onSyncError?: (error: Error) => void;
  onStatusChange?: (status: CollabStatus) => void;
  onResyncRequired?: (doc: ProseMirrorNode, version: number) => void;
}
参数什么时候用说明
enabled开启协作时false 时不会启用协作扩展。
docId每一份协作文档都需要同一 docId 的编辑器实例会同步同一份文档。
apiUrl指向协作 API 前缀SDK 会追加 /api/docs/{docId}/collab/*。同源 Next.js 可用 "/"
timeout网络不稳定或接口可能卡住时单个请求超时,默认 5000ms
clientID需要区分用户或标签页时建议用用户 ID 加标签页后缀;同一文档里的连接应唯一。
pollInterval控制同步频率最小按 500ms 处理;空闲和后台标签页会自动退避。
initialVersion用服务端文档 bootstrap 时应与 content 对应同一次 /doc 响应。
onSynced需要记录同步成功时拉取远端 Step 或确认本地 Step 后触发。
onSyncError需要错误提示或埋点时网络错误、解析错误、Step 被拒绝都会进入这里。
onStatusChange页面需要展示协作状态时接收 CollabStatus
onResyncRequired服务端历史窗口压缩后必须销毁并用返回的完整 doc/version 重建编辑器。

CollabStatus

type CollabStatus =
  | "disconnected"
  | "connecting"
  | "connected"
  | "syncing"
  | "error";
状态含义
disconnected尚未启动或已经停止轮询。
connecting正在启动并执行初始同步。
connected已连接,最近一轮同步正常。
syncing正在推送本地 Step。
error同步请求、Step 应用或服务端响应失败。

后端接口要求

GET /api/docs/:docId/collab/version

返回当前版本号:

{
  "version": 42
}

GET /api/docs/:docId/collab/doc

返回完整 ProseMirror 文档和当前版本号:

{
  "doc": { "type": "doc", "content": [] },
  "version": 42
}

客户端首次创建编辑器前应使用这份响应作为 contentinitialVersion 的来源。

POST /api/docs/:docId/collab/steps

提交客户端本地 Step:

{
  "version": 42,
  "steps": [{ "stepType": "replace" }],
  "clientID": "user-123"
}

接受时返回:

{
  "accepted": true,
  "version": 43
}

拒绝时返回:

{
  "accepted": false,
  "version": 44,
  "error": "Version mismatch: client 42, server 44"
}

GET /api/docs/:docId/collab/events?since=42

没有新事件时返回 null

null

有增量 Step 时返回:

{
  "steps": [{ "stepType": "replace" }],
  "clientIDs": ["user-456"],
  "version": 43
}

如果客户端版本早于服务端保留的 Step 窗口,返回 Resync:

{
  "resync": true,
  "doc": { "type": "doc", "content": [] },
  "version": 100
}

快照接口

启用 versionHistory 时,还需要实现快照接口:

方法路径返回
GET/api/docs/:docId/snapshots{ snapshots: CollabSnapshot[] }
POST/api/docs/:docId/snapshots{ snapshot: CollabSnapshot }
DELETE/api/docs/:docId/snapshots/:snapshotId{ success: true }404
GET/api/docs/:docId/snapshots/:snapshotId/restoreRestoreData
DELETE/api/docs/:docId/snapshots{ success: true }

工作原理

Step 和版本号

ProseMirror Step 是一次原子文档变更,例如插入文字、删除节点或修改格式。协作同步不是反复传全文,而是在客户端之间交换 Step。

服务端维护一个单调递增的 version。每接受一个 Step,version 加 1。客户端提交 Step 时必须带上自己基于的版本号,服务端只接受和当前版本一致的提交。

同步循环

每轮同步大致是:

客户端读取本地协作版本
  |
  |-- GET /events?since=version
  |     |-- 返回 steps:应用远端 Step
  |     |-- 返回 null:没有远端变化
  |     |-- 返回 resync:停止轮询,触发 onResyncRequired
  |
  |-- 检查本地是否有待发送 Step
        |-- 有:POST /steps
        |-- 无:等待下一轮轮询

用户本地一产生可发送 Step,SDK 会唤醒同步循环,不必等到下一次固定轮询。长时间没有变化时,轮询会自动退避;浏览器标签页进入后台时也会降低频率。

Step 窗口和 Resync

服务端通常不会永久保留所有 Step。常见做法是只保留最近 N 条,把更早的 Step 折叠进 baseDoc。当客户端离线太久,请求的 since 早于 historyStart 时,服务端已经无法返回完整增量,只能返回 Resync:

客户端版本 < historyStart
  |
  |-- 服务端返回 { resync: true, doc, version }
  |
  |-- 客户端销毁编辑器
  |-- 用 doc 作为 content 重建
  |-- 用 version 作为 initialVersion 重建

为什么不是自动保存

自动保存适合单人或非协作正文持久化:用户编辑后,把最新正文写回服务端。协作模式适合多人同时编辑:服务端按 Step 仲裁顺序,并向其他客户端广播增量。

同一份正文不要同时用 autoSave.onSaveContentcollaboration 保存。协作正文应由协作服务维护;标题、Emoji、封面等元数据可以单独用 extensionsOptions.meta 保存。

注意事项

  1. apiUrl 是前缀。同源 Next.js 项目用 "/";空字符串会禁用协作。
  2. 初始化时要同时传 contentinitialVersion,并确保它们来自同一次 /collab/doc 响应。
  3. 同一文档内每个连接的 clientID 应唯一。一个用户开两个标签页时,也建议加不同后缀。
  4. 协作依赖持续联网。离线编辑不会在 SDK 内排队。
  5. 版本冲突、Step 解析失败、权限失败都应进入错误状态。生产接口要有明确的鉴权、文档权限和输入校验。
  6. 如果启用 Step 历史压缩,必须实现 Resync;否则老客户端会无法追上服务端状态。