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进行样式设计