给你的图形编辑器加入协作:Hocuspocus + Yjs 三步走
如果你正在维护一款图形编辑器,无论是流程图、白板还是 CAD 工具,迟早都会遇到同一个需求:多人实时协作。这个需求听起来简单,实际落地时却容易在数据同步、冲突解决和状态一致性上踩坑。本文介绍一种经过验证的轻量方案:用 Hocuspocus 作为协作后端,Yjs 作为数据结构层,把图形编辑器的单机状态改造成可多人同时编辑的协作状态。
1. 第一步:搭一个最小可用的协作后端
Hocuspocus 的定位很直接:一个基于 Yjs 的即插即用协作后端[1]。你不需要自己处理 WebSocket 连接管理、消息广播或冲突合并,Hocuspocus 把这些都封装好了。
1.1 最小服务端
一个能跑起来的 Hocuspocus 服务端只需要几行代码。它暴露 Server 类,示例中使用 port: 1234,并提供 onConnect 钩子做鉴权或初始化;持久化则在 1.2 节通过扩展来配置:
import { Server } from "@hocuspocus/server";
const server = new Server({
port: 1234,
async onConnect(data) {
// 可在此处校验 token 或拒绝未授权连接
console.log("Client connected:", data.requestParameters);
},
});
server.listen();这段代码已经具备接收多个客户端、维护文档状态、自动合并并发修改的能力[1:1]。
1.2 持久化配置
生产环境通常需要把文档落盘。Hocuspocus 的持久化层支持数据库扩展,包括 SQLite 在内[2]。一个关键原则是:主存储应保留 Yjs 的二进制更新记录,而不是每次从 JSON 重建文档。Yjs 的二进制更新格式自带增量合并能力,反复从 JSON 重建会丢失版本历史,也让增量同步失去意义[2:1]。
import { SQLite } from "@hocuspocus/extension-sqlite";
const server = new Server({
port: 1234,
extensions: [
new SQLite({
database: "collab.sqlite",
}),
],
});2. 第二步:在客户端把图形数据建模成 Yjs 共享类型
Yjs 的核心抽象是共享类型(shared types)。它们的行为类似 JavaScript 的 Array、Map、Set,但会自动同步和持久化,并通过 provider 在多个客户端之间保持一致[3]。
2.1 为什么图形编辑器适合用 Y.Map
图形编辑器里的元素通常有唯一 ID,例如节点 node-1、node-2。Synergy Codes 在 React Flow 与 Yjs 的集成实践中明确推荐:用 Y.Map 存储节点和边[4]。Map 的优势在于可以精确修改单个字段,而不需要替换整个数组;这在多人同时拖拽不同节点时,能显著降低冲突面积。
一个典型的图形文档结构可以这样设计:
import * as Y from "yjs";
const ydoc = new Y.Doc();
// 顶层用 Map 容纳所有节点
const yNodes = ydoc.getMap("nodes");
// 每个节点本身也是一个 Y.Map,便于细粒度修改
const node1 = new Y.Map();
node1.set("x", 100);
node1.set("y", 200);
node1.set("label", "Start");
node1.set("type", "rectangle");
yNodes.set("node-1", node1);Yjs 提供的共享类型包括 Y.Array、Y.Map、Y.Text 等,都支持 observeDeep 监听深层变化[3:1]。这意味着你可以监听整棵图形树的改动,而不必给每个节点单独绑事件。
2.2 连接 Hocuspocus Provider
客户端通过 HocuspocusProvider 把本地 Y.Doc 与服务端打通。Provider 的配置项包括 url、name、document、token 和 awareness 等[5]:
import { HocuspocusProvider } from "@hocuspocus/provider";
const provider = new HocuspocusProvider({
url: "ws://localhost:1234",
name: "my-graph-document",
document: ydoc,
token: "user-token-optional",
});name 对应服务端上的文档标识,不同 name 之间状态隔离。document 就是你本地创建的 Y.Doc 实例。连接建立后,Yjs 会自动处理增量同步,并在重连后补齐状态。
3. 第三步:建立画布与 Yjs 的双向绑定
这是最容易出错的地方。很多开发者会本能地维护两套状态:一套是图形引擎自己的对象树,另一套是 Yjs 的共享类型,然后试图在两边做双向同步。这种设计很快就会陷入“谁才是真相源”的泥潭。
3.1 让 Yjs 成为持久图数据的唯一真相源
Yjs 社区的核心维护者 Kevin Jahns(dmonad)在论坛中明确建议:对于自定义绘图应用,应该让 Yjs 成为持久图数据的唯一真相源[6]。具体做法是:用户的每一次绘图操作直接修改 Yjs 文档;Yjs 触发 observe 或 observeDeep 事件;画布层只负责监听这些事件并把变化反映到屏幕上。光标、选区、拖拽中的临时反馈这类协作状态,继续交给 Awareness 或本地状态处理。
// 用户拖拽节点时,直接写 Yjs
function onNodeDrag(nodeId, newX, newY) {
const node = yNodes.get(nodeId);
if (node) {
node.set("x", newX);
node.set("y", newY);
}
}
// 画布层只监听 Yjs 事件,不维护独立状态
yNodes.observeDeep((events) => {
for (const event of events) {
if (event.target instanceof Y.Map) {
const nodeId = event.path[event.path.length - 1];
const changedKeys = event.keysChanged;
// 根据 changedKeys 刷新对应节点的渲染
renderNode(nodeId, event.target.toJSON());
}
}
});这种单向数据流避免了“本地状态覆盖远程状态”的经典竞态问题。
3.2 用显式事务减少更新抖动
Yjs 的所有修改都会隐式进入事务,但当你需要一次性修改多个相关字段时,建议用显式的 ydoc.transact(() => ...) 把它们包起来[7]。这样做有三个好处:
- 减少观察者回调次数:被包裹的多次修改只会触发一次
observeDeep,而不是每次set都触发。 - 降低网络更新消息量:Yjs 会把事务内的所有改动打包成一条更新消息发送,而不是逐条广播。
- 定义清晰的撤销/重做边界:一次事务对应用户的一个完整操作意图,撤销栈会更自然。
ydoc.transact(() => {
const node = yNodes.get("node-1");
node.set("x", 150);
node.set("y", 250);
node.set("width", 120);
}, "resize-node"); // 可选标签,用于调试或撤销描述需要注意的是,显式事务是推荐做法,而不是同步的强制前提。Yjs 的隐式事务已经保证了基础一致性,显式事务只是在性能和语义边界上做了优化[7:1]。
3.3 不要直接修改从 Yjs 取出的 JSON
Y.Map.toJSON() 返回的是普通 JavaScript 对象,但修改这个对象不会同步回 Yjs 文档[3:2]。如果你写了类似下面的代码,会发现其他客户端收不到变化:
// 错误示范
const json = node.toJSON();
json.x = 999; // 这不会触发任何同步正确的做法始终是通过共享类型的 API(set、delete、push 等)来修改数据。
4. Awareness:让协作者“看得见彼此”
实时协作不只是同步数据,还需要让用户感知到其他人的存在。Hocuspocus 内置了 Awareness 机制,专门用于传播临时的、非持久化的用户状态,例如光标位置、选区或视口坐标[8]。
4.1 设置和监听 Awareness 字段
客户端可以主动上报自己的状态,也可以监听其他人的状态变化:
// 设置当前用户信息
provider.setAwarenessField("user", {
name: "Alice",
color: "#ff0000",
cursor: { x: 120, y: 340 },
});
// 监听所有协作者的状态变化
provider.on("awarenessUpdate", ({ states }) => {
states.forEach((state, clientId) => {
if (state.user) {
updateRemoteCursor(clientId, state.user.cursor);
}
});
});Awareness 的设计非常灵活。文档中的例子包括共享用户名、光标位置,甚至是在复杂 3D 场景中传播坐标[8:1]。对于图形编辑器,最常见的用法是显示其他用户的鼠标位置和当前选中的元素。
4.2 Awareness 与文档数据的区别
| 维度 | Yjs 文档数据 | Awareness 状态 |
|---|---|---|
| 持久化 | 会落盘、会同步到所有客户端 | 仅内存中,掉线即消失 |
| 冲突策略 | CRDT 自动合并 | 最后写入优先 |
| 典型用途 | 节点坐标、图形属性 | 光标位置、用户昵称、在线状态 |
把这两者分开使用,可以避免“光标位置被写进历史记录”这类尴尬问题。
5. 最小可运行示例
把前面的片段拼起来,一个最小协作图形编辑器的骨架如下:
// server.js
import { Server } from "@hocuspocus/server";
import { SQLite } from "@hocuspocus/extension-sqlite";
const server = new Server({
port: 1234,
extensions: [new SQLite({ database: "collab.sqlite" })],
});
server.listen();// client.js
import * as Y from "yjs";
import { HocuspocusProvider } from "@hocuspocus/provider";
const ydoc = new Y.Doc();
const provider = new HocuspocusProvider({
url: "ws://localhost:1234",
name: "graph-doc",
document: ydoc,
});
const yNodes = ydoc.getMap("nodes");
// 监听所有深层变化并刷新画布
yNodes.observeDeep((events) => {
for (const event of events) {
if (event.target instanceof Y.Map) {
const nodeId = event.path[event.path.length - 1];
renderNode(nodeId, event.target.toJSON());
}
}
});
// 用户操作时直接写 Yjs
function createNode(id, x, y, label) {
ydoc.transact(() => {
const node = new Y.Map();
node.set("x", x);
node.set("y", y);
node.set("label", label);
yNodes.set(id, node);
}, "create-node");
}
function moveNode(id, x, y) {
ydoc.transact(() => {
const node = yNodes.get(id);
if (node) {
node.set("x", x);
node.set("y", y);
}
}, "move-node");
}
// Awareness:显示其他用户光标
provider.setAwarenessField("user", {
name: "Alice",
color: "#ff0000",
});
provider.on("awarenessUpdate", ({ states }) => {
states.forEach((state, clientId) => {
if (state.user && clientId !== provider.awareness.clientID) {
drawRemoteCursor(state.user);
}
});
});这个骨架没有绑定具体图形引擎,你可以把它接到 SVG、Canvas 或 React Flow 之上。
6. 架构概览
把各层职责梳理清楚后,整体架构可以用下面这张图表示:
关键设计决策:
- Yjs 是持久图数据的唯一真相源。画布不维护独立状态树,只负责渲染。
- Hocuspocus 只做传输和持久化,不碰业务逻辑。
- Awareness 与文档数据分离,前者传临时状态,后者传持久状态。
7. 常见陷阱与规避建议
7.1 不要移动已经集成到文档的共享类型
Yjs 的共享类型一旦通过 yNodes.set("node-1", node) 放进文档,就不能再把它移动到另一个父容器里[3:3]。如果你需要重组层级,应该先在新位置创建副本、复制数据,再删除旧节点。
7.2 避免在 observe 回调里再次修改 Yjs
observeDeep 回调里如果再次调用 set 或 delete,可能触发级联更新,甚至无限循环。如果必须在监听中写回数据,建议用标志位或事务标签做短路保护。
7.3 不要把 Awareness 当持久存储用
Awareness 状态不会落盘,也不会在重新连接后恢复。用户的昵称、角色等长期属性应该写进 Yjs 文档的某个配置节点,而不是 Awareness。
7.4 注意 provider 的销毁时机
组件卸载或页面关闭时,应调用 provider.destroy() 和 ydoc.destroy(),否则 WebSocket 连接和 Awareness 心跳会持续占用资源。
8. 结语
给图形编辑器加入协作能力,本质上不是“加一个 WebSocket 广播层”那么简单。真正的难点在于:多人同时修改同一批图形对象时,如何保证状态一致、冲突可解、用户体验自然。Hocuspocus 和 Yjs 的组合把这个问题拆解成了清晰的三层:后端传输、CRDT 数据结构、前端绑定。只要守住“Yjs 是持久图数据的唯一真相源”这条原则,并合理使用 Awareness 传递临时状态,就能在不大改现有编辑器架构的前提下,把单机工具升级为协作工具。
参考文献
ueberdosis/hocuspocus仓库README.md。 https://github.com/ueberdosis/hocuspocus ↩︎ ↩︎Hocuspocus Persistence 指南。 https://tiptap.dev/docs/hocuspocus/guides/persistence ↩︎ ↩︎
Yjs Working with Shared Types 文档。 https://docs.yjs.dev/getting-started/working-with-shared-types ↩︎ ↩︎ ↩︎ ↩︎
Synergy Codes,「Real-time collaboration for multiple users in React Flow projects with Yjs」。 https://www.synergycodes.com/blog/real-time-collaboration-for-multiple-users-in-react-flow-projects-with-yjs-e-book ↩︎
Hocuspocus Provider Configuration 文档。 https://tiptap.dev/docs/hocuspocus/provider/configuration ↩︎
Yjs 论坛帖子「Sync between the text editor and Yjs doc」。 https://discuss.yjs.dev/t/sync-between-the-text-editor-and-yjs-doc/1126 ↩︎
Yjs 官方文档中关于 Transactions 的说明。 https://docs.yjs.dev/getting-started/working-with-shared-types ↩︎ ↩︎
Hocuspocus Awareness 指南。 https://tiptap.dev/docs/hocuspocus/guides/awareness ↩︎ ↩︎