Skip to content

给你的图形编辑器加入协作:Hocuspocus + Yjs 三步走

如果你正在维护一款图形编辑器,无论是流程图、白板还是 CAD 工具,迟早都会遇到同一个需求:多人实时协作。这个需求听起来简单,实际落地时却容易在数据同步、冲突解决和状态一致性上踩坑。本文介绍一种经过验证的轻量方案:用 Hocuspocus 作为协作后端,Yjs 作为数据结构层,把图形编辑器的单机状态改造成可多人同时编辑的协作状态。

1. 第一步:搭一个最小可用的协作后端

Hocuspocus 的定位很直接:一个基于 Yjs 的即插即用协作后端[1]。你不需要自己处理 WebSocket 连接管理、消息广播或冲突合并,Hocuspocus 把这些都封装好了。

1.1 最小服务端

一个能跑起来的 Hocuspocus 服务端只需要几行代码。它暴露 Server 类,示例中使用 port: 1234,并提供 onConnect 钩子做鉴权或初始化;持久化则在 1.2 节通过扩展来配置:

javascript
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]

javascript
import { SQLite } from "@hocuspocus/extension-sqlite";

const server = new Server({
  port: 1234,
  extensions: [
    new SQLite({
      database: "collab.sqlite",
    }),
  ],
});

2. 第二步:在客户端把图形数据建模成 Yjs 共享类型

Yjs 的核心抽象是共享类型(shared types)。它们的行为类似 JavaScript 的 ArrayMapSet,但会自动同步和持久化,并通过 provider 在多个客户端之间保持一致[3]

2.1 为什么图形编辑器适合用 Y.Map

图形编辑器里的元素通常有唯一 ID,例如节点 node-1node-2。Synergy Codes 在 React Flow 与 Yjs 的集成实践中明确推荐:用 Y.Map 存储节点和边[4]Map 的优势在于可以精确修改单个字段,而不需要替换整个数组;这在多人同时拖拽不同节点时,能显著降低冲突面积。

一个典型的图形文档结构可以这样设计:

javascript
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.ArrayY.MapY.Text 等,都支持 observeDeep 监听深层变化[3:1]。这意味着你可以监听整棵图形树的改动,而不必给每个节点单独绑事件。

2.2 连接 Hocuspocus Provider

客户端通过 HocuspocusProvider 把本地 Y.Doc 与服务端打通。Provider 的配置项包括 urlnamedocumenttokenawareness[5]

javascript
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 触发 observeobserveDeep 事件;画布层只负责监听这些事件并把变化反映到屏幕上。光标、选区、拖拽中的临时反馈这类协作状态,继续交给 Awareness 或本地状态处理。

javascript
// 用户拖拽节点时,直接写 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]。这样做有三个好处:

  1. 减少观察者回调次数:被包裹的多次修改只会触发一次 observeDeep,而不是每次 set 都触发。
  2. 降低网络更新消息量:Yjs 会把事务内的所有改动打包成一条更新消息发送,而不是逐条广播。
  3. 定义清晰的撤销/重做边界:一次事务对应用户的一个完整操作意图,撤销栈会更自然。
javascript
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]。如果你写了类似下面的代码,会发现其他客户端收不到变化:

javascript
// 错误示范
const json = node.toJSON();
json.x = 999; // 这不会触发任何同步

正确的做法始终是通过共享类型的 API(setdeletepush 等)来修改数据。

4. Awareness:让协作者“看得见彼此”

实时协作不只是同步数据,还需要让用户感知到其他人的存在。Hocuspocus 内置了 Awareness 机制,专门用于传播临时的、非持久化的用户状态,例如光标位置、选区或视口坐标[8]

4.1 设置和监听 Awareness 字段

客户端可以主动上报自己的状态,也可以监听其他人的状态变化:

javascript
// 设置当前用户信息
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. 最小可运行示例

把前面的片段拼起来,一个最小协作图形编辑器的骨架如下:

javascript
// 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();
javascript
// 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. 架构概览

把各层职责梳理清楚后,整体架构可以用下面这张图表示:

关键设计决策:

  1. Yjs 是持久图数据的唯一真相源。画布不维护独立状态树,只负责渲染。
  2. Hocuspocus 只做传输和持久化,不碰业务逻辑。
  3. Awareness 与文档数据分离,前者传临时状态,后者传持久状态。

7. 常见陷阱与规避建议

7.1 不要移动已经集成到文档的共享类型

Yjs 的共享类型一旦通过 yNodes.set("node-1", node) 放进文档,就不能再把它移动到另一个父容器里[3:3]。如果你需要重组层级,应该先在新位置创建副本、复制数据,再删除旧节点。

7.2 避免在 observe 回调里再次修改 Yjs

observeDeep 回调里如果再次调用 setdelete,可能触发级联更新,甚至无限循环。如果必须在监听中写回数据,建议用标志位或事务标签做短路保护。

7.3 不要把 Awareness 当持久存储用

Awareness 状态不会落盘,也不会在重新连接后恢复。用户的昵称、角色等长期属性应该写进 Yjs 文档的某个配置节点,而不是 Awareness。

7.4 注意 provider 的销毁时机

组件卸载或页面关闭时,应调用 provider.destroy()ydoc.destroy(),否则 WebSocket 连接和 Awareness 心跳会持续占用资源。

8. 结语

给图形编辑器加入协作能力,本质上不是“加一个 WebSocket 广播层”那么简单。真正的难点在于:多人同时修改同一批图形对象时,如何保证状态一致、冲突可解、用户体验自然。Hocuspocus 和 Yjs 的组合把这个问题拆解成了清晰的三层:后端传输、CRDT 数据结构、前端绑定。只要守住“Yjs 是持久图数据的唯一真相源”这条原则,并合理使用 Awareness 传递临时状态,就能在不大改现有编辑器架构的前提下,把单机工具升级为协作工具。

参考文献


  1. ueberdosis/hocuspocus 仓库 README.mdhttps://github.com/ueberdosis/hocuspocus ↩︎ ↩︎

  2. Hocuspocus Persistence 指南。 https://tiptap.dev/docs/hocuspocus/guides/persistence ↩︎ ↩︎

  3. Yjs Working with Shared Types 文档。 https://docs.yjs.dev/getting-started/working-with-shared-types ↩︎ ↩︎ ↩︎ ↩︎

  4. 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 ↩︎

  5. Hocuspocus Provider Configuration 文档。 https://tiptap.dev/docs/hocuspocus/provider/configuration ↩︎

  6. Yjs 论坛帖子「Sync between the text editor and Yjs doc」。 https://discuss.yjs.dev/t/sync-between-the-text-editor-and-yjs-doc/1126 ↩︎

  7. Yjs 官方文档中关于 Transactions 的说明。 https://docs.yjs.dev/getting-started/working-with-shared-types ↩︎ ↩︎

  8. Hocuspocus Awareness 指南。 https://tiptap.dev/docs/hocuspocus/guides/awareness ↩︎ ↩︎