React Flow 实战(二)—— 拖拽添加节点
上一篇 《React Flow 实战》介绍了自定义节点等基本操作,接下来就该撸一个真正的流程图了
一、ReactFlowProvider
React Flow 提供了两个 Hooks 来处理画布数据:
import {
useStoreState,
useStoreActions
} from 'react-flow-renderer';
通常情况下可以直接使用它们来获取 nodes、edges
但如果页面上同时存在多个 ReactFlow,或者需要在 ReactFlow 外部操作画布数据,就需要使用 ReactFlowProvider 将整个画布包起来
于是整个流程图的入口文件 index.jsx 是这样的:
// index.jsx
import React, { useState } from 'react';
import { ReactFlowProvider } from 'react-flow-renderer';
import Sider from './Sider';
import Graph from './Graph';
import Toolbar from './Toolbar';
import flowStyles from './index.module.less';
export default function FlowPage() {
// 画布实例
const [reactFlowInstance, setReactFlowInstance] = useState(null);
return (
<div className={flowStyles.container}>
<ReactFlowProvider>
{/* 顶部工具栏 */}
<Toolbar instance={reactFlowInstance} />
<div className={flowStyles.main}>
{/* 侧边栏,展示可拖拽的节点 */}
<Sider />
{/* 画布,处理核心逻辑 */}
<Graph
instance={reactFlowInstance}
setInstance={setReactFlowInstance}
/>
</div>
</ReactFlowProvider>
</div>
);
}
这里创建了 reactFlowInstance 这个状态,用来保存 ReactFlow 创建后的实例
这个实例会在 Graph 中设置,但会在 Graph 和 Toolbar 中使用,所以将该状态提升到 index.js 中管理
但这种将 state 和 setState 都传给子组件的方式并不好,最好是使用 useReducer 加以改造,或者引入状态管理节制
整体的目录结构如下
二、拖拽添加节点
简单的拖拽添加节点,可以通过原生 API draggable 实现
在 Sider 中触发节点的 onDragStart 事件,然后在 Graph 中通过 ReactFlow onDrop 来接收
// Sider.jsx
import React from 'react';
import classnames from 'classnames';
import { useStoreState } from 'react-flow-renderer';
import flowStyles from '../index.module.less';
// 可用节点
const allowedNodes = [
{
name: 'Input Node',
className: flowStyles.inputNode,
type: 'input',
},
{
name: 'Relation Node',
className: flowStyles.relationNode,
type: 'relation', // 这是自定义节点类型
},
{
name: 'Output Node',
className: flowStyles.outputNode,
type: 'output',
},
];
export default function FlowSider() {
// 获取画布上的节点
const nodes = useStoreState((store) => store.nodes);
const onDragStart = (evt, nodeType) => {
// 记录被拖拽的节点类型
evt.dataTransfer.setData('application/reactflow', nodeType);
evt.dataTransfer.effectAllowed = 'move';
};
return (
<div className={flowStyles.sider}>
<div className={flowStyles.nodes}>
{allowedNodes.map((x, i) => (
<div
key={`${x.type}-${i}`}
className={classnames([flowStyles.siderNode, x.className])}
onDragStart={e => onDragStart(e, x.type)}
draggable
>
{x.name}
</div>
))}
</div>
<div className={flowStyles.print}>
<div className={flowStyles.printLine}>
节点数量:{ nodes?.length || '-' }
</div>
<ul className={flowStyles.printList}>
{
nodes.map((x) => (
<li key={x.id} className={flowStyles.printItem}>
<span className={flowStyles.printItemTitle}>{x.data.label}</span>
<span className={flowStyles.printItemTips}>({x.type})</span>
</li>
))
}
</ul>
</div>
</div>
);
}
上面还通过 useStoreState 拿到了画布上的节点信息 nodes,该 nodes 基于 Redux 管理,无需手动更新
在 Graph 中,首先需要通过 onLoad 回调得到 ReactFlow 实例
接着处理 onDragOver 事件,更新 dropEffect,和 effectAllowed 保持一致
然后在 onDrop 事件处理函数中,通过 getBoundingClientRect 获取画布容器的坐标信息
但坐标信息需要通过 ReactFlow 实例提供的 project 方法处理为 ReactFlow 坐标系
最后组装节点信息,更新 elements 即可
// Graph/index.jsx import React, { useState, useRef } from 'react'; import ReactFlow, { Controls } from 'react-flow-renderer'; import RelationNode from '../Node/relationNode'; import flowStyles from '../index.module.less'; function getHash(len) { let length = Number(len) || 8; const arr = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'.split(''); const al = arr.length; let chars = ''; while (length--) { chars += arr[parseInt(Math.random() * al, 10)]; } return chars; } export default function FlowGraph(props) { const { setInstance, instance } = props; // 画布的 DOM 容器,用于计算节点坐标 const graphWrapper = useRef(null); // 节点、连线 都通过 elements 来维护 const [elements, setElements] = useState(props.elements || []); // 自定义节点 const nodeTypes = { relation: RelationNode, }; // 画布加载完毕,保存当前画布实例 const onLoad = (instance) => setInstance(instance); const onDrop = (event) => { event.preventDefault(); const reactFlowBounds = graphWrapper.current.getBoundingClientRect(); // 获取节点类型 const type = event.dataTransfer.getData('application/reactflow'); // 使用 project 将像素坐标转换为内部 ReactFlow 坐标系 const position = instance.project({ x: event.clientX - reactFlowBounds.left, y: event.clientY - reactFlowBounds.top, }); const newNode = { id: getHash(), type, position, // 传入节点 data data: { label: `${type} node` }, }; setElements((els) => els.concat(newNode)); };const onDragOver = (event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }; return ( <div className={flowStyles.graph} ref={graphWrapper}> <ReactFlow elements={elements} nodeTypes={nodeTypes} onLoad={onLoad} onDrop={onDrop} onDragOver={onDragOver} > <Controls /> </ReactFlow> </div> ); }
完成以上逻辑,就能够从侧边栏拖拽节点添加到画布上了
// 可以先删除以上有关自定义节点 RelationNode 的代码,试试拖拽功能
但目前的节点只是展示出来了,暂时不能连线,或者更新节点数据,后面逐步完善
三、连线
在画布上连线的时候,会触发 ReactFlow onConnect 事件,并提供连线信息
然后通过 addEdge 来添加连线,这个方法接收两个参数 edgeParams 和 elements,最后返回全新的 elements
// Graph/index.jsx
import ReactFlow, { addEdge } from 'react-flow-renderer';
// ...
export default function FlowGraph(props) {
// ...
// 连线
const onConnect = params => setElements(els => addEdge(params, els));
return (
<ReactFlow
elements={elements}
onConnect={onConnect}
// other...
/>
);
}
如果需要设置连线类型,或者设置其他连线的信息,都可以通过 addEdge 的第一个参数来设置
从节点出口拉出的线,在连接到节点入口前,默认展示的是 bezier 类型的线
如果需要自定义连接中的线的样式,可以使用 connectionLineComponent,具体可以参考官方示例
另外,还可以通过 onEdgeUpdate 来更改连线的起点或终点,参考官方示例
四、获取画布数据
在最开始的 index.jsx 中维护了一份 ReactFlow 的画布实例 reactFlowInstance,并传给了 Graph 和 Toolbar
通过 reactFlowInstance 就可以很方便的获取画布数据
// Toolbar.jsx
import React, { useCallback } from 'react';
import classnames from 'classnames';
import flowStyles from '../index.module.less';
export default function Toolbar({ instance }) {
// 保存
const handleSave = useCallback(() => {
console.log('toObject', instance.toObject());
}, [instance]);
return (
<div className={flowStyles.toolbar}>
<button
className={classnames([flowStyles.button, flowStyles.primaryBtn])}
onClick={handleSave}
>
保存
</button>
</div>
);
}
上面使用的是 Instance.toObject,拿到的是画布的全量数据,如果只需要 elements 可以使用 Instance.getElements
完整的实例方法可以参考官方文档
除了通过实例获取画布数据,还可以使用 useStoreState
import ReactFlow, { useStoreState } from 'react-flow-renderer';
const NodesDebugger = () => {
const nodes = useStoreState((state) => state.nodes);
const edges = useStoreState((state) => state.edges);
console.log('nodes', nodes);
console.log('edges', edges);
return null;
};
const Flow = () => (
<ReactFlow elements={elements}>
<NodesDebugger />
</ReactFlow>
);
但这样获取的 nodes 会携带一些画布信息
具体使用哪种方式,可以根据实际的业务场景来取舍
实际项目中的流程图,通常都会在节点甚至连线上配置各种数据
我们可以通过 elements 中各个元素的 data 来维护,但这真的合理吗?
elements 保存了节点和连线的位置、样式信息,用于 ReactFlow 绘制流程图,和业务数据并无关联
所以我建议以 map 的形式单独维护业务数据,可以通过节点或连线的 id 快速查找
具体的实现方案有很多,下一篇文章将介绍基于 React Context 的流程图数据管理方案
// 文章还在施工中,有兴趣可以先看下项目 flow-demo-app