为什么 Tiptap 做协同编辑离不开 Hocuspocus❓❓❓

1 阅读22分钟

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、Tiptap 和 LangGraph 开发 DocFlow

DocFlow 是一个面向 AI 全栈场景的协同文档平台,主要围绕富文本编辑、实时协作和 AI 工作流展开。

如果你对 AI 全栈开发、Tiptap、LangGraph 或协同文档感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

eb44cd75f896ed5cf3ba3aad76fb3fdb.png

很多人第一次做协同编辑,都会下意识把它理解成一个更复杂的 WebSocket 功能。

两个人打开同一篇文档,A 输入一段文字,B 的页面实时出现;B 删除一个段落,A 的页面也跟着更新。这个效果看起来像是前端监听输入、后端广播消息、其他客户端刷新 UI。

但只要把它放进真实业务里,问题马上就会变复杂。

  • 两个人同时修改同一个位置,最终内容以谁为准?
  • 用户断网后继续编辑,重新上线时怎么合并?
  • 富文本里有标题、列表、图片、表格、自定义节点,怎么保证结构不乱?
  • 在线头像、远程光标、选区高亮,应该和文档内容存在一起吗?
  • 文档状态要怎么保存,能不能直接存成 JSON?
  • 多个后端实例部署时,不同实例上的用户怎么互相同步?
  • 权限、租户、审计、版本历史应该放在哪一层?
  • 后续接入 AI 改写、总结、评论、RAG 时,怎么保证实时文档、数据库和 AI 上下文不打架?

这就是 Hocuspocus 的价值所在。

它不是一个富文本编辑器,也不是普通 WebSocket 封装。它更像是 Tiptap 和 Yjs 背后的协同同步层,负责把多人编辑、实时同步、Awareness、鉴权、持久化、多实例扩展这些能力收口起来,让协同编辑从一个能跑的 Demo,逐渐变成一个可维护的生产系统。

Hocuspocus 不负责编辑体验,它负责协同状态同步

如果我们用 Tiptap 做文档编辑器,Tiptap 负责的是编辑器本身的体验,比如段落、标题、列表、图片、代码块、表格、自定义扩展、快捷键、菜单和命令系统。

但多人协同时,真正难的不是编辑器 UI,而是协同状态。

假设三个用户同时编辑同一篇文档:

  • A 在标题后面追加文字
  • B 删除正文第一段
  • C 离线状态下修改了列表内容,几分钟后重新上线

如果只是把编辑器内容转成 HTML 或 JSON,然后通过 WebSocket 广播给其他人,很容易出现覆盖、重复、丢更新、结构错乱等问题。

Hocuspocus 在整个链路里的位置更像是协同后端:

20260604201016

这条链路里,各层的职责其实很清楚:

  • 用户输入:产生本地编辑行为
  • Tiptap:捕获编辑变化,维护富文本编辑体验
  • Yjs:记录协同文档状态,让多人更新可以合并和收敛
  • Hocuspocus Provider:在客户端建立连接,把本地 Yjs 更新同步出去
  • Hocuspocus Server:接收更新,处理鉴权、加载、保存和广播
  • 其他客户端:收到更新后合并到本地文档,最终保持一致

这也是 Hocuspocus 和普通 WebSocket 最大的区别。普通 WebSocket 只负责传消息,Hocuspocus 面向的是协同文档状态。

只靠 WebSocket 广播,很难撑住富文本协同

做一个简单聊天室,用 WebSocket 广播消息是合理的。消息是追加式的,A 发一条,B 收一条,顺序和展示都比较清楚。

但富文本协同不是聊天室。

富文本有结构,有节点,有 mark,有嵌套关系,有自定义扩展。用户修改的也不是一条条独立消息,而是文档里的某个位置、某个节点、某段结构。

如果只是广播当前内容,很容易遇到这些问题:

  • 后发内容覆盖先发内容
  • 离线编辑重新上线后无法正确合并
  • 富文本节点被错误拆开或合并
  • 多人同时编辑同一区域时结果不稳定
  • 服务端很难判断一次更新到底影响了哪里
  • 文档内容、在线状态、业务状态混在一起,后期无法治理

Yjs 的作用,是把文档变化建模成可以合并的更新。Hocuspocus 的作用,是把这些更新通过 WebSocket 在多个客户端和服务端之间同步,并提供 hooks 让我们把鉴权、加载、存储、日志等业务能力接进去。

所以不要把 Hocuspocus 理解成少写几行 WebSocket 代码。它真正解决的是协同编辑里最麻烦的状态一致性问题。

前端接入的核心是 Provider,而不是自己设计同步协议

在前端侧,我们通常不会自己处理底层 WebSocket 消息,而是通过 Hocuspocus Provider 把一个 Yjs 文档连接到服务端。

一个最小接入大概是这样:

import * as Y from "yjs";
import { HocuspocusProvider } from "@hocuspocus/provider";

const document = new Y.Doc();

const provider = new HocuspocusProvider({
  url: "ws://127.0.0.1:1234",
  name: "document.1001",
  document,
});

这里最关键的字段是 name

它看起来只是一个房间名,但在生产项目里,它应该是业务文档的协同标识。比如:

const documentName = `workspace.${workspaceId}.document.${documentId}`;

这样后端才能根据 documentName 判断当前连接访问的是哪个租户、哪个空间、哪篇文档,并结合用户身份做权限校验。

如果项目是 React 或 Next.js,还要注意连接管理的问题。真实产品里,一个用户可能同时打开多个文档、多个编辑面板、多个协同区域。如果每个编辑器都单独创建 WebSocket,连接会变得混乱,重连、销毁、状态恢复也会很难管。

更合理的方式是:

  • 一个页面或工作台共享 WebSocket 连接
  • 不同文档用不同 room 管理
  • 编辑器只关心当前文档的 Yjs 状态
  • 在线用户、光标、选区通过 Awareness 同步
  • 页面卸载时正确销毁 provider 和 room
  • token 过期、断线重连、权限变化要能被统一处理

前端真正要避免的是把同步协议写散。只要多个组件都在自己连 WebSocket、自己处理消息、自己更新编辑器状态,协同逻辑很快就会变成一团乱麻。

documentName 不是随便起的房间名,而是协同权限的入口

很多协同 Demo 会直接把 name 写成 room-1document-1,这样能跑,但不适合生产环境。

在真实业务里,documentName 至少应该承载三个信息:

  • 这是谁的文档
  • 这篇文档属于哪个空间或租户
  • 当前连接应该按什么权限访问

比如一个团队文档系统,可以把协同房间设计成:

const documentName = `tenant.${tenantId}.workspace.${workspaceId}.document.${documentId}`;

这样做的好处是,后端在 onAuthenticate 里可以直接基于 documentName 做权限判断,而不是把协同连接和业务文档关系拆散。

但这里也要注意,不要把敏感信息直接暴露在 documentName 里。它适合做资源定位,不适合承载隐私字段。真正的用户身份、角色、权限,应该来自 token 和后端权限系统,而不是前端自己拼出来的字符串。

一个更稳的思路是:

  • documentName 负责定位资源
  • token 负责证明用户身份
  • 后端权限系统负责判断是否可读、可写、可评论、可管理
  • hooks 负责把这些判断串进协同连接生命周期

这样协同连接才不会变成一个绕过业务权限的后门。

后端要把 Hocuspocus 当成协同网关,而不是孤立服务

Hocuspocus Server 可以单独启动,但在真实项目里,最好不要把它当成一个和业务无关的独立 WebSocket 服务。

更合理的定位是:协同网关。

它负责处理协同连接,但权限、租户、文档元信息、审计、版本、搜索索引这些能力,仍然应该由业务系统提供。

以 NestJS 项目为例,可以这样拆边界:

  • NestJS 负责用户、workspace、权限、文档元信息、审计日志
  • Hocuspocus 负责实时连接、Yjs 更新同步、Awareness、文档加载和保存
  • PostgreSQL 或对象存储负责保存 Yjs binary 和业务派生数据
  • Redis 负责多实例之间的更新传播和在线状态同步
  • 搜索系统负责从文档派生纯文本、摘要、向量索引或 BM25 索引

Hocuspocus 的服务端能力通常通过 hooks 接入。常见 hooks 包括:

  • onAuthenticate:验证用户身份和文档权限
  • onLoadDocument:用户连接时加载协同文档
  • onStoreDocument:文档变化后保存协同状态
  • onConnect:连接建立时记录日志或初始化上下文
  • onDisconnect:连接断开时清理状态或写审计

一个简化后的服务端设计可以这样写:

import { Server } from "@hocuspocus/server";

type CollaborationContext = {
  userId: string;
  workspaceId: string;
  permissions: string[];
};

const server = new Server<CollaborationContext>({
  name: "collaboration-server",
  port: 1234,

  async onAuthenticate({ token, documentName }) {
    const session = await verifyCollaborationToken(token);

    const permission = await checkDocumentPermission({
      userId: session.userId,
      documentName,
    });

    if (!permission.canRead) {
      throw new Error("Not authorized");
    }

    return {
      userId: session.userId,
      workspaceId: permission.workspaceId,
      permissions: permission.actions,
    };
  },

  async onLoadDocument({ documentName, context }) {
    return loadYjsDocumentFromStorage({
      documentName,
      workspaceId: context.workspaceId,
    });
  },

  async onStoreDocument({ documentName, document, context }) {
    await saveYjsDocumentToStorage({
      documentName,
      document,
      workspaceId: context.workspaceId,
      updatedBy: context.userId,
    });
  },
});

server.listen();

这段代码的重点不是具体 API,而是工程边界:

  • onAuthenticate 不只是校验登录态,还要校验当前用户是否能访问这篇文档
  • onLoadDocument 只负责恢复协同文档状态,不应该夹杂复杂业务逻辑
  • onStoreDocument 保存的是 Yjs 文档状态,而不是随手转成普通 JSON
  • context 用来把用户、租户、权限传给后续 hooks,避免每个 hook 重新查一遍
  • 存储失败、鉴权失败、加载失败都应该进入统一日志和监控,而不是只在控制台打印错误

如果这个边界没有设计好,Hocuspocus 很容易被用成一个没有权限、没有审计、没有租户隔离的 WebSocket 服务。Demo 阶段问题不大,上线后风险很高。

持久化时不要把 Y.Doc 当成普通 JSON 存

这是接 Yjs 和 Hocuspocus 时非常容易踩的坑。

很多人会想:编辑器里不是能拿到 JSON 吗?那我保存 JSON 不就行了吗?

对于普通富文本编辑器,这样做没问题。但对于协同编辑,只保存编辑器 JSON 不够。因为 Yjs 文档里保存的不只是当前内容,还有协同合并需要依赖的内部状态。

如果每次都把 Y.Doc 转成普通 JSON 保存,然后下次再用 JSON 重新创建 Y.Doc,可能短期看起来能恢复内容,但长期会破坏协同语义。后面一旦遇到离线合并、多人并发编辑、增量更新,就可能出现重复、丢失或状态异常。

更合理的做法是把数据拆成两类。

第一类是协同源数据,也就是 Yjs binary。它用于恢复协同文档状态,是 Hocuspocus 和 Yjs 正常工作的基础。

第二类是业务派生数据,比如 HTML、JSON、纯文本、摘要、搜索索引、向量索引和版本展示数据。它们用于业务读取、搜索、预览、导出、RAG、AI 总结、版本对比,但不应该替代 Yjs binary。

也就是说,协同编辑的底层存储应该保留 Yjs 的数据格式;业务系统需要什么展示形态,再从协同文档派生出来。

这能避免一个很常见的后期问题:协同层、展示层、搜索层、AI 层全都抢同一份 JSON,最后谁也不敢改。

Awareness 解决的是人在场,不是文档内容

协同编辑里还有一个很重要的概念:Awareness。

它负责同步当前用户的临时在线状态,比如:

  • 谁正在打开这篇文档
  • 用户头像和昵称
  • 远程光标位置
  • 远程选区范围
  • 谁正在输入
  • 鼠标位置或临时状态

Awareness 解决的是人在场感。它让用户知道,当前不是自己一个人在编辑,而是有人正在同一篇文档里协作。

但 Awareness 不应该被当成正式业务数据存储。

这些内容适合放在 Awareness 里:

  • 在线头像
  • 光标位置
  • 选区高亮
  • 正在输入
  • 临时鼠标位置

这些内容不适合放在 Awareness 里:

  • 文档正文
  • 评论内容
  • 审批状态
  • 任务状态
  • 版本历史
  • 权限配置

原因很简单,Awareness 是临时状态,它关注的是当前在线协作场景,不负责长期保存和审计。

所以在工程设计里,可以这样划分:

  • Yjs Document 保存协同文档内容
  • Awareness 同步在线用户、光标、选区等临时状态
  • Database 保存业务数据、权限、评论、版本和审计
  • Search Index 保存纯文本、摘要、向量索引和关键词索引

这个边界越早拆清楚,后面做评论、版本历史、AI 总结、RAG 检索时越轻松。

生产环境要考虑多实例,但 Redis 不是持久化方案

当协同编辑接入真实用户后,很快会遇到多实例部署问题。

单个 Hocuspocus Server 可以处理一定数量的连接,但如果用户量增加,或者要做高可用,就需要多个实例一起工作。此时就会出现一个问题:A 用户连接到了实例 1,B 用户连接到了实例 2,他们编辑的是同一篇文档,更新怎么互相同步?

这时候可以引入 Redis,让多个 Hocuspocus 实例之间同步 updates 和 Awareness。

但这里一定要注意:Redis 不是文档持久化方案。

它更适合承担这些职责:

  • 多个 Hocuspocus 实例之间传播更新
  • 同步在线状态
  • 支撑 Awareness 在多实例之间流动
  • 让负载均衡后的多个实例能看到同一篇文档的变化

而真正的文档状态,仍然应该保存到数据库、对象存储或专门的持久化扩展里。

生产部署时可以这样理解:

  • 客户端 A 连接到 Hocuspocus 实例 1
  • 客户端 B 连接到 Hocuspocus 实例 2
  • 实例 1 和实例 2 通过 Redis 同步协同更新和在线状态
  • Hocuspocus 实例把最终文档状态保存到持久化存储
  • 用户重新打开文档时,再从持久化存储恢复 Yjs 文档

Redis 负责实例之间的消息传播,持久化存储负责文档恢复。两者不能混为一谈。

另外,Redis 也不是性能问题的万能解药。如果一个大文档房间里有大量用户同时编辑,单纯增加实例和 Redis 不一定能降低压力。因为协同更新仍然需要在实例之间传播,热点文档仍然可能成为瓶颈。

更稳的方案通常是:

  • 按 workspace、documentId 或租户做分片
  • 尽量让同一篇文档的连接落到同一组实例
  • 对大文档和高并发房间做单独限流
  • 监控连接数、同步延迟、存储耗时、重连次数
  • 对超大文档做拆分,不要所有内容塞进一个 Y.Doc
  • 定期压缩、快照或清理不必要的协同状态

协同系统最怕的是前期只追求能同步,后期所有文档、所有用户、所有更新都混在一个巨大实时房间里。那不是协同编辑,是实时状态垃圾场。

Hocuspocus 适合做文档协同,不适合承接所有实时业务

Hocuspocus 很适合这些场景:

  • 类 Notion、飞书文档、语雀的多人富文本编辑
  • 知识库、项目文档、协同笔记、团队空间
  • 带多人光标、在线头像、实时保存的编辑器工作台
  • Tiptap、ProseMirror、Monaco 等编辑器的协同层
  • 需要离线编辑、断线重连、多人合并的文档类应用

但它不是所有实时系统的标准答案。

如果只是聊天室、通知推送、直播弹幕,用普通 WebSocket、SSE 或消息队列会更简单。

如果是订单、支付、库存、审批流这类强事务业务,也不应该把状态塞进 Yjs 文档里。它们应该由业务数据库、事务和状态机管理。

Hocuspocus 最适合的是协同文档状态,不是业务状态。

这个边界非常重要。很多系统后期混乱,都是因为一开始把协同文档当成万能状态容器,评论、审批、任务、权限、版本全塞进去。短期看起来统一,长期会变成维护灾难。

真正落地前,要先定义文档边界

接入 Hocuspocus 之前,最好先把文档相关数据拆清楚。

以一个知识库页面为例,可以这样设计:

  • 标题、正文、富文本块进入 Yjs 文档
  • 文档 ID、workspaceId、作者、更新时间进入业务表
  • 权限、角色、分享设置进入权限系统
  • 评论、审批、任务引用单独建表,通过锚点关联文档位置
  • 搜索文本、摘要、向量索引从 Yjs 文档派生生成
  • 版本历史基于快照、增量或业务版本策略独立设计
  • 审计日志记录用户、时间、动作、文档、来源 IP 和 traceId

这样拆的好处是,每一类数据都有自己的生命周期。

文档正文需要协同合并;权限需要强一致校验;评论需要可追踪;搜索索引可以异步更新;AI 摘要可以重新生成;审计日志不能被用户随意覆盖。

如果这些东西都混在一个 Y.Doc 里,后面会出现很多麻烦:

  • 搜索更新困难
  • 权限校验不清晰
  • 评论定位不稳定
  • 版本历史难回滚
  • 审计日志不可控
  • AI 处理无法稳定引用原文位置

所以做协同编辑,真正的第一步不是写 Provider,而是先拆数据边界。

和 AI 工作流结合时,Hocuspocus 更像实时上下文底座

如果你的文档系统后续要接 AI,Hocuspocus 的价值会更明显。

比如一个 AI 文档工作台里,用户一边编辑,AI 一边根据当前选区生成内容、改写段落、插入摘要、补全文档结构。多人协作时,AI 也可能作为一个特殊参与者参与编辑。

这时系统会同时存在几类状态:

  • 用户正在编辑的实时文档
  • AI 正在处理的选区范围
  • AI 生成的候选内容
  • 用户是否接受 AI 修改
  • 多人协作产生的最新上下文
  • AI 操作留下的审计记录和版本记录

Hocuspocus 可以负责实时文档状态,但 AI 工作流不能直接把模型输出硬写进文档。更稳的做法是增加一层操作协议:

  • 用户选中一段内容
  • AI 根据选区、上下文和业务指令生成候选修改
  • 前端展示 diff,而不是直接覆盖原文
  • 用户确认或拒绝这次修改
  • 确认后由 Tiptap 写入编辑器
  • Tiptap 的变更进入 Yjs 文档
  • Hocuspocus 把更新同步给其他协作者
  • 后端记录 AI 操作审计、版本记录和 traceId

这样 AI 不是绕过协同系统直接改数据库,而是通过正常编辑链路进入文档。协同、权限、版本、审计都还能保持一致。

这对于 AI 写作平台、法律文档系统、知识库问答、项目文档自动整理都很重要。因为一旦 AI 修改绕开协同层,用户看到的实时状态、数据库保存的状态、AI 处理的状态很容易不一致。

协同编辑接 AI,最怕模型绕过用户确认直接写文档

很多 AI 文档产品做得不稳定,不是因为模型能力不够,而是因为写入链路太粗暴。

最常见的错误做法是:用户点一下生成,模型输出内容,后端直接更新数据库里的文档内容,然后前端再刷新。

这个做法在单人文档里也许能跑,但放到多人协同里很危险。

因为此时至少有三份状态:

  • 用户本地正在编辑的 Tiptap 状态
  • Hocuspocus 和 Yjs 维护的协同状态
  • 后端数据库里保存的文档状态

如果 AI 直接改数据库,就绕过了 Tiptap 和 Yjs。其他在线用户可能看不到这次修改,或者看到的内容和数据库不一致。用户正在编辑的局部内容也可能被 AI 覆盖。

更安全的方式是把 AI 当成一个候选修改生成器,而不是直接写入者。

AI 可以生成建议、摘要、改写结果、结构调整方案,但最终写入应该经过:

  • 权限判断
  • 选区定位
  • diff 展示
  • 用户确认
  • 编辑器写入
  • Yjs 合并
  • Hocuspocus 同步
  • 审计记录

这样设计会多一些步骤,但换来的是可回滚、可追踪、可协同、可解释。对于生产系统来说,这比一次生成直接覆盖文档要安全得多。

评论、版本和搜索不要强行塞进 Yjs 文档

协同文档系统后期最容易膨胀。正文刚做完,就会开始加评论、划词批注、版本历史、AI 摘要、全文搜索、RAG 检索、导出、权限审批。

这时候很容易产生一个错误直觉:既然 Yjs 能同步状态,那这些东西是不是也能放进 Yjs?

理论上能放,工程上不建议全部放。

正文、标题、列表、图片块这些内容属于协同文档主体,适合放进 Yjs。它们需要多人编辑、实时合并、断线重连后继续同步。

但评论、版本、审计、搜索索引这些数据,本质上不是同一种状态。

评论需要稳定定位和可追踪,不只是跟着正文一起漂移;版本历史需要可回滚和可对比;审计日志需要不可随意覆盖;搜索索引需要异步更新和重建;RAG 需要稳定引用原文位置和证据片段。

所以更合理的设计是:

  • 正文内容放进 Yjs
  • 评论单独建表,通过 anchor、position、blockId 或相对位置关联正文
  • 版本历史单独存快照、增量或业务变更记录
  • 搜索索引从正文派生,异步写入索引系统
  • AI 摘要和向量索引作为派生数据,可重新生成
  • 审计日志独立保存,不依赖协同文档本身

这能保证协同文档保持轻量,业务能力也有自己的治理空间。

Hocuspocus 的工程落地建议

如果要把 Hocuspocus 放进生产项目,我会优先关注这些点。

第一,文档命名要业务化。不要只用 room-1 这种临时名字,而是从一开始就用 workspace、documentId、tenantId 等业务信息组织 documentName。

第二,鉴权必须放在连接入口。用户能打开页面,不代表他一定能连接某篇协同文档。协同连接也要做权限校验,不能只靠前端控制按钮显示。

第三,Yjs binary 和业务派生数据分开存。前者用于协同恢复,后者用于搜索、预览、导出、AI 处理和版本展示。

第四,Awareness 只存临时在线状态。不要把评论、任务、审批、权限塞进去。

第五,提前设计大文档策略。一个超大 Y.Doc 会让同步、加载、保存、索引都变重。需要考虑按章节、页面、块或业务模块拆分。

第六,观测指标要从第一天接入。至少要记录连接数、房间数、同步延迟、加载耗时、保存耗时、重连次数、鉴权失败次数、单文档活跃用户数。

第七,AI 修改要走正常编辑链路。不要让 AI 直接改数据库里的文档内容,应该通过 diff、确认、写入编辑器、进入 Yjs、同步和审计这条链路完成。

第八,存储和同步要区分开。Redis 负责多实例消息传播,数据库或对象存储负责文档恢复,不要把 Redis 当成最终持久化。

第九,协同文档要做故障兜底。加载失败、保存失败、鉴权失败、同步异常都要有日志、告警和降级策略,不能让用户以为保存成功但后端实际失败。

第十,所有写入都要可观测。协同编辑不是普通接口请求,它是长连接上的连续状态变化,所以更需要 traceId、userId、workspaceId、documentId、连接 ID、房间名、保存耗时和错误原因。

总结

Hocuspocus 的核心价值,不是帮我们快速做一个多人同时输入的效果,而是给 Tiptap 和 Yjs 补上生产级协同同步底座。

它把客户端连接、Yjs 更新同步、Awareness、鉴权、加载、保存、多实例扩展这些能力收口到一套机制里,让前端不用自己发明同步协议,也让后端可以通过 hooks 把权限、租户、存储、审计接进去。

但真正用好 Hocuspocus,关键不是会写几行 Provider 代码,而是要把系统边界拆清楚:

  • Tiptap 负责编辑体验
  • Yjs 负责协同文档状态
  • Hocuspocus 负责实时同步和协同后端
  • 数据库负责持久化、业务查询和审计
  • Redis 负责多实例之间的状态传播
  • 搜索和 AI 系统使用派生数据,不直接替代协同源数据
  • 评论、版本、权限、审计要有独立的数据模型,不要全部塞进 Yjs

协同编辑最怕一开始只是追求能同步,后面才发现权限、版本、评论、搜索、AI、审计全都混在一起。

Hocuspocus 能解决协同同步的关键问题,但它不是万能后端。把它放在正确的位置上,配合清晰的数据边界、权限系统、持久化策略和可观测能力,Tiptap 协同编辑才有机会从 Demo 走向真正可维护的生产系统。