背景
在低代码场景中,流程是一个必不可少的能力。流程的能力就是给予用户通过一个表单触发一个在不同时间节点流转的异步任务。最常见的就是请假申请,用户提交一个请假申请表单,提交成功之后流程开始运行,直到下一个节点被对应的人员所处理,流程的状态才会向后续步骤行进。那么如何将我们所构思的流程设计器付诸实现呢?今天便来手把手教大家!
流程设计
首先需要进行流程设计,对于前端来说, 流程设计器有这么几个点需要满足:
- 用户根据某个表单创建一个流程
- 可以定义流程的节点
- 可以定义流程的节点所对应的配置
其中定义流程的节点就是定义异步任务的各个环节以及它们之间的关系。流程节点的配置对于实现来说就是一个表单,这个表单可以对当前步骤所需的能力进行配置,例如流程触发的表单字段,审批人等。
据此我们可以确定流程的基本数据模型,第一直觉可能会考虑用一个有向无环图来表示整个流程,图中的节点对应了流程的节点,节点之间的连线表示流程之间的前置关系。但图处理起来过于复杂,这里考虑使用数组描述,数组的元素包括节点和边,每个节点都有一个节点名称及 id,节点之间的边包含了节点的 id 信息。
数据接口定义
以下是使用 typescript 定义的数据模型接口:
export interface Node {
id: ElementId;
position: XYPosition;
type?: string;
__rf?: any;
data?: T;
style?: CSSProperties;
className?: string;
targetPosition?: Position;
sourcePosition?: Position;
isHidden?: boolean;
draggable?: boolean;
selectable?: boolean;
connectable?: boolean;
dragHandle?: string;
}
export interface Edge {
id: ElementId;
type?: string;
source: ElementId;
target: ElementId;
sourceHandle?: ElementId | null;
targetHandle?: ElementId | null;
label?: string | ReactNode;
labelStyle?: CSSProperties;
labelShowBg?: boolean;
labelBgStyle?: CSSProperties;
labelBgPadding?: [number, number];
labelBgBorderRadius?: number;
style?: CSSProperties;
animated?: boolean;
arrowHeadType?: ArrowHeadType;
isHidden?: boolean;
data?: T;
className?: string;
}
export type FlowElement = Node | Edge;
export interface Data {
nodeData: Record;
businessData: Record;
}
export interface WorkFlow {
version: string;
shapes: FlowElement[];
}
整个数据模型的定义很清晰,整体的结构是一个 WorkFlow,其中包含的 version 即版本,shapes 为边以及节点的数组集合。其中 FlowElement 可以是 Node(节点)也可以是 Edge(边),对于 Node 而言其中会存有数据,数据可以通过节点的 data 属性访问。并且数据分为了两部分,一部分是节点的元数据我们称之为 nodeData,一部分是节点所对应的业务数据我们称之为 businessData,元数据主要用于绘制流程图时使用,业务数据主要用于流程引擎执行时使用。
流程实现
对于流程设计器的实现我们使用 react-flow-renderer
这个开源库,它的优点主要有以下三点:
- 轻松实现自定义节点
- 自定义边
- 预置小地图等图形控件
react-flow-renderer
只需要传入 shapes 数组即可渲染出整个流程图,在传入之前需要对 elements 做布局处理,对于布局我们将使用 dagre 这个图形布局库,以下是布局实现。
流程图布局
import store from './store';
import useObservable from '@lib/hooks/observable';
function App() {
const { elements } = useObservable(store);
const [dagreGraph, setDagreGraph] = useState(() => new dagre.graphlib.Graph());
dagreGraph.setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({ rankdir: 'TB', ranksep: 90 });
elements?.forEach((el) => {
if (isNode(el)) {
return dagreGraph.setNode(el.id, {
width: el.data?.nodeData.width,
height: el.data?.nodeData.height,
});
}
dagreGraph.setEdge(el.source, el.target);
});
dagre.layout(dagreGraph);
const layoutedElements = elements?.map((ele) => {
const el = deepClone(ele);
if (isNode(el)) {
const nodeWithPosition = dagreGraph.node(el.id);
el.targetPosition = Position.Top;
el.sourcePosition = Position.Bottom;
el.position = {
x: nodeWithPosition.x - ((el.data?.nodeData.width || 0) / 2),
y: nodeWithPosition.y,
};
}
return el;
});
return (
<>
>
)
}
在使用 dagre 时首先需要调用 dagre.graphlib.Graph 生成一个实例,setDefaultEdgeLabel 用于设置 label,由于这里不需要,故将其置为空。setGraph 用于配置图形属性,通过指定 rankdir 为 TB 意味着布局从 top 到 bottom 也就是从上到下的布局方式,ranksep 表示节点之间的距离。接下来只需要循环元素将节点通过 setNode,边通过 setEdge 设置到 dagre,然后调用 dagre.layout 即可。在设置节点时需要指定节点 id 及节点的宽高,设置边时仅需指定边的起始以及结束点的 id 即可。最后还需要对节点的位置进行微调,因为 dagre 默认的布局方式是垂直左对齐的,我们需要让其垂直居中对齐,因此需要减去其宽度的一半。
自定义节点
对于节点这部分,由于默认的节点类型不能满足要求,我们需要自定义节点,这里就拿结束节点进行举例:
import React from 'react';
import { Handle, Position } from 'react-flow-renderer';
import Icon from '@c/icon';
import type { Data } from '../type';
function EndNodeComponent({ data }: { data: Data }): JSX.Element {
return (
{data.nodeData.name}
);
}
function End(props: any): JSX.Element {
return (
<>
>
);
}
export const nodeTypes = { end: EndNode };
function App() {
// 省略部分内容
return (
<>
>
)
}
自定义节点通过 nodeTypes 指定,我们这里指定了自定义结束节点,节点的具体实现在于 EndNode 这个函数组件。我们只需要在创建节点的时候指定节点的 type 为 end 即可使用 EndNode 渲染,创建节点的逻辑如下:
function nodeBuilder(id: string, type: string, name: string, options: Record) {
return {
id,
type,
data: {
nodeData: { name },
businessData: getNodeInitialData(type)
}
}
}
const endNode = nodeBuilder(endID, 'end', '结束', {
width: 100,
height: 28,
parentID: [startID],
childrenID: [],
})
elements.push(endNode);
自定义边
自定义边与自定义节点类似,通过指定 edgeTypes 完成,下面是实现自定义边的举例代码:
import React, { DragEvent, useState, MouseEvent } from 'react';
import { getSmoothStepPath, getMarkerEnd, EdgeText, getEdgeCenter } from 'react-flow-renderer';
import cs from 'classnames';
import ToolTip from '@c/tooltip/tip';
import type { EdgeProps, FormDataData } from '../type';
import './style.scss';
export default function CustomEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
label,
arrowHeadType,
markerEndId,
source,
target,
}: EdgeProps): JSX.Element {
const edgePath = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 0,
});
const markerEnd = getMarkerEnd(arrowHeadType, markerEndId);
const [centerX, centerY] = getEdgeCenter({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
const formDataElement = elements.find(({ type }) => type === 'formData');
const hasForm = !!(formDataElement?.data?.businessData as FormDataData)?.form.name;
const cursorClassName = cs({ 'cursor-not-allowed': !hasForm });
return (
<>
{status === 'DISABLE' && (
)}
{!hasForm && (
)}
>
);
}
export const edgeTypes = {
plus: CustomEdge,
};
function App() {
// 省略部分内容
return (
<>
>
)
}
边的具体实现在于 CustomEdge 这个函数组件,这里我们通过使用 react-flow-renderer
提供的 EdgeText 组件,在边的中间显示一个加号,以便后续可以添加点击加号时的处理事件。EdgeText 需要传入一个 label 作为显示的内容,然后需要指定内容显示的坐标,我们这里让文本显示在边的中间,边的中间位置通过调用 getEdgeCenter 来计算得到,计算的时候需传入起点与终点的坐标位置。
关于起始点的锚点,锚点有四种,分别是 Left、Top、Right、Bottom,分别表示一个节点的左侧、顶部、右侧、下面的边的中间位置,节点与节点之间的边的起始位置都与锚点相连。此外这里还需判断 type 为 formData 的元素是否配置了表单,如果没有配置则通过设置 cursor-not-allow 这个 className 来禁用用户交互,并提示用户需要选择一张工作表才能继续,这是因为我们的流程需要指定一个触发表单才有意义。
接下来,我们只需要在创建边的时候指定边的 type 为 plus 即可使用 CustomEdge 渲染,创建边的逻辑如下:
export function edgeBuilder(
startID?: string,
endID?: string,
type = 'plus',
label = '+',
): Edge {
return {
id: `e${startID}-${endID}`,
type,
source: startID as string,
target: endID as string,
label,
arrowHeadType: ArrowHeadType.ArrowClosed,
};
}
const edge = edgeBuilder('startNodeId', 'end');
elements.push(edge);
以上即创建了一个类型为 plus 的边,其中 startNodeId 与 end 分别为边的起始节点与结束节点。如果没有指定类型则默认为 plus 类型,label 默认显示一个加号。
添加节点
有了自定义节点和自定义边之后,就可以新增节点了。新增节点有多种方式,可以拖拽也可以点击,这里使用较直观的拖拽来实现。
首先给边添加点击事件的处理,在用户点击加号之后,显示可用的节点供用户拖拽。
import store, { updateStore } from './store';
export type CurrentConnection = {
source?: string;
target?: string;
position?: XYPosition;
}
export default function CustomEdge(props: EdgeProps): JSX.Element {
function switcher(currentConnection: CurrentConnection) {
updateStore((s) => ({ ...s, currentConnection, nodeIdForDrawerForm: 'components' }));
}
function onShowComponentSelector(e: MouseEvent): void {
e.stopPropagation();
if (!hasForm) {
return;
}
switcher({ source, target, position: { x: centerX, y: centerY } });
}
return (
)
}
在用户点击加号之后,我们首先阻止事件冒泡,防止触发 react-flow-renderer
默认的事件处理机制。然后判断用户是否已配置了工作表,如果没有配置,则什么也不做;否则需将当前边所对应的起始节点与结束节点的 id 与边的中点位置记录到状态 store 中。在更改状态的时候指定 nodeIdForDrawerForm 为 components,那么在状态的消费端就会触发节点选择侧边栏的显示,显示节点选择器的代码如下:
import React from 'react';
import useObservable from '@lib/hooks/use-observable';
import Drawer from '@c/drawer';
import store, { toggleNodeForm } from '../store';
function DragNode({
text, type, width, height, iconName, iconClassName
}: RenderProps): JSX.Element {
function onDragStart(event: DragEvent, nodeType: string, width: number, height: number): void {
event.dataTransfer.setData('application/reactflow', JSON.stringify({
nodeType,
nodeName: text,
width,
height,
}));
event.dataTransfer.effectAllowed = 'move';
}
return (
onDragStart(e, type, width, height)}
>
{text}
);
}
const nodeLists = [{
text: '自定义节点1',
type: 'type1',
iconName: 'icon1',
iconClassName: 'bg-teal-500',
}, {
text: '自定义节点2',
type: 'type2',
iconName: 'icon2',
iconClassName: 'bg-teal-1000',
}];
export default function Components(): JSX.Element {
const { nodeIdForDrawerForm } = useObservable(store);
return (
<>
{nodeIdForDrawerForm === 'components' && (
选择一个组件
了解组件