万字长文:从零实现 Yjs + Hocuspocus 协同文档

0 阅读13分钟

一、整体概述

今天我来复盘一下关于协同文档的部分,这一部分如果要拆解库的原理其实比较复杂,我所做的功能实现是接通这个过程,实现当前项目后端 Express 框架下的集成与调用。

技术栈:Yjs + Hocuspocus + Express + MongoDB + React + BlockNote

回顾一下我实际落地的MVP产品实现:整个协同文档功能从后端服务开始搭建、再接口开发,到前端链路接入、权限模型设计,再到并发冲突处理与断线重连,逐步完成了全流程实现,目前功能基本可用。

整体协同链路:

前端拿 docId 建立 Hocuspocus 连接并带 JWT → 服务端在 onAuthenticate 校验用户、canAccessDocument 校验文档权限 → 通过后加载/保存 yjsState 实现多人实时同步。

二、后端基础服务搭建

Hocuspocus——Yjs 生态下支持 WebSocket 的服务端组件。

Hocuspocus

A reliable and scalable WebSocket server for Yjs, the foundation for real-time collaboration.

一个可靠且可扩展的 Yjs WebSocket 服务器,实时协作的基础。

— Hocuspocus 官方文档

项目后端采用 Express 框架,在开发初期,先把 Hocuspocus 在 Express 后端框架下搭建好,并接入 WebSocket 支持。

项目结构的组织是直接在后端建立了一个 collaboration 文件夹。

── collaboration/        # 协作相关模块目录
    ├── hocuspocus.ts    # Hocuspocus 协作服务
    ├── index.ts         # 入口文件(模块导出/主逻辑)
    └── websocket.ts     # WebSocket 通信逻辑

遇到的困难是 AI 对版本上下文缺失的幻觉,会一直给出已不在当前 Hocuspocus 版本支持的方法。

我的经验是要回归官方文档,而不能依赖 AI 随意生成代码!

也要关注自己实际采用的框架版本,最终按照官方文档支持的 express-ws 实现 WebSocket 连接。

websocket.ts

import { Server as HttpServer, IncomingMessage } from "http";
import { WebSocket } from "ws";
import type { Instance as WsInstance } from "express-ws";
import { hocuspocusServer } from "./hocuspocus.js";
export interface WebSocketHandler {
  setup: (httpServer: HttpServer, wsApp: WsInstance["app"]) => void;
}

export const createWebSocketHandler = (): WebSocketHandler => {
  return {
    setup: (httpServer, wsApp) => {
      wsApp.ws("/", (ws: WebSocket, req: IncomingMessage) => {
        hocuspocusServer.handleConnection(ws, req);
      });

      wsApp.ws("/collaboration", (ws: WebSocket, req: IncomingMessage) => {
        hocuspocusServer.handleConnection(ws, req);
      });

      wsApp.ws(
        "/collaboration/:documentName",
        (ws: WebSocket, req: IncomingMessage) => {
          hocuspocusServer.handleConnection(ws, req);
        },
      );
    },
  };
};

在这一部分做了多路径兼容握手,是因为在控制台测试 WebSocket 连通性时发现了握手阶段就直接被关闭了,有报错。

  • 为什么需要多个路由? HocuspocusProvider 常见连接方式会先连到 url 本身(如 ws://host:port/),也可能尝试连接固定路径或带文档名的动态路径。WebSocket 配置里兼容根路径、固定路径与动态路径,避免握手阶段直接被关闭。

hocuspocus.ts 是服务端协同核心(鉴权、加载 Yjs 状态、持久化 Yjs 状态)。

1. 服务器实例化

export const hocuspocusServer = new Hocuspocus({
  name: "hocuspocus-wiki",
  extensions: [new Logger()],
  // ... 配置选项
});

extensions 为插件系统,Logger() 提供内置的日志记录功能

2. 鉴权 - onAuthenticate

onAuthenticate

Called when a client tries to connect. Use this to verify the token and decide whether to allow the connection.

当客户端尝试连接时调用。用于验证令牌并决定是否允许连接。

— Hocuspocus 官方文档

async onAuthenticate({ token, documentName }) {
  const user = verifyCollaborationToken(token);
  const allowed = await canAccessDocument(documentName, user.userId);
  if (!allowed) {
    throw new Error("permission-denied");
  }
  
  return { user };
}

客户端首次建立 WebSocket 连接时触发认证

  • 令牌验证:verifyCollaborationToken 解析并验证 JWT 或自定义 token
  • 权限检查:canAccessDocument 验证用户是否有权访问该文档
  • 失败处理:抛出异常,WebSocket 连接被拒绝
  • 成功返回:返回用户信息,存储在 context.user 中供后续钩子使用

3. 加载 Yjs 状态 - onLoadDocument

onLoadDocument

Called when a document needs to be loaded. Return a Y.Doc instance to restore the document state.

当需要加载文档时调用。返回 Y.Doc 实例以恢复文档状态。

— Hocuspocus 官方文档

async onLoadDocument({ documentName, context }) {
  const userId = context?.user?.userId as string | undefined;
  if (!userId) {
    throw new Error("permission-denied");
  }
  
  const allowed = await canAccessDocument(documentName, userId);
  if (!allowed) {
    throw new Error("permission-denied");
  }
  
  const stored = await Document.findById(documentName, "yjsState").lean();
  if (!stored?.yjsState) {
    return null;
  }
  
  const ydoc = new Y.Doc();
  const update = Uint8Array.from(stored.yjsState);
  Y.applyUpdate(ydoc, update);
  return ydoc;
}
  • 二次权限验证(双重保险,防止认证后的权限变更)
  • 数据库查询(只返回 yjsState 字段,.lean() 提高性能)
  • 创建 Yjs 文档并应用更新,恢复文档状态
  • 如果数据库中没有文档,返回 null(创建新文档)

Yjs 核心概念

Y.Doc is the root of a Yjs document. It holds all shared data and manages updates.

Y.Doc 是 Yjs 文档的根节点。它包含所有共享数据并管理更新。

An update is a binary representation of changes. applyUpdate applies an update to a Y.Doc to restore state.

Update 是变更的二进制表示。applyUpdate 将更新应用于 Y.Doc 以恢复状态。

— Yjs 官方文档

4. 持久化 Yjs 状态 - onStoreDocument

onStoreDocument

Called periodically to persist the document state. Use this to save the Y.Doc to your database.

定期调用以持久化文档状态。用于将 Y.Doc 保存到数据库。

— Hocuspocus 官方文档

async onStoreDocument({ documentName, document }) {
  const yjsState = Buffer.from(Y.encodeStateAsUpdate(document));
  await Document.findByIdAndUpdate(documentName, {
    yjsState,
    yjsStateUpdatedAt: new Date(),
  });
}

定期自动保存(默认每 30 秒),所有客户端断开连接时手动触发保存

三、核心权限模型与数据库设计

实现思路是分享连接加入协作,但核心不是生成链接本身,而是权限模型,也就是要区分是否为协作成员。

本质是一个文档实体对应多个用户的访问关系,我采用的方式是建立独立的关系表

documentMember.ts 协作成员权限模型。

import mongoose from "mongoose";
const { Schema } = mongoose;

const DocumentMemberSchema = new Schema({
  documentId: { type: Schema.Types.ObjectId, ref: "Document", required: true },
  userId: { type: Schema.Types.ObjectId, ref: "User", required: true },
  role: { type: String, enum: ["editor"], default: "editor" },
  joinedAt: { type: Date, default: Date.now },
});
DocumentMemberSchema.index({ documentId: 1, userId: 1 }, { unique: true });
export default mongoose.model("DocumentMember", DocumentMemberSchema);

代码的其他细节上也需要注意,比如 MongoDB 用 1/-1 排序索引可以提升排序与查询效率;针对时间范围等场景的 Schema 是自定义的,兼顾数据库索引与代码逻辑复用,避免重复编写。

四、后端接口开发与测试

截止这个开发阶段,前端还没有接入相关链路。

前端 service 层要注册两个新路由:获取分享链接、加入文档协作。

export const createShareLink = async (id: string) => {
  const response = await apiClient.post(`/api/documents/${id}/share-link`);
  return response.data;
};

export const joinDocument = async (id: string) => {
  const response = await apiClient.post(`/api/documents/${id}/join`);
  return response.data;
};

核心逻辑在后端的 Controller。

对于协同功能的扩展,这里主要理清了两件事:

  • 旧接口加入身份验证
  • 新接口:加入协作、分享链接。

旧接口中的权限校验加入逻辑:

const isOwner = await Document.exists({ _id: docId, owner: userId });
const isMember = await DocumentMember.exists({ documentId: docId, userId });
if (!isOwner && !isMember) {
  return res.status(404).json({ message: "文档不存在" });
}

分享链接接口:

export const createShareLink = async (req: AuthRequest, res: Response) => {
  try {
    const userId = req.user?.userId;
    if (!userId) {
      return res.status(401).json({ error: "Unauthorized: User not authenticated" });
    }

    const { id: docId } = req.params;
    if (!docId) {
      return res.status(400).json({ error: "Bad Request: docId is required" });
    }

    const document = await Document.findOne({
      _id: docId,
      owner: userId,
    });

    if (!document) {
      return res.status(404).json({ error: "Document not found or you are not the owner" });
    }

    const clientOrigin = process.env.CLIENT_ORIGIN || "http://localhost:5173";
    const shareUrl = `${clientOrigin}/wiki/${docId}?join=1`;

    return res.status(200).json({ shareUrl });
  } catch (error) {
    console.error("Error creating share link:", error);
    return res.status(500).json({ error: "Internal server error" });
  }
};

加入协作接口({ documentId, userId } 幂等加入):

export const createDocumentMember = async (req: AuthRequest, res: Response) => {
  try {
    const userId = req.user?.userId;
    if (!userId) {
      return res.status(401).json({ error: "Unauthorized: User not authenticated" });
    }

    const { id: docId } = req.params;
    if (!docId) {
      return res.status(400).json({ error: "Bad Request: docId is required" });
    }

    const document = await Document.findById(docId);
    if (!document) {
      return res.status(404).json({ error: "Document not found" });
    }

    if (document.owner.toString() === userId) {
      return res.status(200).json({
        message: "You are already the owner of this document",
        isOwner: true,
      });
    }

    await DocumentMember.findOneAndUpdate(
      { documentId: docId, userId: userId },
      {
        documentId: docId,
        userId: userId,
        role: "editor",
        joinedAt: new Date(),
      },
      {
        upsert: true,
        new: true,
        setDefaultsOnInsert: true,
      },
    );

    return res.status(200).json({
      message: "Successfully joined the document",
      documentId: docId,
      role: "editor",
    });
  } catch (error) {
    console.error("Error creating document member:", error);
    return res.status(500).json({ error: "Internal server error" });
  }
};

对于后端接口的测试,是采用 Postman 进行参数输入,并填写对应的 Content-Type 或者参数等,验证接口是否可用。

因为之前项目开发的经验,我的教训是:完成一个路由注册与接口设计后,就可以立即验证是否可用。

五、具体加入协作的流程

这个 Hook 会在检测到 URL 包含 ?join=1 参数时,自动执行加入文档的操作

import { useEffect, useRef } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { useAuth } from "../contexts/authContext";
import { joinDocument } from "../services/document";
import { notifyDocumentsChanged } from "../constants/documentEvents";

export const useJoinDocumentFromUrl = () => {
  const { user } = useAuth();
  const { docId } = useParams<{ docId: string }>();
  const [searchParams, setSearchParams] = useSearchParams();
  const joinedKeyRef = useRef<string | null>(null);

  useEffect(() => {
    const shouldJoin = searchParams.get("join") === "1";
    if (!shouldJoin) return;
    if (!user || !docId) return;

    const joinKey = `${user.id}:${docId}`;
    if (joinedKeyRef.current === joinKey) return;
    joinedKeyRef.current = joinKey;

    joinDocument(docId)
      .then(() => {
        notifyDocumentsChanged();
      })
      .catch((error) => {
        console.error("加入文档失败:", error);
      })
      .finally(() => {
        setSearchParams(
          (prev) => {
            const next = new URLSearchParams(prev);
            next.delete("join");
            return next;
          },
          { replace: true },
        );
      });
  }, [searchParams, user, docId, setSearchParams]);
};

六、协同开关逻辑与数据加载区分

核心协同 useCollaboration hook 封装实现了 BlockNote 编辑器与 Hocuspocus 协作服务器的集成。

这个 hook 的实现功能包括:

  1. 管理 WebSocket 连接状态
  2. 配置 Yjs 文档同步
  3. 集成 BlockNote 的协作功能

6.1 类型定义

export type CollaborationStatus =
  | "disabled"      // 协作功能未启用
  | "connecting"    // 正在连接服务器
  | "connected"     // 已连接,可协作编辑
  | "disconnected"; // 连接断开

interface CollaborationOptions {
  docId?: string;           // 文档ID(可选,不传则禁用协作)
  userName?: string;        // 用户显示名称
  userColor?: string;       // 用户光标颜色
  editorOptions?: Parameters<typeof useCreateBlockNote>[0]; // 编辑器配置
}

6.2 WebSocket URL 处理

函数:getCollabWsUrl(),智能构建 WebSocket URL,支持多种配置格式:

const getCollabWsUrl = () => {
  const raw = import.meta.env.VITE_COLLAB_WS_URL?.trim();
  
  if (raw) {
    if (raw.startsWith("ws://") || raw.startsWith("wss://")) {
      return raw.replace(/\/+$/, "");
    }
    
    if (raw.startsWith("/")) {
      const protocol = window.location.protocol === "https:" ? "wss" : "ws";
      return `${protocol}://${window.location.host}${raw}`.replace(/\/+$/, "");
    }
    
    const protocol = window.location.protocol === "https:" ? "wss" : "ws";
    return `${protocol}://${raw}`.replace(/\/+$/, "");
  }
  
  return "ws://localhost:3001";
};

6.3 Hook 主体

开发中遇到过页面已打开但协同一直不接通/状态卡住的典型时序问题。

原因在于事件监听注册时机晚于 WebSocket 连接建立,导致 onConnect 等回调未捕获到首连事件。

解决方案:

  • 提前注册回调:在 HocuspocusProvider 构造时同步传入 onConnect、onDisconnect、onStatus,避免依赖 effect 中“后挂”监听导致时序丢失。
  • 显式触发连接:启用协同时主动调用 provider.configuration.websocketProvider.connect(),而非依赖隐式自动连接。
  • 资源清理:组件卸载时执行 provider.destroy() 和 ydoc.destroy(),防止内存泄漏和连接残留。

状态初始化

const isCollaborationEnabled = Boolean(docId);
const [status, setStatus] = useState<CollaborationStatus>(
  isCollaborationEnabled ? "connecting" : "disabled",
);

创建 Provider 和 YDoc

const { provider, ydoc } = useMemo(() => {
  if (!isCollaborationEnabled || !docId) {
    return { provider: null, ydoc: null };
  }

  const ydoc = new Y.Doc();
  const provider = new HocuspocusProvider({
    url: getCollabWsUrl(),
    name: docId,
    document: ydoc,
    token: () => localStorage.getItem("token") || "",
    onConnect: () => setStatus("connected"),
    onDisconnect: () => setStatus("disconnected"),
    onAuthenticationFailed: () => setStatus("disconnected"),
    onStatus: ({ status: nextStatus }) => {
      if (nextStatus === "connected") setStatus("connected");
      else if (nextStatus === "disconnected") setStatus("disconnected");
      else setStatus("connecting");
    },
  });

  return { provider, ydoc };
}, [docId, isCollaborationEnabled]);

协作配置

const collaborationConfig = useMemo(() => {
  if (!provider || !ydoc || !isCollaborationEnabled) return undefined;

  return {
    provider: provider as any,
    fragment: ydoc.getXmlFragment("blocknote"),
    user: {
      name: userName,
      color: userColor,
    },
  };
}, [provider, ydoc, userName, userColor, isCollaborationEnabled]);

创建编辑器实例

const editor = useCreateBlockNote(
  {
    ...(editorOptions ?? {}),
    ...(collaborationConfig ? { collaboration: collaborationConfig } : {}),
  },
  [editorOptions, collaborationConfig, docId, userName, userColor],
);

连接生命周期管理

useEffect(() => {
  if (!isCollaborationEnabled) {
    setStatus("disabled");
    return;
  }

  if (!provider || !ydoc) return;

  setStatus("connecting");
  void provider.configuration.websocketProvider.connect();

  return () => {
    provider.destroy();
    ydoc.destroy();
  };
}, [provider, ydoc, isCollaborationEnabled]);

6.4 与后端对应关系

前端后端
docId → 文档名onLoadDocument({ documentName })
token → 认证onAuthenticate({ token, documentName })
连接状态WebSocket 连接
ydoc 变更onStoreDocument 触发

6.5 docId 如何作为协同开关?

App 根组件通过 useParams 从 URL 中取出 ID,并传递给 useCollaboration。

  const { docId } = useParams<{ docId: string }>();

如果当前没有 docId,协同被禁用;如果有 docId,则建立 Hocuspocus 连接,并以 name=docId 启用协同。

  const isCollaborationEnabled = Boolean(docId);

并且在加载逻辑上做了协同/非协同区分:

  // 当 docId 或 editor 变化时,从 API 加载文档内容
  useEffect(() => {
    if (!editor) return;

    // 协同模式下由 Yjs/Hocuspocus 负责文档状态同步,避免 REST 全量覆盖引发冲突。
    if (isCollaborationEnabled) return;

    if (!docId) {
      // 无文档时清空编辑器
      try {
        editor.replaceBlocks(editor.document, [{ type: "paragraph" }]);
      } catch (_) {}
      return;
    }
    let cancelled = false; // 竞态条件:如果在 fetch 过程中 docId 发生变化,或者组件卸载了,就不再执行 setState
    getDocumentById(docId)
      .then((doc) => {
        if (cancelled) return;
        const content =
          doc.content && Array.isArray(doc.content) && doc.content.length > 0
            ? doc.content
            : [{ type: "paragraph" }];
        try {
          editor.replaceBlocks(editor.document, content);
        } catch (_) {}
      })
      .catch(() => {});
    return () => {
      cancelled = true;
    };
  }, [docId, editor, isCollaborationEnabled]);

  • 协同模式:采用 Yjs 二进制存储,数据链路通过 WebSocket 与 Yjs 增量同步
  • 非协同模式:采用 RESTful API 一次性拉取并保存

非协同时走 REST 加载逻辑,避免协同状态下 REST 全量覆盖导致实时同步数据被本地覆盖的冲突风险。

七、鉴权体系设计

项目有两条鉴权链:

1) HTTP 接口鉴权链(REST)(这个之前在我的 JWT 一文中写到过)

  • 前端: axios 请求拦截器把 Authorization Bearer Token 加到每个 API 请求头
  • 后端路由入口: documentRoutes.ts 中 router.use(auth)
  • 后端鉴权中间件: auth.ts 从 Authorization 取 token → jwt.verify 校验 → 挂到 req.user
  • 业务控制器: 再做资源权限判断
  • 前端失败处理: 响应拦截器处理 401,清 token 并跳转 /login

2) 协同 WebSocket 鉴权链(Yjs/Hocuspocus)

  • 前端: useCollaboration.ts 中 HocuspocusProvider 的 token 参数携带 token
  • 后端 WS 接入: websocket.ts 中 hocuspocusServer.handleConnection
  • Hocuspocus 生命周期鉴权: hocuspocus.ts 中 onAuthenticate 调用 verifyCollaborationToken
  • 文档级权限: 调用 canAccessDocument(owner/member 才能协同)
  • 状态加载/存储: 通过后才加载/存储 Yjs 状态(onLoadDocument / onStoreDocument)

八、并发冲突处理与断线重连

关于协同并发,我做了 F12 测试,验证用户并发编辑同一行文字时的处理逻辑。

验证方法是:将其中一个用户通过 F12 Network 切换至断网状态并编辑,另一位协作者在联网状态下对同一行进行编辑,断网用户也在同一行编辑,之后恢复网络。

我观察到的现象是:Yjs 的文档并发处理并非按物理时间先后,而是按身份区分——文档创建者的内容会排在前面,协作者的内容会顺延展示,不依据谁先编辑的时间顺序写入,这样能比较好地处理并发冲突,也借此了解了其他 CRDT 算法的思路。

CRDT (Conflict-free Replicated Data Type)

CRDTs are data structures that enable seamless collaboration by resolving conflicts automatically without a central server. Yjs implements CRDTs to handle concurrent edits.

CRDT 是一种数据结构,通过自动解决冲突实现无缝协作,无需中央服务器。Yjs 实现了 CRDT 来处理并发编辑。

— Yjs 官方文档

CRDT(Conflict-free Replicated Data Type) 的核心:

修改不是按"时间顺序"合并,而是按"身份(client ID) + 某种确定性规则"合并。两个写入永不覆盖,只会按身份优先级排列。

断线重连与状态恢复依赖前端 useCollaboration 通过 Hocuspocus 的 Provider 管理连接生命周期,重连逻辑依托官方文档自带的连接管理能力,属于库内部实现。

Provider & Reconnection

HocuspocusProvider automatically handles reconnection when the WebSocket connection is lost. It will attempt to reconnect and synchronize the document state once the connection is restored.

当 WebSocket 连接丢失时,HocuspocusProvider 会自动处理重连。它将在连接恢复后尝试重新连接并同步文档状态。

— Hocuspocus 官方文档

九、实时感知 awareness 库托管 + 轻量业务注入

Awareness

Awareness is a mechanism for sharing user presence information like cursor positions, selections, and user data. It's built on top of Yjs and works seamlessly with Hocuspocus.

Awareness 是一种共享用户状态信息的机制,如光标位置、选区和用户数据。它基于 Yjs 构建,并与 Hocuspocus 无缝协作。

— Yjs 官方文档

前端把用户感知信息注入协同配置,把本地用户身份(显示名、颜色)交给协同层。

  const collaborationConfig = useMemo(() => {
    if (!provider || !ydoc || !isCollaborationEnabled) return undefined;

    return {
      provider: provider as any,
      fragment: ydoc.getXmlFragment("blocknote"),
      user: {
        name: userName,
        color: userColor,
      },
    };
  }, [provider, ydoc, userName, userColor, isCollaborationEnabled]);

在协同连接状态上增加了一个连接状态指示器,使用 collaborationStatus 入参保留协同状态,提升用户体验。

const currentStatus = STATUS_MAP[collaborationStatus];
const STATUS_MAP: Record<CollaborationStatus, { key: string; color: string }> =
  {
    disabled: {
      key: "footbar.collab_disabled",
      color: "var(--toolbar-icon-color)",
    },
    connecting: {
      key: "footbar.collab_connecting",
      color: "var(--toolbar-icon-color)",
    },
    connected: {
      key: "footbar.collab_connected",
      color: "var(--toolbar-icon-color)",
    },
    disconnected: {
      key: "footbar.collab_disconnected",
      color: "var(--toolbar-icon-color)",
    },
  };

十、总结

现在整体功能基本可用。本次协同文档开发,从服务搭建、前后端链路、权限设计,到并发与重连机制,均围绕 Yjs + Hocuspocus 实现,过程中也明确了依赖官方文档、区分加载与鉴权逻辑等关键经验。

协同文档的底层比较复杂,需要追溯到Yjs相关的算法论文,也常常是前端开发遇到的难点,而我只不过是做了最基础的仅区分owner(文档所有者)和 editor(协作者)两个身份。

这个项目对于我个人收获最大的是权限设计,自定义封装 useCollaboration hook,以及接触了websocket的搭建还有hocuspocus作为协作gateway,在全栈思路上给我很多启发。

特别声明:本次代码实现仅仅是能跑通功能,并不是优雅的做法,存在设计等层面的缺陷,还请见谅。限于个人经验,文中若有疏漏,还请不吝赐教。