ReactFlow自定义节点与边缘技能Skill reactflow-custom-nodes

React Flow自定义节点与边缘技能是用于使用React Flow库创建自定义节点和边缘的技术,主要应用于构建节点编辑器、工作流界面、可视化图表和数据流程图等前端开发场景。它支持交互式表单元素、多种节点类型、工具栏、可调整大小节点、分组节点、自定义边缘交互和连接验证。关键技能包括自定义组件、样式定制、行为交互、性能优化、前端开发、UI设计和JavaScript框架。

前端开发 0 次安装 0 次浏览 更新于 3/25/2026

name: reactflow-custom-nodes user-invocable: false description: 在创建自定义React Flow节点、边缘和句柄时使用。涵盖自定义节点组件、可调整大小的节点、工具栏和高级定制。 allowed-tools:

  • Bash
  • Read

React Flow自定义节点和边缘

使用React Flow创建完全自定义的节点和边缘。构建具有自定义样式、行为和交互的复杂节点编辑器。

自定义节点组件

import { memo } from 'react';
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';

// 定义自定义节点数据类型
type TextUpdaterNodeData = {
  label: string;
  onChange: (value: string) => void;
};

type TextUpdaterNode = Node<TextUpdaterNodeData>;

function TextUpdaterNode({ data, isConnectable }: NodeProps<TextUpdaterNode>) {
  const onChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
    data.onChange(evt.target.value);
  };

  return (
    <div className="text-updater-node">
      <Handle
        type="target"
        position={Position.Top}
        isConnectable={isConnectable}
      />
      <div>
        <label htmlFor="text">文本:</label>
        <input
          id="text"
          name="text"
          onChange={onChange}
          className="nodrag"
          defaultValue={data.label}
        />
      </div>
      <Handle
        type="source"
        position={Position.Bottom}
        id="a"
        isConnectable={isConnectable}
      />
    </div>
  );
}

// 使用memo进行性能优化
export default memo(TextUpdaterNode);

注册自定义节点

import { ReactFlow } from '@xyflow/react';
import TextUpdaterNode from './TextUpdaterNode';
import ColorPickerNode from './ColorPickerNode';

// 在组件外定义节点类型以防止重新渲染
const nodeTypes = {
  textUpdater: TextUpdaterNode,
  colorPicker: ColorPickerNode,
};

function Flow() {
  const [nodes, setNodes, onNodesChange] = useNodesState([
    {
      id: '1',
      type: 'textUpdater',
      position: { x: 0, y: 0 },
      data: {
        label: 'Hello',
        onChange: (value) => console.log(value),
      },
    },
  ]);

  return (
    <ReactFlow
      nodes={nodes}
      nodeTypes={nodeTypes}
      onNodesChange={onNodesChange}
    />
  );
}

使用Tailwind样式的节点

import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';

type StatusNodeData = {
  label: string;
  status: 'pending' | 'running' | 'completed' | 'error';
};

const statusColors = {
  pending: 'bg-yellow-100 border-yellow-400',
  running: 'bg-blue-100 border-blue-400',
  completed: 'bg-green-100 border-green-400',
  error: 'bg-red-100 border-red-400',
};

const statusIcons = {
  pending: '⏳',
  running: '⚡',
  completed: '✅',
  error: '❌',
};

function StatusNode({ data }: NodeProps<Node<StatusNodeData>>) {
  return (
    <div
      className={`px-4 py-2 rounded-lg border-2 shadow-sm ${statusColors[data.status]}`}
    >
      <Handle type="target" position={Position.Top} className="!bg-gray-400" />

      <div className="flex items-center gap-2">
        <span className="text-xl">{statusIcons[data.status]}</span>
        <span className="font-medium">{data.label}</span>
      </div>

      <Handle
        type="source"
        position={Position.Bottom}
        className="!bg-gray-400"
      />
    </div>
  );
}

export default memo(StatusNode);

具有多个句柄的节点

import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';

type SwitchNodeData = {
  label: string;
  cases: string[];
};

function SwitchNode({ data }: NodeProps<Node<SwitchNodeData>>) {
  return (
    <div className="switch-node bg-white rounded-lg shadow-lg p-3 min-w-[150px]">
      {/* 单一输入 */}
      <Handle type="target" position={Position.Top} id="input" />

      <div className="font-bold text-center border-b pb-2 mb-2">
        {data.label}
      </div>

      {/* 多个输出 - 每个案例一个 */}
      <div className="space-y-2">
        {data.cases.map((caseLabel, index) => (
          <div key={index} className="relative text-sm text-right pr-4">
            {caseLabel}
            <Handle
              type="source"
              position={Position.Right}
              id={`case-${index}`}
              style={{ top: `${30 + index * 28}px` }}
            />
          </div>
        ))}
      </div>
    </div>
  );
}

export default memo(SwitchNode);

可调整大小的节点

import { memo } from 'react';
import { Handle, Position, NodeResizer, type NodeProps } from '@xyflow/react';

type ResizableNodeData = {
  label: string;
  content: string;
};

function ResizableNode({ data, selected }: NodeProps<Node<ResizableNodeData>>) {
  return (
    <>
      <NodeResizer
        color="#ff0071"
        isVisible={selected}
        minWidth={100}
        minHeight={50}
        handleStyle={{ width: 8, height: 8 }}
      />
      <Handle type="target" position={Position.Top} />
      <div className="p-4 h-full">
        <div className="font-bold">{data.label}</div>
        <div className="text-sm text-gray-600">{data.content}</div>
      </div>
      <Handle type="source" position={Position.Bottom} />
    </>
  );
}

export default memo(ResizableNode);

节点工具栏

import { memo, useState } from 'react';
import {
  Handle,
  Position,
  NodeToolbar,
  type NodeProps,
  useReactFlow,
} from '@xyflow/react';

type EditableNodeData = {
  label: string;
};

function EditableNode({
  id,
  data,
  selected,
}: NodeProps<Node<EditableNodeData>>) {
  const { setNodes, deleteElements } = useReactFlow();
  const [isEditing, setIsEditing] = useState(false);
  const [label, setLabel] = useState(data.label);

  const handleSave = () => {
    setNodes((nodes) =>
      nodes.map((node) =>
        node.id === id ? { ...node, data: { ...node.data, label } } : node
      )
    );
    setIsEditing(false);
  };

  const handleDelete = () => {
    deleteElements({ nodes: [{ id }] });
  };

  return (
    <>
      <NodeToolbar isVisible={selected} position={Position.Top}>
        <button onClick={() => setIsEditing(true)} className="toolbar-btn">
          ✏️ 编辑
        </button>
        <button onClick={handleDelete} className="toolbar-btn text-red-500">
          🗑️ 删除
        </button>
      </NodeToolbar>

      <Handle type="target" position={Position.Top} />

      <div className="px-4 py-2 bg-white rounded shadow">
        {isEditing ? (
          <div className="flex gap-2">
            <input
              value={label}
              onChange={(e) => setLabel(e.target.value)}
              className="border rounded px-2"
              autoFocus
            />
            <button onClick={handleSave}>保存</button>
          </div>
        ) : (
          <span>{data.label}</span>
        )}
      </div>

      <Handle type="source" position={Position.Bottom} />
    </>
  );
}

export default memo(EditableNode);

自定义边缘

import { memo } from 'react';
import {
  BaseEdge,
  EdgeLabelRenderer,
  getBezierPath,
  useReactFlow,
  type EdgeProps,
} from '@xyflow/react';

function ButtonEdge({
  id,
  sourceX,
  sourceY,
  targetX,
  targetY,
  sourcePosition,
  targetPosition,
  style = {},
  markerEnd,
}: EdgeProps) {
  const { setEdges } = useReactFlow();

  const [edgePath, labelX, labelY] = getBezierPath({
    sourceX,
    sourceY,
    sourcePosition,
    targetX,
    targetY,
    targetPosition,
  });

  const onEdgeClick = () => {
    setEdges((edges) => edges.filter((edge) => edge.id !== id));
  };

  return (
    <>
      <BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
      <EdgeLabelRenderer>
        <div
          style={{
            position: 'absolute',
            transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
            fontSize: 12,
            pointerEvents: 'all',
          }}
          className="nodrag nopan"
        >
          <button
            className="w-5 h-5 bg-gray-200 rounded-full border border-gray-400 cursor-pointer hover:bg-red-200"
            onClick={onEdgeClick}
          >
            ×
          </button>
        </div>
      </EdgeLabelRenderer>
    </>
  );
}

export default memo(ButtonEdge);

具有自定义路径的边缘

import { memo } from 'react';
import { BaseEdge, getStraightPath, type EdgeProps } from '@xyflow/react';

function CustomPathEdge({
  sourceX,
  sourceY,
  targetX,
  targetY,
}: EdgeProps) {
  // 创建自定义S曲线路径
  const midY = (sourceY + targetY) / 2;

  const path = `
    M ${sourceX} ${sourceY}
    C ${sourceX} ${midY},
      ${targetX} ${midY},
      ${targetX} ${targetY}
  `;

  return <BaseEdge path={path} style={{ stroke: '#b1b1b7', strokeWidth: 2 }} />;
}

export default memo(CustomPathEdge);

注册自定义边缘

import { ReactFlow } from '@xyflow/react';
import ButtonEdge from './ButtonEdge';
import CustomPathEdge from './CustomPathEdge';

const edgeTypes = {
  buttonEdge: ButtonEdge,
  customPath: CustomPathEdge,
};

function Flow() {
  const [edges, setEdges, onEdgesChange] = useEdgesState([
    {
      id: 'e1-2',
      source: '1',
      target: '2',
      type: 'buttonEdge',
    },
  ]);

  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      edgeTypes={edgeTypes}
      onEdgesChange={onEdgesChange}
    />
  );
}

连接验证

import { useCallback } from 'react';
import { ReactFlow, type IsValidConnection } from '@xyflow/react';

function Flow() {
  // 在建立连接之前进行验证
  const isValidConnection: IsValidConnection = useCallback(
    (connection) => {
      // 防止自我连接
      if (connection.source === connection.target) {
        return false;
      }

      // 只允许特定句柄类型之间的连接
      const sourceNode = nodes.find((n) => n.id === connection.source);
      const targetNode = nodes.find((n) => n.id === connection.target);

      // 示例:输入节点不能接收连接
      if (targetNode?.type === 'input') {
        return false;
      }

      // 示例:输出节点不能发送连接
      if (sourceNode?.type === 'output') {
        return false;
      }

      return true;
    },
    [nodes]
  );

  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      isValidConnection={isValidConnection}
    />
  );
}

分组节点(父容器)

import { memo } from 'react';
import { type NodeProps } from '@xyflow/react';

type GroupNodeData = {
  label: string;
};

function GroupNode({ data }: NodeProps<Node<GroupNodeData>>) {
  return (
    <div className="p-2 border-2 border-dashed border-gray-400 rounded-lg bg-gray-50/50 min-w-[200px] min-h-[150px]">
      <div className="text-xs text-gray-500 font-medium mb-2">{data.label}</div>
    </div>
  );
}

export default memo(GroupNode);

// 使用 - 子节点引用父节点
const nodes = [
  {
    id: 'group-1',
    type: 'group',
    data: { label: 'Group A' },
    position: { x: 0, y: 0 },
    style: { width: 300, height: 200 },
  },
  {
    id: 'child-1',
    data: { label: 'Child Node' },
    position: { x: 50, y: 50 },
    parentId: 'group-1',
    extent: 'parent',
  },
];

CSS样式

/* node.css */
.react-flow__node-custom {
  background: white;
  border: 1px solid #1a192b;
  border-radius: 8px;
  padding: 10px;
  font-size: 12px;
  width: 150px;
}

.react-flow__node-custom.selected {
  border-color: #ff0071;
  box-shadow: 0 0 0 2px #ff0071;
}

.react-flow__handle {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background-color: #555;
}

.react-flow__handle-connecting {
  background-color: #ff0071;
}

.react-flow__handle-valid {
  background-color: #55dd99;
}

/* 防止拖动交互元素 */
.nodrag {
  pointer-events: all;
}

/* 边缘样式 */
.react-flow__edge-path {
  stroke: #b1b1b7;
  stroke-width: 2;
}

.react-flow__edge.selected .react-flow__edge-path {
  stroke: #ff0071;
}

.react-flow__edge.animated .react-flow__edge-path {
  stroke-dasharray: 5;
  animation: dashdraw 0.5s linear infinite;
}

@keyframes dashdraw {
  from {
    stroke-dashoffset: 10;
  }
}

何时使用此技能

在需要时使用reactflow-custom-nodes技能:

  • 构建具有交互式表单元素的节点
  • 创建视觉上不同的节点类型
  • 添加节点工具栏和上下文菜单
  • 构建可调整大小的节点
  • 创建分组/容器节点
  • 添加自定义边缘交互
  • 验证节点之间的连接
  • 构建复杂的工作流界面

最佳实践

  • 始终使用memo()函数记忆自定义节点组件
  • 在组件外定义nodeTypes/edgeTypes
  • 在交互元素上使用nodrag
  • 保持节点组件专注和可重用
  • 使用TypeScript进行类型安全的节点数据
  • 彻底测试连接验证
  • 在自定义节点中考虑可访问性
  • 使用CSS模块或Tailwind进行样式设计

资源