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

0 阅读9分钟

前言

效果演示

GIF.gif

上一章使用 Next.js + Prisma + MySQL 开发全栈项目


目录

章节内容
关键技术说明概念、双路径、持久化、与本仓库的对应关系
技术栈与职责各技术角色速查表
整体架构ASCII 示意图与读/写路径
按实现流程(代码示例)1~11,与关键技术说明对应
总览流程图Mermaid 简图
相关文档仓库内其他说明

关键技术说明

1. 两条数据路径(需先分清)

路径载体存什么典型入口
元数据HTTP,Route Handlers,Prisma文档是否存在、title、列表与删除GET/POST /api/docsPATCH/DELETE /api/docs/:id
正文协同WebSocket,Y 协议,Hocuspocus内存中的 Y.Doc 状态;库中仅 整文档二进制快照 yStateHocuspocusProviderserver/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.jsApp Router:协同页服务端校验文档;Route Handlers 维护文档列表与标题
Prisma + MySQL文档行存在性、title 字段、yState 二进制快照读写
Tiptap / ProseMirror富文本编辑、事务与选区;格式工具条
YjsCRDT 文档状态;Y.Doc 为单一数据源
HocuspocusWebSocket 房间、转发 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 · 部署与持久化(自上而下)

                        ┌─────────────────────────────────────────┐
                        │           浏览器(可多实例)              │
                        │                                         │
                        │  ┌──────────┐   ┌───────┐   ┌──────────┐ │
                        │  │ 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。

  用户 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(即房间名)。

// 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/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 桥接一次。

// 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

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  serverExternalPackages: ["@prisma/client", "prisma"],
};

export default nextConfig;

4:文档 CRUD(HTTP)— 与协同正文解耦

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

// 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 });
}
// 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

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

// 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 做远程光标与用户名。

// 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,两条线独立。

GIF15.gif

8:UI — 加载态、昵称输入、工具条、EditorContent

// 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>
);
// 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 中的昵称键,避免左右栏抢同一键。

// 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 一致。

// 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:本地运行脚本

{
  "scripts": {
    "dev": "next dev",
    "hocuspocus": "tsx server/hocuspocus.ts",
    "dev:collab": "concurrently -n next,hocus -c blue,magenta \"next dev\" \"npm run hocuspocus\""
  }
}
变量作用
DATABASE_URLPrisma 连接 MySQL(Next 与 Hocuspocus 共用)
HOCUSPOCUS_PORTHocuspocus 监听端口,默认 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 重绘]

相关文档