React Flow 实战(三)—— 使用 React.context 管理流程图数据
前面两篇关于 React Flow 的文章已经介绍了如何绘制流程图
而实际项目中,流程图上的每一个节点,甚至每一条连线都需要维护一份独立的业务数据
这篇文章将介绍通过 React.context 来管理流程图数据的实际应用
项目结构:
.
├── Graph
│ └── index.jsx
├── Sider
│ └── index.jsx
├── Toolbar
│ └── index.jsx
├── components
│ ├── Edge
│ │ ├── LinkEdge.jsx
│ │ └── PopoverCard.jsx
│ ├── Modal
│ │ ├── RelationNodeForm.jsx
│ │ └── index.jsx
│ └── Node
│ └── RelationNode.jsx
├── context
│ ├── actions.js
│ ├── index.js
│ └── reducer.js
├── flow.css
└── flow.jsx
结合项目代码食用更香,仓库地址:https://github.com/wisewrong/bolg-demo-app/tree/main/flow-demo-app
一、定义 state
代码未敲,设计先行。在正式动工之前,先想清楚应该维护哪些数据
首先是 React Flow 的画布实例 reactFlowInstance,它会在 Graph.jsx 中创建并使用
另外 Toolbar.jsx 中保存的时候也会用到 reactFlowInstance,所以可以将它放到 context 中维护
然后是 React Flow 的节点/连线信息 elements,以及每个节点/连线对应的配置信息,它们可以放到 elements 中,通过每个元素的 data 来维护
但我更倾向于将业务数据拆开,用 elements 维护坐标等画布信息,另外创建一个 Map 对象 flowData 来维护业务数据
配置节点/连线业务数据的表单通常是放在 Modal 或 Drawer 里,它们肯定会放到画布外 难道还能放到节点里?,但通过节点/连线来触发
所以还需要另外维护一个 modalConfig,来控制 Modal 的显示/隐藏,以及传入 Modal 的节点数据
所以最终的 state 是这样的:
const initState = {
// 画布实例
reactFlowInstance: null,
// 节点数据、连线数据
elements: [],
// 画布数据
flowData: new Map(),
// 弹窗信息
modalConfig: {
visible: false,
nodeType: '',
nodeId: '',
},
};
二、创建 context
管理整个画布的状态,自然就会用到 useReducer
为了便于维护,我将整个 context 拆为三部分:index.js、reducer.js、actions.js
其中 actions.js 用来管理 dispatch 的事件名称:
// context/actions.js
export const SET_INSTANCE = 'set_instance';
export const SET_ELEMENTS = 'set_elements';
export const SET_FLOW_NODE = 'set_flow_node';
export const REMOVE_FLOW_NODE = 'remove_flow_node';
export const OPEN_MODAL = 'open_modal';
export const CLOSE_MODAL = 'close_modal';
reducer.js 管理具体的事件处理逻辑
// context/reducer.js
import * as Actions from "./actions";
// 保存画布实例
const setInstance = (state, reactFlowInstance) => ({
...state,
reactFlowInstance,
});
// 设置节点/连线数据
const setElements = (state, elements) => ({
...state,
elements: Array.isArray(elements) ? elements : [],
});
// 保存节点配置信息
const setFlowNode = (state, node) => {
// ...
};
// 删除节点,同时删除节点配置信息
const removeFlowNode = (state, node) => {
// ...
};
const openModal = (state, node) => {
// ...
}
const closeModal = (state) => {
// ...
}
// 管理所有处理函数
const handlerMap = {
[Actions.SET_INSTANCE]: setInstance,
[Actions.SET_FLOW_NODE]: setFlowNode,
[Actions.REMOVE_FLOW_NODE]: removeFlowNode,
[Actions.OPEN_MODAL]: openModal,
[Actions.CLOSE_MODAL]: closeModal,
[Actions.SET_ELEMENTS]: setElements,
};
const reducer = (state, action) => {
const { type, payload } = action;
const handler = handlerMap[type];
const res = typeof handler === "function" && handler(state, payload);
return res || state;
};
export default reducer;
最后 index.js 管理初始状态,并导出相关产物
// context/index.js
import React, { createContext, useReducer } from 'react';
import reducer from './reducer';
import * as Actions from './actions';
const FlowContext = createContext();
const initState = {
// 画布实例
reactFlowInstance: null,
// 节点数据、连线数据
elements: [],
// 画布数据
flowData: new Map(),
// 弹窗信息
modalConfig: {
visible: false,
nodeType: '',
nodeId: '',
},
};
const FlowContextProvider = (props) => {
const { children } = props;
const [state, dispatch] = useReducer(reducer, initState);
return (
<FlowContext.Provider value={{ state, dispatch }}>
{children}
</FlowContext.Provider>
);
};
export { FlowContext, FlowContextProvider, Actions };
三、节点的添加与删除
建立好状态管理体系之后,就可以通过 Provider 使用了
// flow.jsx
import React from 'react';
import { ReactFlowProvider } from 'react-flow-renderer';
import Sider from './Sider';
import Graph from './Graph';
import Toolbar from './Toolbar';
import Modal from './components/Modal';
// 引入 Provider
import { FlowContextProvider } from './context';
import './flow.css';
export default function FlowPage() {
return (
<div className="container">
<FlowContextProvider>
<ReactFlowProvider>
{/* 顶部工具栏 */}
<Toolbar />
<div className="main">
{/* 侧边栏,展示可拖拽的节点 */}
<Sider />
{/* 画布,处理核心逻辑 */}
<Graph />
</div>
{/* 弹窗,配置节点数据 */}
<Modal />
</ReactFlowProvider>
</FlowContextProvider>
</div>
);
}
上一篇文章《React Flow 实战(二)—— 拖拽添加节点》已经介绍过拖放节点,这里就不再赘述拖拽的实现
在添加节点之后,需要通过 reducer 中的方法来更新数据
// Graph/index.jsx
import React, { useRef, useContext } from "react";
import ReactFlow, { addEdge, Controls } from "react-flow-renderer";
import { FlowContext, Actions } from "../context";
export default function FlowGraph(props) {
const { state, dispatch } = useContext(FlowContext);
const { elements, reactFlowInstance } = state;
const setReactFlowInstance = (instance) => {
dispatch({
type: Actions.SET_INSTANCE,
payload: instance,
});
};
const setElements = (els) => {
dispatch({
type: Actions.SET_ELEMENTS,
payload: els,
});
};
// 画布加载完毕,保存当前画布实例
const onLoad = (instance) => setReactFlowInstance(instance);
// 连线
const onConnect = (params) =>
setElements(
addEdge(
{
...params,
type: "link",
},
elements
)
);
// 拖拽完成后放置节点
const onDrop = (event) => {
event.preventDefault();
const newNode = {
// ...
};
dispatch({
type: Actions.SET_FLOW_NODE,
payload: {
id: newNode.id,
...newNode.data,
},
});
setElements(elements.concat(newNode));
};
// ...
}
同时在 reducer.js 中完善相应的逻辑,通过节点 id 维护节点数据
// context/reducer.js
// 保存节点配置信息
const setFlowNode = (state, node) => {
const nodeId = node?.id;
if (!nodeId) return state;
state.flowData.set(nodeId, node);
return state;
};
// ...
由于 elements 和 flowData 已经解耦,所以如需更新节点数据,直接使用 setFlowNode 更新 flowData 即可,不需要操作 elements
而如果是删除节点,可以通过 ReactFlow 提供的 removeElements 方法来快速处理 elements
// context/reducer.js
import { removeElements } from "react-flow-renderer";
// 删除节点,同时删除节点配置信息
const removeFlowNode = (state, node) => {
const { id } = node;
const { flowData } = state;
const res = { ...state };
if (flowData.get(id)) {
flowData.delete(id);
res.elements = removeElements([node], state.elements);
}
return res;
};
// ...
节点数据的增删改就完成了,只要保证在所有需要展示节点信息的地方(画布节点、弹窗表单、连线弹窗)都通过 flowData 获取,维护起来就会很轻松
四、弹窗表单
最后再聊一聊关于弹窗表单的设计
一开始设计 state 的时候就提到过,整个画布只有一个弹窗,为此还专门维护了一份 modalConfig
弹窗可以只有一个,但不同类型的节点对应的表单却各有不同,这时候就需要创建不同的表单组件,通过节点类型来切换
// Modal/index.jsx
import React, { useContext, useRef } from "react";
import { Modal } from "antd";
import RelationNodeForm from "./RelationNodeForm";
import { FlowContext, Actions } from "../../context";
// 通过节点类型来切换对应的表单组件
const componentsMap = {
relation: RelationNodeForm,
};
export default function FlowModal() {
const formRef = useRef();
const { state, dispatch } = useContext(FlowContext);
const { modalConfig } = state;
const handleOk = () => {
// 组件内部需要暴露一个 submit 方法
formRef.current.submit().then(() => {
dispatch({ type: Actions.CLOSE_MODAL });
});
};
const handleCancel = () => dispatch({ type: Actions.CLOSE_MODAL });
const Component = componentsMap[modalConfig.nodeType];
return (
<Modal title="编辑节点" visible={modalConfig.visible} onOk={handleOk} onCancel={handleCancel}>
{Component && <Component ref={formRef} />}
</Modal>
);
}
但不同的表单组件,最后都是通过弹窗 footer 上的“确定”按钮来提交,而提交表单的逻辑却有可能不同
我这里的做法是,在表单组件内部暴露一个 submit 方法,通过弹窗的 onOk 回调触发
// Modal/RelationNodeForm.jsx
import React, { useContext, useEffect, useImperativeHandle } from "react";
import { Input, Form } from "antd";
import { FlowContext, Actions } from "../../context";
function RelationNodeForm(props, ref) {
const { state, dispatch } = useContext(FlowContext);
const { flowData, modalConfig } = state;
const [form] = Form.useForm();
const initialValues = flowData.get(modalConfig.nodeId) || {};
useImperativeHandle(ref, () => ({
// 将 submit 方法暴露给父组件
submit: () => {
return form
.validateFields()
.then((values) => {
dispatch({
type: Actions.SET_FLOW_NODE,
payload: {
id: modalConfig.nodeId,
...values,
},
});
})
.catch((err) => {
return false;
});
},
}));
useEffect(() => {
form.resetFields();
}, [modalConfig.nodeId, form]);
return (
<Form form={form} initialValues={initialValues}>
{/* Form.Item */}
</Form>
);
}
export default React.forwardRef(RelationNodeForm);
关于 React Flow 的实战就到这里了,本文介绍的是状态管理,所以很多业务代码就没有贴出来
有需要的可以看下 GitHub 上的代码,仓库地址在本文的开头已经贴出来了
总的来说 React Flow 用起来还是挺方便,配合良好的状态管理体系,应该能适用于大部分的流程图需求
如果以后遇到了相当复杂的场景,我会再分享出来~