本文将带你逐行解析一个基于 React Flow 的类似于Dify平台的流程编辑器渲染示例,重点讲解如何在节点和连线(Edge)中添加“+”按钮,一键动态生成节点以及交互效果实现。文中包含核心代码示例与思路剖析,帮助你快速理解流程编排如何渲染和交互。
React Flow
作为底层可视化库Ant Design
的 Dropdown
与 Menu
,在节点与连线上添加浮层按钮SVG
的 path
和 foreignObject
元素实现自定义边React Flow
内置 API getOutgoers
与dfs
算法找出所有需要移动的节点,防止当前链条有节点重叠const StartNode: React.FC<NodeProps> = ({ id, data }) => {
const handleClick = ({ key, domEvent }) => {
domEvent.stopPropagation();
data.onAdd?.(id, key, menuItems.find(e => e.key === key)?.label, "node");
};
return (
<div style={...}>
开始节点
<Handle type="source" position={Position.Right} style={{ background: "#555" }} />
<Dropdown overlay={<Menu items={menuItems} onClick={handleClick} />} trigger={['click']}>
<div style={{...}}><PlusOutlined /></div>
</Dropdown>
</div>
);
};
暴露连线接口“+”
图标,通过 Dropdown
弹出“中间节点 / 结束节点”选项Handle
MiddleNode
同时有左右 Handle
EndNode
只展示左侧 Handle
StartNode
类似const CustomEdge: React.FC<EdgeProps> = ({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style, data }) => {
const [path, labelX, labelY] = getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition });
const onClick = ({ key, domEvent }) => {
domEvent.stopPropagation();
data.onAdd?.(data.source, data.target, key, menuItems.find(e => e.key === key)?.label, "edge");
};
return (
<>
<path id={id} d={path} className="react-flow__edge-path" style={style} />
<foreignObject x={labelX-15} y={labelY-15} width={30} height={30} requiredExtensions="http://www.w3.org/1999/xhtml">
<Dropdown overlay={<Menu items={menuItems} onClick={onClick} />} trigger={['click']}>
<div style={{...}}><PlusOutlined /></div>
</Dropdown>
</foreignObject>
</>
);
};
getBezierPath
计算曲线和中点坐标
在 SVG
中嵌入 HTML
“+”
按钮const addNodeToRight = (sourceId, nodeType, label, clickType) => {
const sourceNode = nodes.find(n => n.id === sourceId);
if (!sourceNode) return;
const newId = getId();
const newNode = { id: newId, type: nodeType, position: {...}, data: { label: `${label}(${newId})`, onAdd: addNodeToRight } };
setNodes(nds => [...nds, newNode]);
const newEdge = { id: `edge_${sourceId}_${newId}`, source: sourceId, target: newId, type: "custom", data: { onAdd: addNodeToRight } };
setEdges(eds => addEdge(newEdge, eds));
};
const addNodeToLineRight = (srcId, tgtId, nodeType, label) => {
// 1. 在源节点后生成新节点,同 addNodeToRight
// 2. 删除原有 src->tgt 连线
// 3. 新增两条连线:src->new, new->tgt
// 4. 调整新节点所在“链”之后所有节点的位置(findLineNode + updateLineNode)
};
import React, { useCallback, useEffect, useRef, useState } from "react";
import ReactFlow, {
Background,
Controls,
MiniMap,
addEdge,
useNodesState,
useEdgesState,
Connection,
Edge,
Node,
Handle,
Position,
NodeProps,
NodeTypes,
MarkerType,
getBezierPath,
EdgeTypes,
EdgeProps,
getOutgoers,
getIncomers,
} from "reactflow";
import { Dropdown, Menu } from "antd";
import { PlusOutlined } from "@ant-design/icons";
import "reactflow/dist/style.css";
const menuItems = [
{
key: "middle",
label: "中间节点",
},
{
key: "end",
label: "结束节点",
},
];
// --- Custom Edge Component with Add Button at Midpoint ---
const CustomEdge: React.FC<EdgeProps> = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
source,
target,
data,
}) => {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const [, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const menuItems = [
{ key: "middle", label: "中间节点" },
{ key: "end", label: "结束节点" },
];
const handleClick = ({ item, key, keyPath, domEvent }) => {
const labelObj = menuItems.find((e) => e.key === key);
domEvent.stopPropagation();
data.onAdd?.(source, target, key, labelObj?.label, "edge");
};
const menu = <Menu items={menuItems} onClick={handleClick} />;
return (
<>
<path
id={id}
style={style}
className="react-flow__edge-path"
d={edgePath}
/>
<foreignObject
width={30}
height={30}
x={labelX - 15}
y={labelY - 15}
requiredExtensions="http://www.w3.org/1999/xhtml"
>
<Dropdown key="edge" overlay={menu} trigger={["click"]}>
<div
style={{
width: 30,
height: 30,
borderRadius: "50%",
backgroundColor: "#1890ff",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
boxShadow: "0 2px 6px rgba(0,0,0,0.2)",
}}
>
<PlusOutlined />
</div>
</Dropdown>
</foreignObject>
</>
);
};
const StartNode: React.FC<NodeProps> = ({ id, data }) => {
const handleClick = ({ item, key, keyPath, domEvent }) => {
const labelObj = menuItems.find((e) => e.key === key);
domEvent.stopPropagation();
data.onAdd?.(id, key, labelObj?.label, "node");
};
const menu = <Menu items={menuItems} onClick={handleClick} />;
return (
<div
style={{
position: "relative",
padding: 10,
background: "#fff",
border: "1px solid #ddd",
borderRadius: 4,
color: "black",
}}
>
开始节点
<Handle
type="source"
position={Position.Right}
id="right"
style={{ background: "#555" }}
/>
<Dropdown key="StartNode" overlay={menu} trigger={["click"]}>
<div
style={{
width: 24,
height: 24,
borderRadius: "50%",
backgroundColor: "#1890ff",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "absolute",
right: -14,
top: "50%",
transform: "translateY(-50%)",
boxShadow: "0 2px 6px rgba(0, 0, 0, 0.2)",
cursor: "pointer",
zIndex: 10,
}}
>
<PlusOutlined style={{ fontSize: 12 }} />
</div>
</Dropdown>
</div>
);
};
const EndNode: React.FC<NodeProps> = ({ id, data }) => {
return (
<div
style={{
position: "relative",
padding: 10,
background: "#fff",
border: "1px solid #ddd",
borderRadius: 4,
color: "black",
}}
>
{data.label}
<Handle
type="target"
position={Position.Left}
id="left"
style={{ background: "#555" }}
/>
</div>
);
};
const MiddleNode: React.FC<NodeProps> = ({ id, data }) => {
const handleClick = ({ item, key, keyPath, domEvent }) => {
const labelObj = menuItems.find((e) => e.key === key);
domEvent.stopPropagation();
data.onAdd?.(id, key, labelObj?.label, "node");
};
const menu = <Menu items={menuItems} onClick={handleClick} />;
return (
<div
style={{
position: "relative",
padding: 10,
background: "#fff",
border: "1px solid #ddd",
borderRadius: 4,
color: "black",
}}
>
{data.label}
<Handle
type="source"
position={Position.Right}
id="right"
style={{ background: "#555" }}
/>
<Handle
type="target"
position={Position.Left}
id="right"
style={{ background: "#555" }}
/>
<Dropdown key="MiddleNode" overlay={menu} trigger={["click"]}>
<div
style={{
width: 24,
height: 24,
borderRadius: "50%",
backgroundColor: "#1890ff",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "absolute",
right: -14,
top: "50%",
transform: "translateY(-50%)",
boxShadow: "0 2px 6px rgba(0, 0, 0, 0.2)",
cursor: "pointer",
zIndex: 10,
}}
>
<PlusOutlined style={{ fontSize: 12 }} />
</div>
</Dropdown>
</div>
);
};
const nodeTypes: NodeTypes = {
start: StartNode,
end: EndNode,
middle: MiddleNode,
};
const edgeTypes: EdgeTypes = { custom: CustomEdge };
const initialNodes: Node[] = [
{
id: "1",
type: "start",
data: { onSelect: () => {} },
position: { x: 100, y: 100 },
},
];
const initialEdges: Edge[] = [];
let id = 1;
const getId = () => `node_${++id}`;
const FlowDemo: React.FC = () => {
const lastNodeIdRef = useRef<string | null>("0");
// const [lineNode, setLineNode] = useState([]);
const [nodes, setNodes, onNodesChange] = useNodesState<Node[]>(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge[]>(
initialEdges.map((e) => ({ ...e, type: "custom" }))
);
const onConnect = useCallback((params: Edge | Connection) => {
setEdges((eds) => addEdge(params, eds));
}, []);
const updateEdge = (edgeId: string, edge: Edge) => {
setEdges((eds) => eds.map((e) => (e.id === edgeId ? edge : e)));
};
const updateLineNode = (edgeIds: string[]) => {
setNodes((nds) =>
nds.map((e) => {
if (edgeIds.includes(e.id)) {
return {
...e,
position: {
x: e.position.x + 400,
y: e.position.y,
},
};
} else return e;
})
);
};
const addNodeToRight = useCallback(
(
sourceNodeId: string,
nodeType: string,
label: string,
clickType: string
) => {
const sourceNode = nodes.find((n) => n.id === sourceNodeId);
const sourceEdge = edges.find((e) => e.source === sourceNodeId);
const outgoers = getOutgoers(sourceNode, nodes, edges);
//寻找已有出边节点
// const findTargeNodes = edges
// .reduce((prev, current, index) => {
// if (current.source === sourceNodeId) {
// const currentNode = nodes.find((n) => n.id === current.target);
// prev.push(currentNode);
// } else prev = prev;
// return prev;
// }, [])
const findTargeNodes =outgoers
.sort((a, b) => {
const A = a.position.y;
const B = b.position.y;
return B - A;
});
const hasChildren = findTargeNodes.length > 0;
if (!sourceNode) return;
const newId = getId();
//保证新节点 往下移 不与已有的出边节点 重叠
const newNodePositionY = !hasChildren
? sourceNode.position.y
: findTargeNodes[0].position.y + 120;
const newNode = {
id: newId,
type: nodeType,
position: {
x: sourceNode.position.x + 400,
y: newNodePositionY,
},
data: {
label: `${label}(${newId})`,
nodeType,
onAdd: addNodeToRight,
},
};
setNodes((nds) => [...nds, newNode]);
const newEdge = {
id: `edge_${sourceNodeId}_${newId}`,
type: "custom",
source: sourceNodeId,
target: newId,
data: { onAdd: addNodeToRight },
};
setEdges((eds) => addEdge(newEdge, eds));
},
[nodes, setNodes, setEdges, edges]
);
const findLineNode = (
nodes: Node[],
edges: Edge[],
startNodeId: string
) => {
const visited = new Set();
//目标是从 触发事件边的target节点开始 所以 得包含target节点 本身
const result:string[] = [startNodeId];
function dfs(currentId:string) {
if (visited.has(currentId)) return;
visited.add(currentId);
// 拿到当前节点对象
const node = nodes.find(n => n.id === currentId);
if (!node) return;
// 找所有直接出边指向的下游节点
const outgoers = getOutgoers(node, nodes, edges);
const incomers = getIncomers(node, nodes, edges);
outgoers.forEach(n => {
if (!visited.has(n.id)) {
result.push(n.id);
dfs(n.id);
}
});
}
dfs(startNodeId);
return result;
};
const addNodeToLineRight = useCallback(
(
sourceNodeId: string,
targetNodeId: string,
nodeType: string,
label: string,
clickType: string
) => {
const sourceNode = nodes.find((n) => n.id === sourceNodeId);
const sourceEdge = edges.find((e) => e.source === sourceNodeId);
const targetNode = nodes.find((n) => n.id === targetNodeId);
if (!sourceNode) return;
const newId = getId();
const newNode = {
id: newId,
type: nodeType,
position: {
x: sourceNode.position.x + 400,
y: targetNode ? targetNode.position.y : sourceNode.position.y,
},
data: {
label: `${label}(${newId})`,
nodeType,
onAdd: addNodeToRight,
},
};
setNodes((nds) => [...nds, newNode]);
const newEdge = {
id: `edge_${sourceNodeId}_${newId}`,
type: "custom",
source: sourceNodeId,
target: newId,
data: { onAdd: addNodeToLineRight },
};
const newSourceEdge = {
id: `edge_${newId}_${targetNodeId}`,
type: "custom",
source: newId,
target: targetNodeId,
data: { onAdd: addNodeToLineRight },
};
//删掉原来的边
setEdges((eds) => {
return eds.filter(e=>e.id!==`edge_${sourceNodeId}_${targetNodeId}`)
});
//新增 节点的出边和入边
setEdges((eds) => addEdge(newEdge, eds));
setEdges((eds) => addEdge(newSourceEdge, eds));
const lineNode = findLineNode(nodes, edges,targetNodeId);
updateLineNode(lineNode);
},
[edges, nodes, setNodes, updateEdge, setEdges, updateLineNode]
);
return (
<div style={{ width: "100vw", height: "100vh" }}>
<ReactFlow
nodes={nodes.map((n) => ({
...n,
data: { ...n.data, onAdd: addNodeToRight },
}))}
edges={edges.map((e) => ({
...e,
data: { ...e.data, onAdd: addNodeToLineRight },
}))}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
>
<MiniMap />
<Controls />
<Background variant="dots" gap={12} size={1} />
</ReactFlow>
</div>
);
};
export default FlowDemo;
React Flow
的底层数据协议为有向图
,根据这个逻辑去理解节点的交互逻辑会更简单一点。如果你有其他问题,欢迎留言讨论,或者查看我的其他文章。希望本篇文章对你有所帮助!✨