🔥🔥🔥Next + Tiptap + Yjs + Hocuspocus实现文档协同

前言

效果演示

上一章使用 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/docsPATCH/DELETE /api/docs/:id
正文协同 WebSocket,Y 协议,Hocuspocus 内存中的 Y.Doc 状态;库中仅 整文档二进制快照 yState HocuspocusProviderserver/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 同步并行、协议不同)

  1. 用户在工具栏输入框改昵称 → setDisplayName,同时写入 localStoragenameStorageKey 区分多栏演示时的两个客户端)。
  2. displayNameuserColor(由 pickColor(displayName) 算出)变化后,useEffect 调用 provider.awareness.setLocalStateField("user", { name, color }),更新本机在 Awareness 里上报的协作身份。
  3. Awareness 的变更经 WebSocket 由 Hocuspocus 广播 给同 documentId 房间内的其他连接;不写入 Y.Doc,也 不经过 MySQL
  4. 对方实例上的 CollaborationCaret 订阅同一 provider 的 Awareness,读取各远端连接上的 user 字段,重绘远程光标旁的标签(名字与颜色)。
  5. CollaborationCaret.configure({ user: { name, color } }) 只在创建扩展时生效一次;故意不把 displayName 放进 useEditor 依赖,避免整编辑器反复挂载;昵称变更仅靠第 2 步的 Awareness 更新即可让对端看到新名字。

4. Hocuspocus(WebSocket 房间)

  • 房间名 = documentId :与数据库 CollaborativeDoc.id 一致,服务端才能 onLoadDocument / onStoreDocument 对上同一行。
  • 进程独立npm run hocuspocusserver/hocuspocus.ts,与 next dev 分离;浏览器默认连 ws://当前主机:1234(可用 HOCUSPOCUS_PORT 改端口)。
  • 钩子onLoadDocument 从 MySQL 读 yState 注入内存;onStoreDocumentdebounce 后写回 yState

5. Next.js + Prisma + MySQL(在本仓库中的分工)

  • Next :协同页用 服务端组件collabDocs.findUnique,避免连不存在的文档;标题等仍走 REST
  • Prisma :Next 与 Hocuspocus 共用 DATABASE_URLlib/prisma.tsnext.config.tsserverExternalPackages 避免 Prisma 被打进错误 bundle。
  • MySQL :表 collaborative_docstitleyState 分离------标题可 REST 更新,正文快照仅由 Hocuspocus 钩子维护。

6. 多用户「A 编辑 → B 界面更新」

  • A 侧:事务 → Y.Doc_AHocuspocusProviderY 更新
  • 服务端:合并进 房间内存文档,广播给其他连接。
  • B 侧:Provider 收包 → Y.Doc_B CRDT 合并Collaboration 驱动 Tiptap 重绘(非整页刷新)。

技术栈与职责

技术 职责
Next.js App Router:协同页服务端校验文档;Route Handlers 维护文档列表与标题
Prisma + MySQL 文档行存在性、title 字段、yState 二进制快照读写
Tiptap / ProseMirror 富文本编辑、事务与选区;格式工具条
Yjs CRDT 文档状态;Y.Doc 为单一数据源
Hocuspocus WebSocket 房间、转发 Y 协议消息;钩子中对接 Prisma

依赖版本(摘录自 package.jsonyjs@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_AHocuspocusProviderY 增量 发到服务端;服务端合并进房间文档并广播;B 的 Provider 收到后写入 Y.Doc_B (CRDT 合并),Collaboration 扩展再驱动 B 的 Tiptap 与 UI 更新。A、B 可同时编辑,方向对称(B 改 A 亦同)。

读路径(打开协同页)RSC 用 Prisma 确认文档存在 → 客户端 Provider 建立 WebSocket → Hocuspocus onLoadDocument 若有 yStateY.applyUpdate 注入内存文档 → Tiptap 与 Y.Doc 绑定后渲染。

写路径(编辑正文) :按键/粘贴进入 ProseMirror 事务 → Collaboration 扩展写入 Yjs → Provider 向房间广播 → 各端 CRDT 合并;服务端在 debounceonStoreDocument 执行 Y.encodeStateAsUpdate 写回 yState

与正交路径(仅元数据) :标题等仍走 PATCH /api/docs/:id,不经过 WebSocket。


实现流程

1:协同路由 --- 服务端用 Prisma 校验文档后渲染

服务端组件 async,用 collabDocs.findUnique 校验 idcollaborative_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 整文档快照

yStateY.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)--- 与协同正文解耦

协同正文不经这些接口逐字保存;它们负责列表、创建、改标题、删文档PATCHDocEditorWithTitle 用于保存标题。

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.DocHocuspocusProvider、连接生命周期

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 自带 undoRedoCollaboration 绑定 ydocCollaborationCaret 依赖同一 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 渲染光标标签。正文仍只走 CollaborationY.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 绑定两个 CollaborativeEditornameStorageKey 区分 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_URLdebounce: 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_TOKENhocuspocus.ts 无鉴权)。


总览流程图

flowchart LR subgraph Client["浏览器"] T[Tiptap] Y[Y.Doc] P[HocuspocusProvider] T <--> Y P <--> Y end subgraph HP["Hocuspocus Server"] S[Server] end subgraph DB["MySQL"] R[(collaborative_docs)] end P <-->|WebSocket| S S -->|onLoadDocument / onStoreDocument| R
flowchart TD E[用户编辑] --> PM[ProseMirror 事务] PM --> COL[Collaboration 扩展] COL --> YU[Yjs 更新] YU --> WS[Hocuspocus 广播] WS --> YO[对端 Y.Doc 合并] YO --> UI[对端 Tiptap 重绘]

相关文档


相关推荐
opteOG2 小时前
前端项目K8S配置
前端
禅思院2 小时前
总篇:iframe沙盒存储隔离:从紧急补丁到企业级防御体系的完整指南
前端·架构·前端框架
妃衣2 小时前
html页面,富文本转word 、Html to Word(docx)
前端·html·word·html转word
用户5458429869582 小时前
Linux磁盘空间排查实战:从df到du的完整诊断链路
前端·后端
Mintopia2 小时前
从“能用”到“好改”:一套新手也能执行的代码进化路径
前端
JarvanMo2 小时前
浅谈Getx删库跑库了
前端
蚰蜒螟2 小时前
深入剖析 Tomcat 9.0.53 源码:Web 资源管理与类加载机制
java·前端·tomcat
Mintopia2 小时前
别再乱用工具函数:一套可控的 util 设计规则
前端
光影少年2 小时前
开发RN项目时,如何调试iOS真机、Android真机?常见调试问题排查?
android·前端·react native·react.js·ios