Hocuspocus:基于 Y.js 的实时协作后端详解
💡 **一句话理解:**Hocuspocus 是构建在 Y.js 之上的实时协作 WebSocket 后端。Y.js 负责无冲突地合并数据,Hocuspocus 负责连接、房间、同步、鉴权、在线状态、持久化和扩容。
1. 它能解决什么问题
传统编辑器通常把整篇 JSON 或 HTML 当作一个值保存。多人同时编辑时,后保存的人很容易覆盖先保存的人;仅靠 WebSocket 广播最新内容,也无法正确处理并发、断线和乱序消息。
Hocuspocus 主要解决以下问题:
- 多人实时编辑冲突:两个用户同时输入、删除或格式化内容时,由 Y.js 的 CRDT 更新自动合并,不依赖“最后写入者获胜”。
- 跨设备同步:同一账号在电脑、手机或多个标签页打开同一文档时,保持内容一致。
- 断线重连与离线编辑:客户端可先在本地修改,恢复连接后交换缺失更新并收敛到相同状态;配合 IndexedDB Provider 可实现更完整的离线优先体验。
- 协作者状态:同步在线用户、名称、颜色、光标与选区,即 Awareness / Presence。
- 协作文档鉴权:连接房间前校验 token、用户身份、文档权限,并可将连接设为只读。
- 文档持久化:从数据库加载 Y.js 状态,并在变更后以防抖方式保存。
- 多实例扩容:多个 Hocuspocus 实例之间通过 Redis 转发文档更新和 Awareness 状态。
- 复用现有业务系统:通过生命周期 Hooks 接入用户系统、权限服务、数据库、Webhook 和审计逻辑。
典型场景
- 类 Notion、Google Docs 的协作文档
- Tiptap / ProseMirror 富文本编辑器
- Monaco 代码编辑器、表格、白板、表单设计器
- 评论草稿、项目状态、应用配置等共享状态
- 需要“先离线工作、后自动同步”的应用
它不直接负责什么
Hocuspocus 不是富文本编辑器,也不是业务数据库。它不会替你提供工具栏、文档列表、成员管理、版本发布流程或业务权限模型。通常需要组合:
- Tiptap / ProseMirror:编辑器和文档 Schema
- Y.js:共享数据结构与冲突合并
- Hocuspocus Provider:浏览器端连接和协议
- Hocuspocus Server:WebSocket 协作后端
- PostgreSQL / S3 / SQLite 等:长期持久化
- Redis:多实例实时消息同步,不负责长期存储
2. 工作原理
- 每个客户端维护一个本地
Y.Doc。 - Tiptap 的 Collaboration 扩展把 ProseMirror 文档绑定到
Y.Doc中的共享 Fragment。 - 用户操作产生增量 Y.js Update,而不是反复发送整篇文档。
- Provider 通过 WebSocket 把 Update 发给 Hocuspocus Server。
- Server 将更新应用到房间文档并广播给其他连接。
- 其他客户端应用 Update;即使更新重复、延迟或乱序,最终也会收敛到一致状态。
- Server 在适当时机将 Y.js 二进制状态持久化。
🧠 **关键区别:**WebSocket 只解决“怎么传”;Y.js 解决“并发修改怎么合并”;Hocuspocus 把连接管理、协议、鉴权、生命周期和生产化能力封装起来。
3. 核心对象
| 对象 | 职责 |
|---|---|
Y.Doc | 一份协作文档的 CRDT 容器,可以包含 XML Fragment、Map、Array、Text 等共享类型。 |
documentName / name | 房间和文档标识,例如 tenant:42:document:1001;同名客户端会进入同一协作空间。 |
HocuspocusProvider | 客户端 Provider,负责连接、认证、同步、重连、Awareness 和多文档复用连接。 |
Hocuspocus Server | 服务端,管理 WebSocket、房间内存文档、Hooks、扩展及广播。 |
Awareness | 短暂的在线状态,如用户信息和光标;不应当作为业务数据持久化。 |
4. 最小可运行示例
4.1 安装服务端
Hocuspocus v4 需要 Node.js 22+。服务端与 Provider 应使用相互匹配的主版本。
npm install @hocuspocus/server
// server.ts
import { Server } from '@hocuspocus/server'
const server = new Server({
port: 1234,
})
server.listen()
启动后得到 ws://localhost:1234。这只适合验证同步,尚未加入鉴权和持久化。
4.2 安装客户端与 Tiptap 扩展
npm install \
yjs y-prosemirror \
@hocuspocus/provider \
@tiptap/core @tiptap/pm @tiptap/starter-kit \
@tiptap/extension-collaboration \
@tiptap/extension-collaboration-caret
import * as Y from 'yjs'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCaret from '@tiptap/extension-collaboration-caret'
import { HocuspocusProvider } from '@hocuspocus/provider'
const ydoc = new Y.Doc()
const provider = new HocuspocusProvider({
url: 'ws://localhost:1234',
name: 'document-1001',
document: ydoc,
token: 'your-access-token',
})
const editor = new Editor({
extensions: [
StarterKit.configure({
// Collaboration 自带基于 Y.js 的历史管理,避免与本地历史冲突
undoRedo: false,
}),
Collaboration.configure({
document: ydoc,
}),
CollaborationCaret.configure({
provider,
user: {
name: 'powerfulyang',
color: '#7c3aed',
},
}),
],
})
用两个浏览器窗口打开相同 name 的文档,即可观察实时同步;使用不同 name 则进入不同房间。
4.3 生命周期清理
在 React/Vue 组件卸载或切换文档时,应销毁旧实例,避免重复连接和监听器泄漏:
editor.destroy()
provider.destroy()
ydoc.destroy()
切换文档时,更稳妥的做法是为新文档建立新的 Y.Doc、Provider 和 Editor 实例,而不是把旧实例强行复用到另一个房间。
5. 生产环境鉴权
客户端发送 token:
const provider = new HocuspocusProvider({
url: 'wss://collab.example.com',
name: `tenant:${tenantId}:document:${documentId}`,
document: ydoc,
token: async () => getFreshAccessToken(),
})
服务端在 onAuthenticate 中校验“用户能否访问当前文档”,而不只是判断 token 是否有效:
import { Server } from '@hocuspocus/server'
const server = new Server({
port: 1234,
async onAuthenticate({ token, documentName, connection }) {
const user = await verifyAccessToken(token)
const permission = await getDocumentPermission(user.id, documentName)
if (!permission) {
throw new Error('Not authorized')
}
connection.readOnly = permission === 'read'
// 返回值会成为后续 hooks 可使用的 context
return {
userId: user.id,
tenantId: user.tenantId,
}
},
})
鉴权注意事项
- 不要仅信任客户端传来的
documentName、用户 ID 或只读标记。 - 在服务端同时验证租户、文档和用户权限,防止通过猜测房间名越权访问。
- 生产环境使用
wss://,并限制允许的 Origin。 - 短期 token 过期后需要刷新;v4 可通过 token 同步机制重新校验并更新只读权限。
- 不要把敏感业务信息放进 Awareness,因为它会广播给房间中的其他客户端。
6. 文档持久化
6.1 使用 Hooks
import * as Y from 'yjs'
import { Server } from '@hocuspocus/server'
const server = new Server({
port: 1234,
async onLoadDocument({ documentName }) {
const binary = await storage.get(documentName)
const document = new Y.Doc()
if (binary) {
Y.applyUpdate(document, binary)
}
return document
},
async onStoreDocument({ documentName, document }) {
const binary = Y.encodeStateAsUpdate(document)
await storage.set(documentName, Buffer.from(binary))
},
})
onStoreDocument 是适合保存文档的防抖 Hook;不要在每个字符变化时同步写数据库。也可以使用官方 Database、SQLite 或 S3 扩展。
6.2 应该存什么
优先保存 Y.encodeStateAsUpdate(document) 得到的 Uint8Array / 二进制数据。加载时取回同一份 Y.js 状态并应用到 Y.Doc。
不要把协作数据只当成普通 JSON 循环覆盖,否则可能:
- 丢失 CRDT 历史与状态向量信息
- 在初始化和同步时重复插入内容
- 无法正确合并离线期间产生的更新
如果业务需要全文检索、预览、导出或服务端渲染,可以额外生成 Tiptap JSON / HTML 作为派生快照,但应明确哪一份数据是协作真相源。
6.3 初始化旧数据
已有 JSON / HTML 内容迁移到协作模型时,只应在文档尚不存在时转换一次,例如使用 Tiptap / ProseMirror Transformer 生成初始 Y.Doc。不要让每个新连接都重复把旧 JSON 写入 Y.Doc。
7. Awareness:用户与光标
Awareness 用于易失状态:
provider.setAwarenessField('user', {
id: currentUser.id,
name: currentUser.name,
color: '#0ea5e9',
})
适合放入:
- 用户显示名、头像和颜色
- 当前光标或选区
- 当前查看的子页面、编辑模式
- “正在输入”等瞬时状态
不适合放入:权限真相、文档内容、审批状态等必须持久化和可信的数据。用户断开后,对应 Awareness 状态会消失。
8. 离线优先
Hocuspocus 负责重新联网后的服务端同步;如果希望刷新页面甚至关闭浏览器后仍保留本地文档,可增加 y-indexeddb:
npm install y-indexeddb
import { IndexeddbPersistence } from 'y-indexeddb'
const localPersistence = new IndexeddbPersistence(
'document-1001',
ydoc,
)
await localPersistence.whenSynced
同一个 Y.Doc 可以同时连接 IndexedDB Provider 与 Hocuspocus Provider:本地缓存负责离线恢复,WebSocket Provider 负责远程同步。
首次渲染时要区分:
- 本地 IndexedDB 是否已恢复
- WebSocket 是否已连接
- 服务端文档是否已完成同步
否则可能在同步完成前错误地展示“空文档”或执行初始化逻辑。
9. 多实例与 Redis
当单实例无法承载连接数、需要高可用或滚动发布时,可在负载均衡器后运行多个实例,并加入 Redis 扩展:
npm install @hocuspocus/extension-redis
Redis 负责让连接在不同实例上的用户仍能收到彼此的更新和 Awareness 消息。
⚠️ **Redis 扩展不负责持久化文档。**仍需 Database / S3 / SQLite 等持久化方案。并且消息会被所有相连实例处理;若主要瓶颈是 CPU 或内存,可考虑按
documentName分片到彼此独立的实例,而不是让所有实例共享全部消息。
10. React 使用建议
Hocuspocus v4 提供 @hocuspocus/provider-react,可管理 WebSocket 连接、房间生命周期,并订阅连接、同步和 Awareness 状态。对于 React 18/19 和 Strict Mode,它通常比在组件渲染过程中手动 new HocuspocusProvider() 更安全。
如果手动管理实例:
- 在
useMemo或专门的生命周期层创建,而不是每次 render 都创建。 - Effect cleanup 中销毁 Provider。
documentId改变时彻底重建当前房间实例。- 避免多个 Editor 误用同一个可变 Provider 状态。
- UI 应区分
connecting、connected、synced、disconnected和error。
11. 常见坑
11.1 把初始 content 和 Collaboration 同时当作真相源
协作文档应由 Y.Doc 提供内容。若每个客户端启动时又向编辑器传入相同初始 content,可能出现重复内容或覆盖时序问题。初始化只做一次,之后从持久化的 Y.Doc 加载。
11.2 同时启用两套 Undo / Redo
Tiptap Collaboration 使用 Y.js 的协作历史。StarterKit 自带的本地 UndoRedo 应关闭,否则撤销栈可能互相冲突。
11.3 保存 JSON 而不是 Y.js 二进制状态
JSON 适合展示和业务读取,但不能完整替代 CRDT 状态。把二进制 Y.js 状态作为协作真相源,JSON/HTML 作为派生数据。
11.4 documentName 设计不安全
不要只用可枚举的数字 ID。推荐包含租户与资源类型,并始终在服务端做授权:
tenant:{tenantId}:document:{documentId}
房间名是路由标识,不是权限机制。
11.5 在 Serverless Function 中直接启动长连接服务
Hocuspocus 需要稳定的 WebSocket 长连接和进程生命周期。普通按请求启动、随时冻结的函数环境通常不合适。应使用支持 WebSocket 的常驻服务、容器或受支持的边缘运行环境,并确认平台限制。
11.6 把 Redis 当数据库
Redis 扩展只转发多实例消息。没有长期存储时,所有实例重启后文档仍可能丢失。
11.7 客户端过早执行初始化
connected 只代表 WebSocket 已连接,不一定代表文档已同步。依赖完整内容的逻辑应等待同步状态。
11.8 Schema 不一致
协作用户必须使用兼容的 Tiptap / ProseMirror Schema。若不同版本客户端对节点和属性理解不同,可能丢失或无法渲染内容。发布新 Schema 时应制定向后兼容与迁移策略。
11.9 未限制大文档和恶意连接
生产环境应考虑 token 校验、Origin、连接数、消息大小、文档大小、速率限制、超时、日志和异常隔离。
12. 什么时候选 Hocuspocus
适合选择:
- 已使用或计划使用 Tiptap、ProseMirror、Y.js
- 需要自托管和掌控数据
- 需要鉴权、持久化 Hook、Awareness 与横向扩展
- 不想自行实现 Y.js WebSocket 协议和房间生命周期
可能不必选择:
- 只有单用户编辑和普通自动保存
- 只需要服务器向客户端单向推送通知
- 团队希望完全托管,不愿运维 WebSocket 与数据库
- 业务要求严格的“锁定后才能编辑”,而非并发合并;此时应优先实现锁和审批流程
与其他方案的区别
| 方案 | 适合场景 | 特点 |
|---|---|---|
| Hocuspocus | 生产级、自托管 Y.js 协作后端 | Hooks、鉴权、持久化扩展、Awareness、Redis 扩容 |
y-websocket 基础服务 | 原型、学习或自行二次开发 | 更轻量,但生产能力通常需要自己补齐 |
| WebRTC Provider | 小规模 P2P 尝试 | 客户端之间传输,服务端可见性与集中控制较弱 |
| Tiptap Collab | 希望使用托管服务 | 减少自建与运维成本 |
13. 推荐落地路线
- 验证同步:单实例 + 内存文档,两个浏览器测试并发编辑。
- 确定文档标识:设计带租户边界的
documentName。 - 接入鉴权:实现
onAuthenticate,覆盖无权限、只读、token 过期。 - 接入持久化:保存并恢复 Y.js 二进制状态,测试进程重启。
- 完善编辑器:Collaboration、CollaborationCaret、协作 Undo/Redo。
- 测试异常网络:断网编辑、乱序、刷新、重复连接、长时间离线。
- 加入可观测性:连接数、房间数、同步耗时、保存失败、文档大小。
- 压力与故障测试:大文档、多协作者、滚动发布和实例故障。
- 按瓶颈扩容:需要跨实例消息时用 Redis;CPU/内存问题可按文档分片。
14. 官方资料
**版本备注:**本文按照 2026 年 7 月可用的 Hocuspocus v4 文档整理;升级时应检查 v4 Release Notes、Node.js 版本要求,以及 Server、Provider 与扩展包的版本兼容性。