Создание совместных мультиплеерных холстов (в стиле Figma, Miro или Whimsical) предъявляет жесткие требования к архитектуре синхронизации состояния. Традиционная клиент-серверная модель с централизованной базой данных и отправкой REST/WebSocket событий (NODE_MOVED, EDGE_CREATED) оказывается непригодной при масштабировании:
Современным промышленным стандартом решения этих проблем является использование CRDT на базе библиотеки Yjs.
В связке с @xyflow/react архитектура строится на строгом разделении:
Y.Doc с использованием Y.Map (для словаря узлов по ключу id) и Y.Array (для упорядоченного списка ребер). Любая операция над графом оборачивается в атомарную транзакцию doc.transact().lockedBy: clientId) через легковесный бинарный протокол поверх WebRTC или WebSocket без записи на диск.АРХИТЕКТУРА МУЛЬТИПЛЕЕРНОГО ХОЛСТА НА БАЗЕ YJS:
┌────────────────────────────────────────────────────────────────────────┐
│ КЛИЕНТ А (Browser Tab 1) КЛИЕНТ Б (Browser Tab 2) │
│ ├─ React Flow Canvas ├─ React Flow Canvas │
│ ├─ Y.Doc (CRDT In-Memory) ├─ Y.Doc (CRDT In-Memory) │
│ │ ├─ yNodes: Y.Map<Node> │ ├─ yNodes: Y.Map<Node> │
│ │ └─ yEdges: Y.Array<Edge> │ └─ yEdges: Y.Array<Edge> │
│ └─ Awareness (Cursors, Locks) └─ Awareness (Cursors, Locks) │
└────────────────────────────────────────────────────────────────────────┘
▲ ▲
│ Binary Encodings (lib0) │
▼ ▼
┌────────────────────────────────────────────────────────────────────────┐
│ СЕТЕВОЙ ТРАНСПОРТ: Y-WEBSOCKET / Y-WEBRTC │
│ ├─ Stateful WebSocket Server (Node.js/Bun) / P2P WebRTC Mesh │
│ ├─ Персистентность: LevelDB / PostgreSQL / S3 Snapshot │
│ └─ Рассылка бинарных дельт (State Vectors) │
└────────────────────────────────────────────────────────────────────────┘В графовых приложениях узлы и ребра обладают принципиально разной семантикой доступа:
Y.Map<Node>: Каждый узел идентифицируется уникальным строковым ключом node.id. Это обеспечивает прямой доступ $O(1)$ при перемещении конкретной вершины и гарантирует, что одновременное изменение свойств разных узлов клиентами А и Б объединяется без конфликтов.Y.Array<Edge> или Y.Map<Edge>: При использовании Y.Map для ребер ключ edge.id исключает дублирование связей при параллельном соединении портов.Каждый клиент обладает уникальным идентификатором clientID. При изменении узла Yjs генерирует бинарную дельту (Update), содержащую монотонно возрастающий логический счетчик времени (Lamport Timestamp).
При выполнении сложного действия (например, удаление узла вместе со всеми привязанными к нему ребрами) все изменения должны применяться атомарно. Для этого используется метод doc.transact(() => { ... }, origin):
origin маркирует источник изменений (например, origin = 'local'). Это позволяет React Flow различать локальные мутации пользователя от внешних обновлений, пришедших по сети, предотвращая бесконечные циклы обратной связи (Feedback Loops).Если два пользователя начнут одновременно перетаскивать один и тот же узел в противоположные стороны, победит последний полученный пакет (Last-Write-Wins внутри CRDT). Для предотвращения «перетягивания каната» внедряется механизм превентивной блокировки:
onNodeDragStart клиент публикует в Awareness состояние: locks: { [nodeId]: myClientId }.isLocked = true), отключая возможность его перетаскивания и отображая имя захватившего пользователя.onNodeDragStop или при отключении клиента (тайм-аут Awareness Heartbeat 30 секунд) блокировка автоматически снимается.СХЕМА РАЗРЕШЕНИЯ КОЛЛИЗИЙ И БЛОКИРОВКИ УЗЛА:
Клиент А (Драг узла) Клиент Б (Попытка захвата)
│ │
├─ Awareness.setLocalState({ │
│ lockedNode: "node-42" │
│ }) ──────────────────────────────► │
│ ├─ Узел "node-42" заблокирован!
├─ doc.transact(() => { │ (nodrag включен, рамка с именем А)
│ yNodes.set("node-42", pos) │
│ }) ──────────────────────────────► ├─ Позиция узла обновлена плавно
│ │
├─ Drag End: │
│ Awareness.setLocalState({ │
│ lockedNode: null │
│ }) ──────────────────────────────► ├─ Блокировка снята, узел свободенСоздается локальный CRDT-документ Y.Doc и настраивается WebSocket-соединение с резервным каналом WebRTC.
Настраивается слушатель yNodes.observeDeep(), транслирующий внешние изменения в setNodes без потери фокуса ввода.
Событие onPaneMouseMove транслирует мировые координаты курсора (screenToFlowCoordinate) в канал присутствия с троттлингом 30 мс.
Добавление, перемещение и удаление узлов выполняются через doc.transact(), гарантируя согласованность топологии при реконнекте.
Зачем в методе doc.transact(fn, origin) передается параметр origin (например, строка 'local' или ссылка на локальный инстанс)?
Системные архитекторы одновременно проектируют топологию распределенной системы. Разработчик А связывает ребрами Kafka и Consumer-сервисы, пока Разработчик Б настраивает спецификацию API в инспекторе параметров узла. Мультикурсоры с именами и аватарами предотвращают случайное пересечение рабочих областей.
Data Science команды строят графы предобработки фичей и обучения моделей. Механизм Node Locking предотвращает параллельный запуск и изменение гиперпараметров обучающего узла двумя инженерами одновременно.
UX-дизайнеры и аналитики связывают экраны мобильного приложения. Поддержка автономного Offline-режима позволяет инженеру продолжать работу в поезде или самолете: при появлении сети Yjs автоматически выполняет бесконфликтное слияние локальных дельт с общим документом команды.
Группа реагирования на инциденты (SOC Incident War Room) локализует атаку. На графе сети аналитики в реальном времени подсвечивают скомпрометированные серверы, прикрепляют стикеры заметок и передают друг другу управление фокусом холста через Follow Mode (следование за камерой ведущего).
Построим законченную систему совместного редактирования графа на React Flow, Yjs, WebSocket-провайдере и протоколе Awareness.
export interface UserPresence {
id: string;
name: string;
color: string;
// Координаты курсора в мировом пространстве графа (Graph Canvas Space)
cursor: { x: number; y: number } | null;
// ID узла, который пользователь в данный момент перетаскивает
lockedNodeId: string | null;
lastActive: number;
}
export interface ClientAwarenessState {
user: UserPresence;
}import { useEffect, useRef, useState, useCallback } from 'react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import type { Node, Edge, NodeChange, EdgeChange } from '@xyflow/react';
import { applyNodeChanges, applyEdgeChanges } from '@xyflow/react';
import type { UserPresence, ClientAwarenessState } from '@/types/collaboration';
const WEBSOCKET_SERVER_URL = 'wss://yjs.internal.enterprise/ws';
const SYNC_ORIGIN_LOCAL = 'local-react-flow';
export function useYjsCollaboration(roomId: string, currentUser: Omit<UserPresence, 'cursor' | 'lockedNodeId' | 'lastActive'>) {
const [nodes, setNodes] = useState<Node[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const [collaborators, setCollaborators] = useState<UserPresence[]>([]);
const ydocRef = useRef<Y.Doc | null>(null);
const providerRef = useRef<WebsocketProvider | null>(null);
useEffect(() => {
const ydoc = new Y.Doc();
ydocRef.current = ydoc;
// Y.Map для узлов по ID, Y.Map для ребер по ID
const yNodes = ydoc.getMap<Node>('flow_nodes');
const yEdges = ydoc.getMap<Edge>('flow_edges');
const provider = new WebsocketProvider(WEBSOCKET_SERVER_URL, roomId, ydoc);
providerRef.current = provider;
// 1. Инициализация Awareness (Курсоры и блокировки)
const awareness = provider.awareness;
awareness.setLocalStateField('user', {
...currentUser,
cursor: null,
lockedNodeId: null,
lastActive: Date.now(),
} satisfies UserPresence);
// Слушатель изменения состояний участников
const handleAwarenessChange = () => {
const states = awareness.getStates() as Map<number, ClientAwarenessState>;
const activeUsers: UserPresence[] = [];
states.forEach((state, clientId) => {
if (clientId !== ydoc.clientID && state.user) {
activeUsers.push(state.user);
}
});
setCollaborators(activeUsers);
};
awareness.on('change', handleAwarenessChange);
// 2. Слушатели изменений в CRDT-документе
const handleNodesChange = (event: Y.YMapEvent<Node>, transaction: Y.Transaction) => {
// Игнорируем локальные транзакции, они уже применены в React
if (transaction.origin === SYNC_ORIGIN_LOCAL) return;
const currentNodesList = Array.from(yNodes.values());
setNodes(currentNodesList);
};
const handleEdgesChange = (event: Y.YMapEvent<Edge>, transaction: Y.Transaction) => {
if (transaction.origin === SYNC_ORIGIN_LOCAL) return;
const currentEdgesList = Array.from(yEdges.values());
setEdges(currentEdgesList);
};
yNodes.observe(handleNodesChange);
yEdges.observe(handleEdgesChange);
// Первичная синхронизация при подключении
provider.on('synced', (isSynced: boolean) => {
if (isSynced) {
setNodes(Array.from(yNodes.values()));
setEdges(Array.from(yEdges.values()));
}
});
return () => {
yNodes.unobserve(handleNodesChange);
yEdges.unobserve(handleEdgesChange);
awareness.off('change', handleAwarenessChange);
provider.destroy();
ydoc.destroy();
};
}, [roomId, currentUser]);
// Применение локальных изменений узлов (Drag, Select, Remove)
const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((prevNodes) => {
const updatedNodes = applyNodeChanges(changes, prevNodes);
const ydoc = ydocRef.current;
if (ydoc) {
const yNodes = ydoc.getMap<Node>('flow_nodes');
ydoc.transact(() => {
for (const change of changes) {
if (change.type === 'position' && change.position) {
const node = yNodes.get(change.id);
if (node) {
yNodes.set(change.id, { ...node, position: change.position });
}
} else if (change.type === 'remove') {
yNodes.delete(change.id);
}
}
}, SYNC_ORIGIN_LOCAL);
}
return updatedNodes;
});
}, []);
// Блокировка узла при начале перетаскивания
const onNodeDragStart = useCallback((_: React.MouseEvent, node: Node) => {
const awareness = providerRef.current?.awareness;
if (awareness) {
const currentUserState = awareness.getLocalState() as ClientAwarenessState;
awareness.setLocalStateField('user', {
...currentUserState.user,
lockedNodeId: node.id,
});
}
}, []);
// Снятие блокировки узла
const onNodeDragStop = useCallback(() => {
const awareness = providerRef.current?.awareness;
if (awareness) {
const currentUserState = awareness.getLocalState() as ClientAwarenessState;
awareness.setLocalStateField('user', {
...currentUserState.user,
lockedNodeId: null,
});
}
}, []);
// Обновление координат курсора
const updateCursorPosition = useCallback((x: number, y: number) => {
const awareness = providerRef.current?.awareness;
if (awareness) {
const currentUserState = awareness.getLocalState() as ClientAwarenessState;
awareness.setLocalStateField('user', {
...currentUserState.user,
cursor: { x, y },
});
}
}, []);
return {
nodes,
edges,
collaborators,
onNodesChange,
onNodeDragStart,
onNodeDragStop,
updateCursorPosition,
ydoc: ydocRef.current,
};
}import React, { memo } from 'react';
import type { UserPresence } from '@/types/collaboration';
interface MultiplayerCursorsProps {
collaborators: UserPresence[];
transform: [number, number, number]; // [x, y, zoom]
}
export const MultiplayerCursors = memo(function MultiplayerCursors({
collaborators,
transform,
}: MultiplayerCursorsProps) {
const [tx, ty, zoom] = transform;
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{collaborators.map((user) => {
if (!user.cursor) return null;
// Преобразование координат графа в экранные координаты для отрисовки курсора
const screenX = user.cursor.x * zoom + tx;
const screenY = user.cursor.y * zoom + ty;
return (
<div
key={user.id}
style={{
transform: `translate3d(${screenX}px, ${screenY}px, 0)`,
transition: 'transform 80ms ease-out',
}}
className="absolute left-0 top-0 will-change-transform"
>
{/* SVG иконка указателя мыши */}
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
style={{ color: user.color }}
className="drop-shadow"
>
<path
d="M5.65 2.15a1 1 0 0 0-1.4 1.15l4.5 16.5a1 1 0 0 0 1.8.2l3.75-6.25 6.25-3.75a1 1 0 0 0 .2-1.8l-16.5-4.5a1 1 0 0 0-.6.45z"
fill="currentColor"
/>
</svg>
{/* Бейдж с именем пользователя */}
<div
style={{ backgroundColor: user.color }}
className="ml-4 mt-0.5 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-semibold text-white shadow"
>
{user.name}
</div>
</div>
);
})}
</div>
);
});import React, { useCallback, useMemo } from 'react';
import {
ReactFlow,
useReactFlow,
useStore,
type ReactFlowState,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useYjsCollaboration } from '@/hooks/useYjsCollaboration';
import { MultiplayerCursors } from './MultiplayerCursors';
interface CollaborativeCanvasProps {
roomId: string;
userId: string;
userName: string;
userColor: string;
}
export function CollaborativeCanvas({
roomId,
userId,
userName,
userColor,
}: CollaborativeCanvasProps) {
const { screenToFlowPosition } = useReactFlow();
const transform = useStore((s: ReactFlowState) => s.transform);
const {
nodes,
edges,
collaborators,
onNodesChange,
onNodeDragStart,
onNodeDragStop,
updateCursorPosition,
} = useYjsCollaboration(roomId, {
id: userId,
name: userName,
color: userColor,
});
// Отслеживание перемещения указателя мыши над холстом
const handlePaneMouseMove = useCallback(
(event: React.MouseEvent) => {
// Переводим пиксели окна браузера в координаты графа с учетом зума
const flowCoords = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
updateCursorPosition(flowCoords.x, flowCoords.y);
},
[screenToFlowPosition, updateCursorPosition]
);
// Вычисляем заблокированные узлы другими участниками
const lockedNodeIds = useMemo(() => {
const map = new Set<string>();
for (const c of collaborators) {
if (c.lockedNodeId) map.add(c.lockedNodeId);
}
return map;
}, [collaborators]);
// Размечаем узлы: если узел заблокирован кем-то другим, отключаем drag
const processedNodes = useMemo(() => {
return nodes.map((node) => {
const isLockedByOther = lockedNodeIds.has(node.id);
return {
...node,
draggable: !isLockedByOther,
className: isLockedByOther ? 'opacity-70 ring-2 ring-status-warning' : '',
};
});
}, [nodes, lockedNodeIds]);
return (
<div className="relative h-screen w-full bg-surface-background">
<ReactFlow
nodes={processedNodes}
edges={edges}
onNodesChange={onNodesChange}
onNodeDragStart={onNodeDragStart}
onNodeDragStop={onNodeDragStop}
onPaneMouseMove={handlePaneMouseMove}
fitView
/>
{/* Оверлей мультикурсоров других участников */}
<MultiplayerCursors
collaborators={collaborators}
transform={transform}
/>
</div>
);
}Движение мыши транслируется в мировые координаты холста и отправляется в Awareness с ограничением частоты.
При drag-start ID узла фиксируется в awareness.lockedNodeId. Соседи блокируют перетаскивание этой ноды.
Позиции узла сериализуются в Y.Map с origin='local', формируя монотонный Lamport-вектор обновлений.
Удаленные клиенты получают бинарные дельты, обновляя локальный React Flow стейт без перезаписи чужих полей.
Пользователь А зажал узел графа (установив lockedNodeId в Awareness), после чего у него внезапно пропало интернет-соединение (или упала вкладка браузера). Что произойдет с заблокированным узлом у остальных участников команды?
Если в слушателе yNodes.observe(...) обновлять стейт React без проверки transaction.origin:
// ❌ КАТАСТРОФИЧЕСКАЯ ОШИБКА: бесконечный цикл рендеринга
yNodes.observe((event, transaction) => {
// Вызывается и на чужие, и на собственные действия!
setNodes(Array.from(yNodes.values()));
});Любое локальное движение мыши обновляет Yjs, Yjs уведомляет слушателя, слушатель вызывает setNodes, React перемонтирует компоненты, триггерит эффект и снова посылает сигнал.
Решение: Обязательно проверяйте if (transaction.origin === SYNC_ORIGIN_LOCAL) return;.
Отправка сырых значений event.clientX и event.clientY в координаты курсора пользователя. При разном разрешении экранов или разном текущем коэффициенте зума у пользователей курсор коллеги будет летать в случайных пустых зонах холста.
Решение: Всегда преобразуйте координаты через screenToFlowPosition({ x, y }) из хука useReactFlow().
Попытка записывать координаты мыши каждого пользователя в ydoc.getMap('cursors') засоряет историю версий CRDT тысячами ненужных структур, раздувая потребление памяти и размер файла снапшота до сотен мегабайт.
Решение: Для курсоров и фокусов используйте исключительно протокол присутствия provider.awareness.
Событие onPaneMouseMove может генерироваться с частотой до 144–240 Гц на геймерских мониторах. Отправка сетевого пакета на каждый пиксель перегрузит WebSocket-канал.
Решение: Ограничивайте частоту отправки через троттлинг (например, один пакет в 30–50 мс через lodash-es/throttle или requestAnimationFrame).