Построение интерактивных графовых интерфейсов в React сопряжено с фундаментальным архитектурным вызовом: необходимо объединить декларативный компонентный подход React с непрерывным императивным позиционированием сотен элементов на двумерной плоскости при 60 кадрах в секунду. Прямое хранение координат каждого узла в стандартном useState корневого компонента приводит к лавинообразному рендерингу всего дерева при малейшем смещении курсора мыши (drag-операция генерирует до 120 событий в секунду на экранах с высокой частотой обновления).
Библиотека @xyflow/react (современная мультиплатформенная эволюция react-flow-renderer в рамках экосистемы xyflow) решает эту проблему за счет двухуровневой архитектуры:
ReactFlowStoreProvider. При перетаскивании узла обновляются только CSS-трансформации конкретного DOM-элемента, минуя повторный рендеринг соседних узлов.transform: translate3d(x, y, 0).EdgeLabelRenderer): Выносит интерактивные DOM-элементы (кнопки удаления, бейджи, поля ввода на ребрах) за пределы SVG в общий HTML-слой, решая проблему кроссбраузерной изоляции <foreignObject>.АРХИТЕКТУРА СЛОЕВ РЕНДЕРИНГА @XYFLOW/REACT:
┌────────────────────────────────────────────────────────────────────────┐
│ <ReactFlowProvider> (Zustand State Engine) │
│ ├─ Viewport State: { x: number, y: number, zoom: number } │
│ └─ Node/Edge Registry: Map<NodeId, NodeInternal> │
├────────────────────────────────────────────────────────────────────────┤
│ <div className="react-flow"> (Контейнер с overflow: hidden) │
│ │ │
│ ├─ <div className="react-flow__renderer"> │
│ │ │ (CSS transform: translate(x, y) scale(zoom)) │
│ │ │ │
│ │ ├─ <svg className="react-flow__edges"> │
│ │ │ └─ <path d="M... Bezier / Step" /> [Edge Layer] │
│ │ │ │
│ │ ├─ <div className="react-flow__nodes"> │
│ │ │ ├─ <CustomNode id="1" /> [DOM Node Layer] │
│ │ │ │ ├─ <Handle type="target" position="left" /> │
│ │ │ │ └─ <Handle type="source" position="right" /> │
│ │ │ └─ <GroupNode id="subflow-1"> [Subflow Container] │
│ │ │ └─ <ChildNode id="child-1" parentId="subflow-1" /> │
│ │ │ │
│ │ └─ <div className="react-flow__edge-labels"> │
│ │ └─ [EdgeLabelRenderer Portals: HTML buttons, tags, badges] │
│ │ │
│ └─ <div className="react-flow__controls"> [Screen Space UI Controls] │
└────────────────────────────────────────────────────────────────────────┘В основе ментальной модели графового редактора лежат три ключевые абстракции: Node, Handle и Edge.
Узел взаимодействует с топологией графа исключительно через компоненты <Handle>. Порт регистрирует свои абсолютные экранные координаты в центральном реестре стора с помощью ResizeObserver и getBoundingClientRect().
Каждый порт имеет строгую типизацию:
type="source": испускает связь (выходной терминал).type="target": принимает связь (входной терминал).position: физическая ориентация на гранях узла (Position.Top, Position.Bottom, Position.Left, Position.Right).id: обязателен, если на одной грани узла располагается более одного порта (например, несколько входов функции или разветвление логического условия).Для предотвращения недопустимых циклов и ошибок бизнес-логики рантайм предоставляет проп isValidConnection: предикатную функцию, вызываемую в реальном времени при наведении перетаскиваемой временной связи на потенциальный целевой порт.
В сложных графах узлы могут формировать иерархии (группы, контейнеры, подграфы). В @xyflow/react это реализуется декларативно через свойство parentId:
parentId: "group-node-id".position: { x, y } автоматически интерпретируются как локальные смещения относительно левого верхнего угла родителя, а не холста.extent: 'parent' блокирует перетаскивание дочернего узла за пределы родительского прямоугольника.ИЕРАРХИЯ КООРДИНАТНЫХ ПРОСТРАНСТВ SUBFLOW:
┌───────────────────────────────────────────────────────────┐
│ Canvas Coordinate Space (World Space) │
│ │
│ Group Node (ID: "group-1") │
│ Absolute Canvas Pos: { x: 200, y: 150 } │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Local Coordinate Space (Rel to Group Top-Left): │ │
│ │ │ │
│ │ Child Node (ID: "child-a", parentId: "group-1") │ │
│ │ Relative Pos: { x: 30, y: 40 } │ │
│ │ (Вычисленная мировая позиция: { x: 230, y: 190 }) │ │
│ │ │ │
│ │ Child Node (ID: "child-b", parentId: "group-1") │ │
│ │ Relative Pos: { x: 250, y: 80 } │ │
│ └─────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘Разработчик объявляет неизменяемые маппинги типов узлов и ребер вне области рендеринга React-компонента во избежание сброса внутреннего состояния.
Массивы вершин и ребер передаются в компонент <ReactFlow> вместе с колбэками синхронизации onNodesChange и onEdgesChange.
Рантайм измеряет физические габариты нод и точные смещения портов <Handle> через ResizeObserver, сохраняя их в Zustand-реестре.
На основе зарегистрированных координат портов функции getBezierPath / getSmoothStepPath строят векторные пути SVG в отдельном слое.
Узел представляет собой условие 'IF / ELSE' с одним входным портом слева и двумя выходными портами справа ('True' и 'False'). Разработчик не указал атрибут 'id' на компонентах <Handle type='source'>. К каким последствиям это приведет?
В распределенных платформах обработки данных (аналогах Apache Airflow, Prefect, Dagster) инженеры настраивают графы преобразования потоков данных. Кастомные узлы отображают статус выполнения шагов (Pending, Running, Failed), текущую пропускную способность (строк/сек) и схему валидации данных на входных и выходных портах. Функция isValidConnection проверяет совпадение типов схем (например, нельзя подключить JSON-выход к порту, ожидающему бинарный поток Parquet).
Студийные веб-секвенсоры и модульные синтезаторы моделируют путь прохождения аудиосигнала. Узлы представляют генераторы колебаний (Oscillator), полосовые фильтры (BiquadFilter) и усилители (Gain). Вложенные подграфы (subflows) группируют цепочки эффектов (FX Rack), изолируя внутреннюю маршрутизацию и сворачивая группу в единый компактный макро-узел.
В enterprise-платформах оркестрации LLM-агентов (LangChain Graph, AutoGen, Dify) узлы представляют промпты, векторные базы данных, роутеры решений и вызовы внешних инструментов. Пользовательские ребра содержат интерактивные условия ветвления с процентным разделением трафика (AB-тестирование промптов), реализованные через <EdgeLabelRenderer>.
Системы документооборота и CRM-системы используют графы состояний (Statecharts / BPMN). Кастомные ребра отображают роли согласующих сотрудников, тайм-ауты эскалации и интерактивные кнопки быстрого удаления связи с подтверждением действия.
Для обеспечения максимальной производительности и надежности типизации проект строится на строгих TypeScript-интерфейсах. Начнем с описания типов данных кастомных узлов и создания типобезопасного графа.
В @xyflow/react узлы типизируются через дженерик Node<Data, Type>. Создадим контракт для узла математической обработки данных:
import type { Node, Edge, BuiltInNode } from '@xyflow/react';
// Полезная нагрузка (business payload) для узла фильтрации данных
export interface FilterNodeData {
field: string;
operator: 'equals' | 'greater_than' | 'contains';
thresholdValue: number | string;
isPassed: boolean;
}
// Полезная нагрузка для родительской группы (Subflow)
export interface PipelineGroupData {
label: string;
isolationLevel: 'strict' | 'relaxed';
}
// Дискриминированное объединение всех типов узлов системы
export type FilterCustomNode = Node<FilterNodeData, 'filterNode'>;
export type PipelineGroupCustomNode = Node<PipelineGroupData, 'pipelineGroup'>;
export type ApplicationNode = FilterCustomNode | PipelineGroupCustomNode | BuiltInNode;
// Типизированное кастомное ребро с метаданными задержки сети
export interface LatencyEdgeData {
latencyMs: number;
protocol: 'gRPC' | 'REST' | 'WebSocket';
}
export type ApplicationEdge = Edge<LatencyEdgeData, 'deletableEdge'>;Создадим компонент узла, реализующий строгий интерфейс NodeProps. Компонент узла должен быть обернут в React.memo для предотвращения холостых ререндеров, когда изменяются другие элементы графа.
import React, { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import type { FilterCustomNode } from '@/types/graph';
export const FilterNode = memo(function FilterNode({
id,
data,
selected,
}: NodeProps<FilterCustomNode>) {
return (
<div
className={`min-w-[220px] rounded-lg border bg-surface-card p-3 shadow-md transition-all ${
selected ? 'border-accent-blue ring-2 ring-accent-blue/30' : 'border-border-subtle'
}`}
>
{/* Входной терминал (Target Handle) */}
<Handle
type="target"
position={Position.Left}
id="input-stream"
className="!h-3 !w-3 !border-2 !border-surface-card !bg-accent-blue"
/>
<div className="flex items-center justify-between border-b border-border-subtle pb-2">
<span className="text-xs font-semibold uppercase tracking-wider text-text-muted">
Оператор фильтра
</span>
<span
className={`h-2 w-2 rounded-full ${
data.isPassed ? 'bg-status-success' : 'bg-status-warning'
}`}
/>
</div>
<div className="mt-2 space-y-1.5 font-mono text-xs">
<div className="flex justify-between">
<span className="text-text-secondary">Поле:</span>
<span className="font-semibold text-text-primary">{data.field}</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary">Условие:</span>
<span className="text-accent-cyan">{data.operator}</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary">Порог:</span>
<span className="text-text-primary">{data.thresholdValue}</span>
</div>
</div>
{/* Множественные выходные терминалы (Source Handles) с обязательными ID */}
<div className="relative mt-3 pt-2 text-[10px]">
<div className="flex justify-end pr-2 text-status-success">
Истина (True)
<Handle
type="source"
position={Position.Right}
id="output-true"
style={{ top: '65%' }}
className="!h-2.5 !w-2.5 !border-2 !border-surface-card !bg-status-success"
/>
</div>
<div className="flex justify-end pr-2 text-status-danger">
Ложь (False)
<Handle
type="source"
position={Position.Right}
id="output-false"
style={{ top: '85%' }}
className="!h-2.5 !w-2.5 !border-2 !border-surface-card !bg-status-danger"
/>
</div>
</div>
</div>
);
});Главная проблема кастомных ребер в SVG — отображение сложных интерактивных HTML-элементов. Стандартный тег SVG <foreignObject> страдает от багов масштабирования в Safari и перехватывает клики у нижележащих узлов. Решение — использовать <EdgeLabelRenderer>, который телепортирует JSX в отдельный абсолютно позиционированный HTML-слой.
import React, { memo } from 'react';
import {
BaseEdge,
EdgeLabelRenderer,
getBezierPath,
useReactFlow,
type EdgeProps,
} from '@xyflow/react';
import type { ApplicationEdge } from '@/types/graph';
export const DeletableEdge = memo(function DeletableEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
data,
}: EdgeProps<ApplicationEdge>) {
const { setEdges } = useReactFlow();
// Вычисление математической кубической кривой Безье и координат ее центра
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const handleDelete = (event: React.MouseEvent) => {
event.stopPropagation();
setEdges((edges) => edges.filter((edge) => edge.id !== id));
};
return (
<>
{/* Базовый оптимизированный SVG-путь */}
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
{/* Портал для интерактивного HTML-оверлея */}
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
}}
className="nodrag nopan flex items-center gap-1 rounded border border-border-subtle bg-surface-overlay px-1.5 py-0.5 text-[11px] shadow-sm backdrop-blur-sm"
>
{data?.latencyMs !== undefined && (
<span className="font-mono text-text-muted">
{data.latencyMs}ms ({data.protocol})
</span>
)}
<button
type="button"
onClick={handleDelete}
aria-label="Удалить ребро"
className="flex h-4 w-4 items-center justify-center rounded-full bg-status-danger/20 text-status-danger hover:bg-status-danger hover:text-white"
>
×
</button>
</div>
</EdgeLabelRenderer>
</>
);
});import React, { useCallback, useMemo } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
addEdge,
type Connection,
type NodeTypes,
type EdgeTypes,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { FilterNode } from './nodes/FilterNode';
import { DeletableEdge } from './edges/DeletableEdge';
import type { ApplicationNode, ApplicationEdge } from '@/types/graph';
// ВАЖНО: Объявление маппингов строго вне тела компонента
const nodeTypes: NodeTypes = {
filterNode: FilterNode,
};
const edgeTypes: EdgeTypes = {
deletableEdge: DeletableEdge,
};
const initialNodes: ApplicationNode[] = [
// Родительский контейнер (Subflow Group)
{
id: 'group-1',
type: 'group',
position: { x: 50, y: 50 },
style: {
width: 480,
height: 320,
backgroundColor: 'rgba(255, 255, 255, 0.03)',
border: '1px dashed rgba(255, 255, 255, 0.2)',
borderRadius: '12px',
},
data: { label: 'Группа ETL-валидации' },
},
// Дочерний узел 1 внутри группы
{
id: 'node-filter-1',
type: 'filterNode',
parentId: 'group-1',
extent: 'parent', // Не позволяет вытащить узел за границы группы
position: { x: 30, y: 40 }, // Относительные координаты
data: {
field: 'payload.statusCode',
operator: 'equals',
thresholdValue: 200,
isPassed: true,
},
},
// Дочерний узел 2 внутри группы
{
id: 'node-filter-2',
type: 'filterNode',
parentId: 'group-1',
extent: 'parent',
position: { x: 230, y: 140 }, // Относительные координаты
data: {
field: 'payload.retryCount',
operator: 'greater_than',
thresholdValue: 3,
isPassed: false,
},
},
];
const initialEdges: ApplicationEdge[] = [
{
id: 'e1-2',
source: 'node-filter-1',
sourceHandle: 'output-true',
target: 'node-filter-2',
targetHandle: 'input-stream',
type: 'deletableEdge',
data: { latencyMs: 14, protocol: 'gRPC' },
},
];
export function WorkflowCanvas() {
const [nodes, , onNodesChange] = useNodesState<ApplicationNode>(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState<ApplicationEdge>(initialEdges);
// Валидация соединений в реальном времени
const isValidConnection = useCallback((connection: Connection) => {
// Запрет циклических связей узла с самим собой
if (connection.source === connection.target) return false;
// Разрешаем соединять только определенные выходные порты
return connection.sourceHandle?.startsWith('output-') ?? false;
}, []);
const onConnect = useCallback(
(params: Connection) => {
setEdges((eds) =>
addEdge(
{
...params,
type: 'deletableEdge',
data: { latencyMs: 0, protocol: 'REST' },
},
eds
)
);
},
[setEdges]
);
return (
<div className="h-screen w-full bg-surface-background">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
isValidConnection={isValidConnection}
fitView
>
<Background color="#334155" gap={16} />
<Controls />
<MiniMap zoomable pannable nodeStrokeWidth={3} />
</ReactFlow>
</div>
);
}Пользователь зажимает ЛКМ на выходном порте <Handle type='source'>. Рантайм инициирует режим создания ребра (Connection Mode).
Временная связь отрисовывается в SVG от исходного порта к текущим координатам курсора мыши в реальном времени.
При наведении на целевой порт вызывается функция проверки. Если она возвращает false, порт подсвечивается статусом ошибки и сброс связи блокируется.
При отпускании мыши на валидном порте срабатывает колбэк onConnect, формируя неизменяемый объект ребра с атрибутами source, target, sourceHandle и targetHandle.
Родительский узел 'group-1' находится в позиции { x: 500, y: 300 }. Вложенный узел 'child-1' имеет свойство parentId: 'group-1' и позицию { x: 50, y: 80 }. Каковы абсолютные мировые координаты узла 'child-1' на холсте, и что произойдет при перемещении группы на вектор (+100, +50)?
Если объявить объекты nodeTypes или edgeTypes внутри тела React-компонента без стабильной мемоизации:
// ❌ КРИТИЧЕСКАЯ ОШИБКА: новый объект создается при каждом рендере
export function BadCanvas() {
const nodeTypes = { custom: MyCustomNode };
return <ReactFlow nodeTypes={nodeTypes} ... />;
}При каждом изменении любого состояния внутри BadCanvas ссылка на объект nodeTypes меняется. React Flow интерпретирует это как полную смену реестра типов и принудительно перемонтирует (unmount/remount) абсолютно все DOM-узлы графа. Пользовательский ввод теряется, фокус сбрасывается, а анимации ломаются.
Решение: Всегда выносите nodeTypes и edgeTypes за пределы компонента в константы модуля либо оборачивайте их в useMemo(() => ({ ... }), []).
Если в кастомном узле разместить форму ввода (<input>, <select>, <textarea>) или кнопку без специальных CSS-классов, попытка выделить текст или нажать на элемент будет перехвачена механизмом панорамирования холста или драга узла:
// ❌ ОШИБКА: попытка выделить текст в input начнет перетаскивание узла
<input type="text" value={data.label} onChange={handleChange} />
// ✅ ПРАВИЛЬНО: nodrag и nopan блокируют всплытие событий мыши к холсту
<input type="text" className="nodrag nopan" value={data.label} onChange={handleChange} />Прямое изменение координат узла вида node.position.x += 10 нарушает ссылочную целостность в Zustand-сторе и React Reconciliation. Граф не обновится, либо войдет в состояние рассинхронизации ребер и портов.
Решение: Используйте предоставленный хук useNodesState или применяйте встроенную утилиту applyNodeChanges(changes, nodes) для корректной обработки геометрических дельт.
Разработчики часто пытаются вручную написать <path d={edgePath} /> внутри кастомного ребра, забывая про обработку состояний выделения (selected), классов анимации и взаимодействия с маркерами стрелок (markerEnd).
Решение: Всегда возвращайте нативный компонент <BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />, который инкапсулирует системные стили и hover-эффекты @xyflow/react.