实时协作
从 Next.js 接入、协作 API、Resync、快照、参数到 ProseMirror Step 原理的实时协作指南。
实时协作
SVeditor 的协作模式让多个编辑器实例同时编辑同一份文档。它基于 ProseMirror Step 同步 和 HTTP 轮询,不要求你部署 WebSocket。
最常见的接入路径是:
- 服务端保存一份权威文档状态:
doc、version、steps、clientIDs、historyStart。 - 实现协作接口:
doc、steps、events,可选实现version、reset和snapshots。 - 客户端先拉取完整文档和版本号,再创建
SVeditor,并传入extensionsOptions.collaboration。 - 如果服务端返回 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 解析 doc 和 steps。服务端或其他协作参与方请安装共享包:
pnpm add @wztlink1013/sveditor-collab-schemaimport {
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.tsdoc 接口返回完整文档和当前版本,客户端首次进入协作前也应使用它来 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 }));
}version 和 reset 是辅助接口:
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 /version 和 POST /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 必须不同,但 docId、content 和 initialVersion 必须来自同一次 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.apiUrl 和 collaboration.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
}客户端首次创建编辑器前应使用这份响应作为 content 和 initialVersion 的来源。
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/restore | RestoreData |
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.onSaveContent 和 collaboration 保存。协作正文应由协作服务维护;标题、Emoji、封面等元数据可以单独用 extensionsOptions.meta 保存。
注意事项
apiUrl是前缀。同源 Next.js 项目用"/";空字符串会禁用协作。- 初始化时要同时传
content和initialVersion,并确保它们来自同一次/collab/doc响应。 - 同一文档内每个连接的
clientID应唯一。一个用户开两个标签页时,也建议加不同后缀。 - 协作依赖持续联网。离线编辑不会在 SDK 内排队。
- 版本冲突、Step 解析失败、权限失败都应进入错误状态。生产接口要有明确的鉴权、文档权限和输入校验。
- 如果启用 Step 历史压缩,必须实现 Resync;否则老客户端会无法追上服务端状态。