背景

在低代码场景中,流程是一个必不可少的能力。流程的能力就是给予用户通过一个表单触发一个在不同工夫节点流转的异步工作。最常见的就是销假申请,用户提交一个销假申请表单,提交胜利之后流程开始运行,直到下一个节点被对应的人员所解决,流程的状态才会向后续步骤前进。那么如何将咱们所构思的流程设计器付诸实现呢?明天便来手把手教大家!

流程设计

首先须要进行流程设计,对于前端来说, 流程设计器有这么几个点须要满足:

  1. 用户依据某个表单创立一个流程
  2. 能够定义流程的节点
  3. 能够定义流程的节点所对应的配置

其中定义流程的节点就是定义异步工作的各个环节以及它们之间的关系。流程节点的配置对于实现来说就是一个表单,这个表单能够对以后步骤所需的能力进行配置,例如流程触发的表单字段,审批人等。

据此咱们能够确定流程的根本数据模型,第一直觉可能会思考用一个有向无环图来示意整个流程,图中的节点对应了流程的节点,节点之间的连线示意流程之间的前置关系。但图解决起来过于简单,这里思考应用数组形容,数组的元素包含节点和边,每个节点都有一个节点名称及 id,节点之间的边蕴含了节点的 id 信息。

数据接口定义

以下是应用 typescript 定义的数据模型接口:

export interface Node<T = any> {  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<T = any> {  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<T = any> = Node<T> | Edge<T>;export interface Data {  nodeData: Record<string, any>;  businessData: Record<string, any>;}export interface WorkFlow {  version: string;  shapes: FlowElement<Data>[];}

整个数据模型的定义很清晰,整体的构造是一个 WorkFlow,其中蕴含的 version 即版本,shapes 为边以及节点的数组汇合。其中 FlowElement 能够是 Node(节点)也能够是 Edge(边),对于 Node 而言其中会存有数据,数据能够通过节点的 data 属性拜访。并且数据分为了两局部,一部分是节点的元数据咱们称之为 nodeData,一部分是节点所对应的业务数据咱们称之为 businessData,元数据次要用于绘制流程图时应用,业务数据次要用于流程引擎执行时应用。

流程实现

对于流程设计器的实现咱们应用 react-flow-renderer 这个开源库,它的长处次要有以下三点:

  1. 轻松实现自定义节点
  2. 自定义边
  3. 预置小地图等图形控件

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 (    <>      <ReactFlow        className="cursor-move"        elements={layoutedElements}      />    </>  )}

在应用 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 (    <div      className="shadow-flow-header rounded-tl-8 rounded-tr-8 rounded-br-0 rounded-bl-8        bg-white w-100 h-28 flex items-center cursor-default"    >      <section className="flex items-center p-4 w-full h-full justify-center">        <Icon name="stop_circle" className="mr-4 text-red-600" />        <span className="text-caption-no-color-weight font-medium text-gray-600">          {data.nodeData.name}        </span>      </section>    </div>  );}function End(props: any): JSX.Element {  return (    <>      <Handle        type="target"        position={Position.Top}        isConnectable={false}      />      <EndNodeComponent {...props} />    </>  );}export const nodeTypes = { end: EndNode };function App() {  // 省略局部内容  return (    <>      <ReactFlow        className="cursor-move"        elements={layoutedElements}        nodeTypes={nodeTypes}      />    </>  )}

自定义节点通过 nodeTypes 指定,咱们这里指定了自定义完结节点,节点的具体实现在于 EndNode 这个函数组件。咱们只须要在创立节点的时候指定节点的 type 为 end 即可应用 EndNode 渲染,创立节点的逻辑如下:

function nodeBuilder(id: string, type: string, name: string, options: Record<string, any>) {  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 (    <>      <g        className={cs(cursorClassName, { 'opacity-50': !hasForm })}      >        <path          id={id}          style={{ ...style, borderRadius: '50%' }}          className={cs('react-flow__edge-path cursor-pointer pointer-events-none', cursorClassName)}          d={edgePath}          markerEnd={markerEnd}        />        {status === 'DISABLE' && (          <EdgeText            className={cursorClassName}            style={{              filter: 'drop-shadow(0px 8px 24px rgba(55, 95, 243, 1))',              pointerEvents: 'all',            }}            x={centerX}            y={centerY}            label={label}          />        )}      </g>      {!hasForm && (        <foreignObject          className="overflow-visible workflow-node--tooltip"          x={centerX + 20}          y={centerY - 10}          width="220"          height="20"        >          <ToolTip            label="请为开始节点抉择一张工作表"            style={{              transform: 'none',              backgroundColor: 'transparent',              alignItems: 'center',            }}            labelClassName="whitespace-nowrap text-12 bg-gray-700 rounded-8 text-white pl-5"          >            <span></span>          </ToolTip>        </foreignObject>      )}    </>  );}export const edgeTypes = {  plus: CustomEdge,};function App() {  // 省略局部内容  return (    <>      <ReactFlow        className="cursor-move"        elements={layoutedElements}        nodeTypes={nodeTypes}        edgeTypes={edgeTypes}      />    </>  )}

边的具体实现在于 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<SVGElement>): void {    e.stopPropagation();    if (!hasForm) {      return;    }    switcher({ source, target, position: { x: centerX, y: centerY } });  }  return (    <EdgeText      // ... 省略局部内容      onClick={onShowComponentSelector}      // ... 省略局部内容    />  )}

在用户点击加号之后,咱们首先阻止事件冒泡,避免触发 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 (    <div      className="bg-gray-100 rounded-8 cursor-move flex items-center overflow-hidden       border-dashed hover:border-blue-600 border transition"      draggable      onDragStart={(e) => onDragStart(e, type, width, height)}    >      <Icon name={iconName} size={40} className={cs('mr-4 text-white', iconClassName)} />      <span className="ml-16 text-body2">{text}</span>    </div>  );}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' && (        <Drawer          title={(            <div>              <span className="text-h5 mr-16">抉择一个组件</span>              <span className="text-caption text-underline"> 理解组件</span>            </div>          )}          distanceTop={0}          onCancel={() => toggleNodeForm('')}          className="flow-editor-drawer"        >          <div>            <div className="text-caption-no-color text-gray-400 my-12">人工解决</div>            <div className="grid grid-cols-2 gap-16">              {nodeLists.map((node) => (                <DragNode                  {...node}                  key={node.text}                  width={200}                  height={72}                />              ))}            </div>          </div>        </Drawer>      )}    </>  );}function App() {  // 省略局部内容  return (    <>      {/* 省略局部内容 */}      <Components />    </>  )}

这里在 App 中引入 Components 组件,节点选择器由 Components 组件具体实现。其实现过程也很简略,通过 Drawer 组件循环渲染每个 DragNode,DragNode 依据 nodeLists 渲染每一个节点信息。当然,Components 是否渲染取决于 nodeIdForDrawerForm === 'components' 是否成立。最初在每个节点开始拖拽时,通过 onDragStart 将节点名称、节点类型以及节点宽高通过 dataTransfer 存储起来。

当用户将节点拖拽到连线两头时,咱们做以下解决即可:

import { nanoid } from 'nanoid';import { XYPosition } from 'react-flow-renderer';import { updateStore } from './store';import { nodeBuilder } from './utils';function setElements(eles: Elements): void {  updateStore((s) => ({ ...s, elements: eles }));}function getCenterPosition(position: XYPosition, width: number, height: number): XYPosition {  return { x: position.x - (width / 2), y: position.y - (height / 2) };}function onDrop(e: DragEvent): Promise<void> {  e.preventDefault();  if (!e?.dataTransfer) {    return;  }  const { nodeType, width, height, nodeName } = JSON.parse(    e.dataTransfer.getData('application/reactflow'),  );  const { source, target, position } = currentConnection;  if (!source || !target || !position) {    return;  }  addNewNode({ nodeType, width, height, nodeName, source, target, position });  updateStore((s) => ({ ...s, currentConnection: {} }));}function App() {  const { elements } = useObservable(store);  // 省略局部内容  function addNewNode({ nodeType, width, height, nodeName, source, target, position }) {    const id = nodeType + nanoid();    const newNode = nodeBuilder(id, nodeType, nodeName, {      width,      height,      parentID: [source],      childrenID: [target],      position: getCenterPosition(position, width, height),    });    let newElements = elements.concat([newNode, edgeBuilder(source, id), edgeBuilder(id, target)];    newElements = removeEdge(newElements, source, target);    setElements(newElements);  }  return (    <>      {/* 省略局部内容 */}      <ReactFlow        onDrop={onDrop}      />    </>  )}

当拖拽放下的时候执行 onDrop,调用 preventDefault 避免触发 react-flow-renderer 的默认行为,而后判断是否存在 dataTransfer,否则返回。接下来通过 nodeType、width、height、nodeName 别离获取节点类型、节点宽度、节点高度、节点名称,再调用 addNewNode 执行新增节点的操作。

实现之后咱们须要将 currentConnection 重置为初始值。其中 addNewNode 的过程给节点生成了一个 id,并应用 nodeType 作为前缀,而后调用 nodeBuilder 生成新节点。须要留神咱们这里将 source 和 target 记录在了 childrenID 和 parentID 中,以便后续节点的操作。getCenterPosition 用于获取节点的地位,因为须要将节点放在连线中点,节点的终点应该是原始终点减去节点宽高的各一半。

须要的节点筹备好之后,接下来须要为新节点生成两条边,并将之前的起始与完结节点之间的连线删除。这里则是别离通过 edgeBuilder 与 removeEdge 实现,removeEdge 由 react-flow-renderer 提供,其接管三个参数:第一个是元素的数组;第二个是起始节点的 id;第三个是完结节点的 id。最终返回的是删除起始节点到完结节点之间连线的新的元素数组。最初通过 setElements 将新的元素数组更新到 store 状态中即可更新画布。

删除节点

有了增加节点的能力,接下来须要的是删除节点的性能。首先须要在节点的右上角渲染一个删除按钮,当用户点击删除按钮之后,则将以后节点以及与以后节点关联的边一并删除。上面是删除节点组件的实现:

import { FlowElement, removeElements } from 'react-flow-renderer';import Icon from '@c/icon';import useObservable from '@lib/util/observable';import store, { updateStore } from './store';function onRemoveNode(nodeID: string, elements: FlowElement<Data>[]): FlowElement<Data>[] {  const elementToRemove = elements.find((element) => element.id === nodeID);  const { parentID, childrenID } = elementToRemove?.data?.nodeData || {};  const edge = edgeBuilder(parentID, childrenID);  const newElements = removeElements([elementToRemove], elements);  return newElements.concat(edge);}interface Props {  id: string;}export function NodeRemover({ id }: Props) {  const { elements } = useObservable(store);  function onRemove() {    const newElements = onRemoveNode(id, elements);    updateStore((s) => ({ ...s, elements: newElements }));  }  return (    <Icon      name="close"      onClick={onRemove}    />  )}function CustomNode({ id }: Props) {  // 省略局部内容  return (    <>      <NodeRemover id={id}>    </>  )}

当点击删除按钮之后,通过以后节点的 id 找到以后节点元素,而后调用 react-flow-renderer 提供的 removeElements 办法将以后节点从 elements 中删除即可。之后还须要新增一条前置节点与后续节点的一条连线,保障图是连通的。最初只需在自定义节点中引入 NodeRemover 组件即可。

节点配置

节点配置的逻辑相似于增加节点,只不过这里侧边栏组件中渲染的不是供拖拽的节点列表,而是以后对应节点的配置表单。首先须要实现的是点击以后节点展现配置表单,以下是节点点击事件处理的实现:

function CustomNode({ id }: Props) {  // 省略局部内容  function handleConfig() {    updateStore((s) => ({      ...s,      nodeIdForDrawerForm: id,    }));  }  return (    <>      <div onClick={handleConfig}>        <NodeRemover id={id}>      </div>    </>  )}

当点击节点之后,将 nodeIdForDrawerForm 即以后节点的 id 记录在状态中。接下来实现 ConfigForm 表单:

import store, { updateStore } from './store';import Form from './form';function ConfigForm() {  const { nodeIdForDrawerForm, id, name, elements } = useObservable(store);  const currentNodeElement = elements.find(({ id }) => id === nodeIdForDrawerForm);  const defaultValue = currentNodeElement?.data?.businessData;  function onSubmit(data: BusinessData): void {    const newElements = elements.map(element => {      if (element.id === nodeIdForDrawerForm) {        element.data.businessData = data;      }      return element;    })    updateStore((s) => ({ ...s, elements }))  }  function closePanel() {    updateStore((s) => ({      ...s,      nodeIdForDrawerForm: '',    }));  }  return (    <Form      defaultValue={defaultValue}      onSubmit={onSubmit}      onCancel={closePanel}    />>  )}

这里依据 nodeIdForDrawerForm 获取以后节点元素,将它的 businessData 作为默认值传给表单,而后在表单提交的时候将值写回到元素状态中即可。最初如果在点击勾销的时候须要敞开侧边栏表单,则只需将 nodeIdForDrawerForm 重置为空即可。

接下来将 ConfigForm 组件引入 App 即可实现点击显示配置表单,敞开侧边栏时不渲染表单的性能:

import store from './store';function App() {  // 省略局部内容  const { nodeIdForDrawerForm } = useObservable(store);  return (    <>      {/* 省略局部内容 */}      <Components />      {nodeIdForDrawerForm && (        <ConfigForm />      )}    </>  )}

这里通过判断 nodeIdForDrawerForm 是否存在决定是否渲染表单,当 nodeIdForDrawerForm 被重置为空的时候,表单不会被渲染。

问题与难点

目前已知有两个问题:

  1. 连线会被节点笼罩,导致连线看不到。
  2. 连线与连线之间会有穿插。

对于第一种状况,react-flow-renderer 自身并没有提供解决方案,只能靠本人实现 path find 算法。第二种状况则很难解决,当图形很简单的时候很难防止连线穿插的产生。

总结

本文咱们从流程设计器的需要,到设计器的数据接口定义,最终实现了一个满足根本要求的流程设计器。不过还有很多须要依据不同业务需要具体进行剖析的场景,须要大家在理论业务中去进一步提炼。

开源库

react-flow-renderer:
https://github.com/prahladina...

公众号:全象云低代码
GitHub:https://github.com/quanxiang-...