前言
效果演示:

上一章 :使用 Next.js + Prisma + MySQL 开发全栈项目
目录
| 章节 | 内容 |
|---|---|
| [关键技术说明](#章节 内容 关键技术说明 概念、双路径、持久化、与本仓库的对应关系 技术栈与职责 各技术角色速查表 整体架构 ASCII 示意图与读/写路径 按实现流程(代码示例) 1~11,与关键技术说明对应 总览流程图 Mermaid 简图 相关文档 仓库内其他说明 "#%E5%85%B3%E9%94%AE%E6%8A%80%E6%9C%AF%E8%AF%B4%E6%98%8E") | 概念、双路径、持久化、与本仓库的对应关系 |
| [技术栈与职责](#章节 内容 关键技术说明 概念、双路径、持久化、与本仓库的对应关系 技术栈与职责 各技术角色速查表 整体架构 ASCII 示意图与读/写路径 按实现流程(代码示例) 1~11,与关键技术说明对应 总览流程图 Mermaid 简图 相关文档 仓库内其他说明 "#%E6%8A%80%E6%9C%AF%E6%A0%88%E4%B8%8E%E8%81%8C%E8%B4%A3") | 各技术角色速查表 |
| [整体架构](#章节 内容 关键技术说明 概念、双路径、持久化、与本仓库的对应关系 技术栈与职责 各技术角色速查表 整体架构 ASCII 示意图与读/写路径 按实现流程(代码示例) 1~11,与关键技术说明对应 总览流程图 Mermaid 简图 相关文档 仓库内其他说明 "#%E6%95%B4%E4%BD%93%E6%9E%B6%E6%9E%84") | ASCII 示意图与读/写路径 |
| [按实现流程(代码示例)](#章节 内容 关键技术说明 概念、双路径、持久化、与本仓库的对应关系 技术栈与职责 各技术角色速查表 整体架构 ASCII 示意图与读/写路径 按实现流程(代码示例) 1~11,与关键技术说明对应 总览流程图 Mermaid 简图 相关文档 仓库内其他说明 "#%E6%8C%89%E5%AE%9E%E7%8E%B0%E6%B5%81%E7%A8%8B%E4%BB%A3%E7%A0%81%E7%A4%BA%E4%BE%8B") | 1~11,与关键技术说明对应 |
| [总览流程图](#章节 内容 关键技术说明 概念、双路径、持久化、与本仓库的对应关系 技术栈与职责 各技术角色速查表 整体架构 ASCII 示意图与读/写路径 按实现流程(代码示例) 1~11,与关键技术说明对应 总览流程图 Mermaid 简图 相关文档 仓库内其他说明 "#%E6%80%BB%E8%A7%88%E6%B5%81%E7%A8%8B%E5%9B%BE") | Mermaid 简图 |
| [相关文档](#章节 内容 关键技术说明 概念、双路径、持久化、与本仓库的对应关系 技术栈与职责 各技术角色速查表 整体架构 ASCII 示意图与读/写路径 按实现流程(代码示例) 1~11,与关键技术说明对应 总览流程图 Mermaid 简图 相关文档 仓库内其他说明 "#%E7%9B%B8%E5%85%B3%E6%96%87%E6%A1%A3") | 仓库内其他说明 |
关键技术说明
1. 两条数据路径(需先分清)
| 路径 | 载体 | 存什么 | 典型入口 |
|---|---|---|---|
| 元数据 | HTTP,Route Handlers,Prisma |
文档是否存在、title、列表与删除 |
GET/POST /api/docs、PATCH/DELETE /api/docs/:id |
| 正文协同 | WebSocket,Y 协议,Hocuspocus | 内存中的 Y.Doc 状态;库中仅 整文档二进制快照 yState |
HocuspocusProvider、server/hocuspocus.ts |
要点:多人同时打字时的增量同步不经过 MySQL ;只在 进房 (onLoadDocument)和 防抖落盘 (onStoreDocument)时读写 yState。
2. Yjs(CRDT)在做什么
- 无锁合并:多客户端并发编辑同一文档,用 CRDT 保证收敛到一致状态,而不是「谁后写谁覆盖」。
- 单一数据源 :协同模式下,正文的「真源」是共享的
Y.Doc;Tiptap 通过@tiptap/extension-collaboration把 ProseMirror 文档绑到这份Y.Doc。 - 持久化形态 :落库的不是 HTML 段落,而是
Y.encodeStateAsUpdate的二进制 ,读回时用Y.applyUpdate灌进内存文档。
3. Tiptap / ProseMirror
- 编辑器内核:按键、选区、段落结构由 ProseMirror 事务描述;Tiptap 提供扩展与 React 封装。
- 协同扩展 :
Collaboration负责与Y.Doc同步;CollaborationCaret+ Provider 的 Awareness 负责远程光标与显示名。 - 为何关掉 StarterKit 自带撤销 :协同下由 Yjs 统一状态,
undoRedo: false避免与 CRDT 双轨冲突。
昵称修改后如何出现在对方光标旁(与正文 Y 同步并行、协议不同)
- 用户在工具栏输入框改昵称 →
setDisplayName,同时写入localStorage(nameStorageKey区分多栏演示时的两个客户端)。 displayName或userColor(由pickColor(displayName)算出)变化后,useEffect调用provider.awareness.setLocalStateField("user", { name, color }),更新本机在 Awareness 里上报的协作身份。- Awareness 的变更经 WebSocket 由 Hocuspocus 广播 给同
documentId房间内的其他连接;不写入Y.Doc,也 不经过 MySQL。 - 对方实例上的
CollaborationCaret订阅同一provider的 Awareness,读取各远端连接上的user字段,重绘远程光标旁的标签(名字与颜色)。 CollaborationCaret.configure({ user: { name, color } })只在创建扩展时生效一次;故意不把displayName放进useEditor依赖,避免整编辑器反复挂载;昵称变更仅靠第 2 步的 Awareness 更新即可让对端看到新名字。
4. Hocuspocus(WebSocket 房间)
- 房间名 =
documentId:与数据库CollaborativeDoc.id一致,服务端才能onLoadDocument/onStoreDocument对上同一行。 - 进程独立 :
npm run hocuspocus跑server/hocuspocus.ts,与next dev分离;浏览器默认连ws://当前主机:1234(可用HOCUSPOCUS_PORT改端口)。 - 钩子 :
onLoadDocument从 MySQL 读yState注入内存;onStoreDocument在 debounce 后写回yState。
5. Next.js + Prisma + MySQL(在本仓库中的分工)
- Next :协同页用 服务端组件 做
collabDocs.findUnique,避免连不存在的文档;标题等仍走 REST。 - Prisma :Next 与 Hocuspocus 共用
DATABASE_URL与lib/prisma.ts;next.config.ts中serverExternalPackages避免 Prisma 被打进错误 bundle。 - MySQL :表
collaborative_docs中title与yState分离------标题可 REST 更新,正文快照仅由 Hocuspocus 钩子维护。
6. 多用户「A 编辑 → B 界面更新」
- A 侧:事务 →
Y.Doc_A→HocuspocusProvider发 Y 更新。 - 服务端:合并进 房间内存文档,广播给其他连接。
- B 侧:Provider 收包 →
Y.Doc_BCRDT 合并 →Collaboration驱动 Tiptap 重绘(非整页刷新)。
技术栈与职责
| 技术 | 职责 |
|---|---|
| Next.js | App Router:协同页服务端校验文档;Route Handlers 维护文档列表与标题 |
| Prisma + MySQL | 文档行存在性、title 字段、yState 二进制快照读写 |
| Tiptap / ProseMirror | 富文本编辑、事务与选区;格式工具条 |
| Yjs | CRDT 文档状态;Y.Doc 为单一数据源 |
| Hocuspocus | WebSocket 房间、转发 Y 协议消息;钩子中对接 Prisma |
依赖版本(摘录自 package.json) :yjs、@hocuspocus/provider、@hocuspocus/server、@tiptap/react、@tiptap/starter-kit、@tiptap/extension-collaboration、@tiptap/extension-collaboration-caret、@prisma/client 等。
整体架构
下图从部署与数据流 概括全链路。多人实时编辑 只走 WebSocket ↔ Hocuspocus 内存 Y.Doc ,不经过 MySQL ;MySQL 中的 yState 仅在 进房加载 (Y.applyUpdate)与 防抖落盘 (Y.encodeStateAsUpdate)时参与。文档 标题 等仍由 Next REST + Prisma 维护。
图 1 · 部署与持久化(自上而下)
bash
┌─────────────────────────────────────────┐
│ 浏览器(可多实例) │
│ │
│ ┌──────────┐ ┌───────┐ ┌──────────┐ │
│ │ Tiptap │◄─►│ Y.Doc │◄─►│ Provider│ │
│ └──────────┘ └───────┘ └────┬─────┘ │
│ │ ws │
└───────────────────────────────┼─────────┘
│
┌───────────────────────┴───────────────────────┐
▼ ▼
(HTTP:列表·创建·PATCH 标题) (WebSocket:Y 协议)
│ │
┌───────────────────────┐ ┌──────────────────┐
│ Next.js │ │ Hocuspocus │
│ · RSC 页 findUnique │ │ 独立 Node 进程 │
│ · RouteHandlers │ │ server/hocuspocus│
│ /api/docs · :id │ │ 房间 = documentId │
└───────────┬───────────┘ └────────┬─────────┘
│ │
│ Prisma │ 内存 Y.Doc ←→ 房间同步
│(title 等字段) │
│ │ onLoadDocument:
│ │ yState(BLOB)
│ │ → Y.applyUpdate
│ │ onStoreDocument:
│ │ Y.encodeStateAsUpdate
│ │ → 写回 yState(debounce)
└────────────────┬─────────────────────┘
▼
┌─────────────────────────────┐
│ MySQL · collaborative_docs │
│ title · yState(二进制快照) │
└─────────────────────────────┘
图 2 · 双用户实时编辑(用户 A 打 → 用户 B 页面更新) :这条横向 路径与上图垂直 栈正交------只发生在 两个浏览器 与 Hocuspocus 房间 之间,不经过 MySQL ;B 侧更新来自 Y 协议广播 + CRDT 合并,不是刷新整页 HTML。
css
用户 A(浏览器) Hocuspocus(同一 room = documentId) 用户 B(浏览器)
┌─────────────────────┐ ┌───────────────────────────┐ ┌─────────────────────┐
│ │ │ 房间内存 Y 文档 + 广播 │ │ │
│ 键盘 / 粘贴 │ │ │ │ Tiptap 重绘 │
│ │ │ │ │ │ ▲ │
│ ▼ │ │ │ │ │ │
│ Tiptap ↔ Y.Doc_A │──── WS ────►│ 合并入 canonical 状态 │─── WS ────►│ Y.Doc_B ↔ Tiptap │
│ Collaboration │ 发送更新 │ 转发给其他连接 │ 推送更新 │ Collaboration │
└─────────────────────┘ └───────────────────────────┘ └─────────────────────┘
说明:A 侧本地事务 → ProseMirror 写入 Y.Doc_A → HocuspocusProvider 把 Y 增量 发到服务端;服务端合并进房间文档并广播;B 的 Provider 收到后写入 Y.Doc_B (CRDT 合并),Collaboration 扩展再驱动 B 的 Tiptap 与 UI 更新。A、B 可同时编辑,方向对称(B 改 A 亦同)。
读路径(打开协同页) :RSC 用 Prisma 确认文档存在 → 客户端 Provider 建立 WebSocket → Hocuspocus onLoadDocument 若有 yState 则 Y.applyUpdate 注入内存文档 → Tiptap 与 Y.Doc 绑定后渲染。
写路径(编辑正文) :按键/粘贴进入 ProseMirror 事务 → Collaboration 扩展写入 Yjs → Provider 向房间广播 → 各端 CRDT 合并;服务端在 debounce 后 onStoreDocument 执行 Y.encodeStateAsUpdate 写回 yState。
与正交路径(仅元数据) :标题等仍走 PATCH /api/docs/:id,不经过 WebSocket。
实现流程
1:协同路由 --- 服务端用 Prisma 校验文档后渲染
服务端组件 async,用 collabDocs.findUnique 校验 id 在 collaborative_docs 中存在;否则 notFound()。客户端演示壳 CollabPlayground 接收 documentId(即房间名)。
tsx
// app/docs/(main)/[id]/collab/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { CollabPlayground } from "@/components/CollabPlayground";
import { collabDocs } from "@/lib/prisma";
export default async function DocCollabPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const doc = await collabDocs.findUnique({
where: { id },
select: { id: true, title: true },
});
if (!doc) notFound();
return (
<div className="docs-main-inner docs-collab-route">
<header className="site-header">
<Link href="/">首页</Link>
<span className="site-header-sep">/</span>
<Link href="/docs">协同文档</Link>
<span className="site-header-sep">/</span>
<Link href={`/docs/${doc.id}`}>{doc.title}</Link>
<span className="site-header-sep">/</span>
<span className="site-header-current">协同编辑</span>
</header>
<CollabPlayground documentId={doc.id} />
</div>
);
}
2:库表定义 --- 元数据与 Yjs 整文档快照
yState 存 Y.encodeStateAsUpdate 的二进制结果;title 与正文协同分离,标题通过 REST API 更新(见第 4 节)。
prisma
// prisma/schema.prisma(节选)
model CollaborativeDoc {
id String @id @default(cuid())
title String @db.VarChar(255)
/// Y.encodeStateAsUpdate 的二进制快照
yState Bytes? @db.LongBlob
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("collaborative_docs")
}
3:Prisma 单例、collabDocs 与 Next 打包配置
3.1 开发环境单例 + CollaborativeDoc 委托
collaborativeDoc 为 Prisma Client 生成的方法名;因 TS 泛型解析问题,用 any 桥接一次。
ts
// lib/prisma.ts(全文)
import { PrismaClient } from "@prisma/client";
export type AppPrismaClient = InstanceType<typeof PrismaClient>;
const globalForPrisma = globalThis as unknown as { prisma: AppPrismaClient | undefined };
export const prisma: AppPrismaClient = globalForPrisma.prisma ?? new PrismaClient();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const collabDocs = (prisma as any).collaborativeDoc;
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
3.2 避免 Prisma 被打进前端 bundle
ts
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@prisma/client", "prisma"],
};
export default nextConfig;
4:文档 CRUD(HTTP)--- 与协同正文解耦
协同正文不经这些接口逐字保存;它们负责列表、创建、改标题、删文档 。PATCH 被 DocEditorWithTitle 用于保存标题。
ts
// app/api/docs/route.ts
import { NextResponse } from "next/server";
import { collabDocs } from "@/lib/prisma";
export async function GET() {
const rows = await collabDocs.findMany({
orderBy: { updatedAt: "desc" },
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json(rows);
}
export async function POST(req: Request) {
let title = "未命名文档";
try {
const body = (await req.json()) as { title?: string };
if (typeof body.title === "string" && body.title.trim()) {
title = body.title.trim().slice(0, 255);
}
} catch {
/* empty body */
}
const doc = await collabDocs.create({
data: { title },
select: { id: true, title: true, createdAt: true, updatedAt: true },
});
return NextResponse.json(doc, { status: 201 });
}
ts
// app/api/docs/[id]/route.ts
import { NextResponse } from "next/server";
import { collabDocs } from "@/lib/prisma";
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(req: Request, ctx: Ctx) {
const { id } = await ctx.params;
let title: string | undefined;
try {
const body = (await req.json()) as { title?: string };
if (typeof body.title === "string" && body.title.trim()) {
title = body.title.trim().slice(0, 255);
}
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}
if (!title) {
return NextResponse.json({ error: "title_required" }, { status: 400 });
}
try {
const doc = await collabDocs.update({
where: { id },
data: { title },
select: { id: true, title: true, updatedAt: true },
});
return NextResponse.json(doc);
} catch {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
}
export async function DELETE(_req: Request, ctx: Ctx) {
const { id } = await ctx.params;
try {
await collabDocs.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
}
5:单文档页 --- 标题走 REST,正文走 CollaborativeEditor
tsx
// components/DocEditorWithTitle.tsx(节选:标题保存 + 协同编辑器)
const saveTitle = useCallback(
async (raw: string) => {
const trimmed = raw.trim() || "未命名文档";
if (trimmed === lastSaved.current) {
setSaveError(null);
return;
}
setSaving(true);
setSaveError(null);
try {
const res = await fetch(`/api/docs/${documentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: trimmed }),
});
if (!res.ok) {
setSaveError("保存失败,请重试");
return;
}
const data = (await res.json()) as { title: string };
lastSaved.current = data.title;
setTitle(data.title);
router.refresh();
window.dispatchEvent(new Event("collab-docs-refresh"));
} catch {
setSaveError("保存失败,请重试");
} finally {
setSaving(false);
}
},
[documentId, router],
);
// ...
<CollaborativeEditor
documentId={documentId}
surfaceClassName="docs-collab-prose-inner"
/>
6:浏览器侧 --- WebSocket 地址、Y.Doc、HocuspocusProvider、连接生命周期
tsx
// components/CollaborativeEditor.tsx(前半:状态、昵称、Y.Doc、Provider、订阅与销毁)
"use client";
import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider";
import { useEffect, useMemo, useState } from "react";
import * as Y from "yjs";
const DISPLAY_KEY_BASE = "collab-display-name";
// ... pickColor、props 等(见仓库)
const [wsUrl, setWsUrl] = useState<string | null>(null);
const [displayName, setDisplayName] = useState("");
const [syncError, setSyncError] = useState<string | null>(null);
useEffect(() => {
const url =
process.env.NEXT_PUBLIC_HOCUSPOCUS_URL ||
`ws://${typeof window !== "undefined" ? window.location.hostname : "127.0.0.1"}:1234`;
setWsUrl(url);
}, []);
useEffect(() => {
const existing = localStorage.getItem(displayStorageKey)?.trim();
if (existing) {
setDisplayName(existing);
return;
}
const name =
(defaultDisplayName && defaultDisplayName.trim()) ||
`访客-${Math.random().toString(36).slice(2, 7)}`;
localStorage.setItem(displayStorageKey, name);
setDisplayName(name);
}, [displayStorageKey, defaultDisplayName]);
const ydoc = useMemo(() => new Y.Doc(), [documentId]);
const provider = useMemo(() => {
if (!wsUrl) return null;
return new HocuspocusProvider({
url: wsUrl,
name: documentId,
document: ydoc,
token: process.env.NEXT_PUBLIC_HOCUSPOCUS_TOKEN,
});
}, [documentId, ydoc, wsUrl]);
useEffect(() => {
if (!provider) return;
const onStatus = (e: { status: WebSocketStatus }) => {
if (e.status === WebSocketStatus.Disconnected) {
setSyncError("已与协作服务断开,请确认 Hocuspocus 已启动。");
} else {
setSyncError(null);
}
};
const onClose = (e: { event: CloseEvent }) => {
if (e.event.code !== 1000) {
setSyncError(
e.event.reason || "连接已关闭,请检查文档是否存在且服务已启动。",
);
}
};
provider.on("status", onStatus);
provider.on("close", onClose);
return () => {
provider.off("status", onStatus);
provider.off("close", onClose);
provider.destroy();
};
}, [provider]);
7:Tiptap --- Collaboration / CollaborationCaret、依赖项注意点
协同模式关闭 StarterKit 自带 undoRedo;Collaboration 绑定 ydoc;CollaborationCaret 依赖同一 provider 做远程光标与用户名。
tsx
// components/CollaborativeEditor.tsx(useEditor 与 Awareness)
const userColor = useMemo(
() => pickColor(displayName || "anon"),
[displayName],
);
const editor = useEditor(
{
immediatelyRender: false,
extensions: [
StarterKit.configure({ undoRedo: false }),
TextStyle,
Color.configure({ types: ["textStyle"] }),
Underline,
Collaboration.configure({ document: ydoc }),
...(provider
? [
CollaborationCaret.configure({
provider,
user: {
name: displayName || "访客",
color: userColor,
},
}),
]
: []),
],
editorProps: {
attributes: {
class: "tiptap-editor",
},
},
},
// 勿把 displayName / userColor 放进 deps,否则会频繁销毁编辑器
[provider, ydoc],
);
useEffect(() => {
if (!provider?.awareness || !displayName) return;
provider.awareness.setLocalStateField("user", {
name: displayName,
color: userColor,
});
}, [provider, displayName, userColor]);
说明(昵称 → 对方光标) :输入框改昵称会触发上述 useEffect,把 { name, color } 写入 Awareness ;Hocuspocus 转发 Awareness 后,对端 CollaborationCaret 用远端 Awareness 里的 user 渲染光标标签。正文仍只走 Collaboration ↔ Y.Doc,两条线独立。
8:UI --- 加载态、昵称输入、工具条、EditorContent
tsx
// components/CollaborativeEditor.tsx(渲染片段)
if (!wsUrl || !provider) {
return (
<p className={compact ? "hint collab-loading--compact" : "hint"}>
正在准备协作连接...
</p>
);
}
return (
<div className={["collab-editor-wrap", compact ? "collab-editor-wrap--compact" : ""].filter(Boolean).join(" ")}>
<div className="collab-toolbar">
<label className={["collab-name-field", compact ? "collab-name-field--compact" : ""].filter(Boolean).join(" ")}>
<span>{compact ? "昵称" : "显示名称"}</span>
<input
type="text"
value={displayName}
onChange={(e) => {
const v = e.target.value.trim() || "访客";
setDisplayName(v);
localStorage.setItem(displayStorageKey, v);
}}
maxLength={32}
placeholder="协作时他人看到的名字"
/>
</label>
{syncError ? <span className="collab-sync-error">{syncError}</span> : null}
</div>
<EditorFormatToolbar editor={editor} compact={compact} />
<div className={["collab-prose", "card", surfaceClassName ?? ""].filter(Boolean).join(" ")}>
<EditorContent editor={editor} />
</div>
</div>
);
tsx
// components/EditorFormatToolbar.tsx(节选:用 useEditorState 驱动按钮高亮,用 chain 执行命令)
import type { Editor } from "@tiptap/core";
import { useEditorState } from "@tiptap/react";
export function EditorFormatToolbar({ editor, compact }: { editor: Editor | null; compact?: boolean }) {
const fmt = useEditorState({
editor,
selector: (snap) => {
const ed = snap.editor;
if (!ed) {
return { bold: false, italic: false, /* ... */ };
}
const attrs = ed.getAttributes("textStyle") as { color?: string };
return {
bold: ed.isActive("bold"),
italic: ed.isActive("italic"),
color: attrs?.color ?? "",
// ...
};
},
});
if (!editor || !fmt) return null;
return (
<div className="tiptap-fmt-bar" role="toolbar" aria-label="文本格式">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<strong>B</strong>
</button>
{/* ... 斜体、标题、颜色等 */}
</div>
);
}
9:本地双客户端 --- CollabPlayground 同房间、不同 nameStorageKey
同一 documentId 绑定两个 CollaborativeEditor,nameStorageKey 区分 localStorage 中的昵称键,避免左右栏抢同一键。
tsx
// components/CollabPlayground.tsx(核心结构)
"use client";
import { CollaborativeEditor } from "@/components/CollaborativeEditor";
export function CollabPlayground({ documentId }: { documentId: string }) {
return (
<div className="collab-playground">
<section className="collab-playground-hero" aria-label="协同编辑说明">
{/* ... 说明文案、房间 ID 展示 */}
</section>
<div className="collab-playground-split">
<article className="collab-playground-panel collab-playground-panel--a" aria-label="用户 1 编辑器">
<header className="collab-playground-panel-head">{/* ... */}</header>
<CollaborativeEditor
documentId={documentId}
nameStorageKey="share-demo-u1"
defaultDisplayName="用户1"
compact
surfaceClassName="collab-prose--playground"
/>
</article>
<article className="collab-playground-panel collab-playground-panel--b" aria-label="用户 2 编辑器">
<header className="collab-playground-panel-head">{/* ... */}</header>
<CollaborativeEditor
documentId={documentId}
nameStorageKey="share-demo-u2"
defaultDisplayName="用户2"
compact
surfaceClassName="collab-prose--playground"
/>
</article>
</div>
</div>
);
}
10:Hocuspocus 服务端 --- 环境变量、加载/保存、优雅退出
独立进程加载 .env / .env.local,与 Next 共用 DATABASE_URL。debounce: 2000 控制写库频率。documentName 与客户端 name: documentId 一致。
ts
// server/hocuspocus.ts(全文)
import { config } from "dotenv";
import { resolve } from "node:path";
import { Server } from "@hocuspocus/server";
import * as Y from "yjs";
import { collabDocs, prisma } from "../lib/prisma";
const root = process.cwd();
config({ path: resolve(root, ".env") });
config({ path: resolve(root, ".env.local"), override: true });
const port = Number(process.env.HOCUSPOCUS_PORT || "1234");
const server = new Server({
name: "next-collab",
port,
debounce: 2000,
async onLoadDocument({ documentName, document }) {
const row = await collabDocs.findUnique({
where: { id: documentName },
});
if (!row) {
throw new Error(`Unknown document: ${documentName}`);
}
if (row.yState && row.yState.length > 0) {
Y.applyUpdate(document, new Uint8Array(row.yState));
}
},
async onStoreDocument({ documentName, document }) {
const state = Y.encodeStateAsUpdate(document);
await collabDocs.update({
where: { id: documentName },
data: { yState: Buffer.from(state) },
});
},
});
void server.listen().then(() => {
console.log(
`[hocuspocus] listening on ws://127.0.0.1:${port} (persist → Prisma / DATABASE_URL)`,
);
});
async function shutdown() {
await server.destroy();
await prisma.$disconnect();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
11:本地运行脚本
json
{
"scripts": {
"dev": "next dev",
"hocuspocus": "tsx server/hocuspocus.ts",
"dev:collab": "concurrently -n next,hocus -c blue,magenta \"next dev\" \"npm run hocuspocus\""
}
}
| 变量 | 作用 |
|---|---|
DATABASE_URL |
Prisma 连接 MySQL(Next 与 Hocuspocus 共用) |
HOCUSPOCUS_PORT |
Hocuspocus 监听端口,默认 1234 |
未在 .env 中配置、本仓库服务端也未使用:NEXT_PUBLIC_HOCUSPOCUS_URL(缺省 ws://当前主机:1234)、NEXT_PUBLIC_HOCUSPOCUS_TOKEN(hocuspocus.ts 无鉴权)。
总览流程图
相关文档
- Tiptap --- Collaborative editing(本仓库采用自建 Hocuspocus,非 Tiptap Cloud)
- Yjs Documentation
- Hocuspocus Server
