如何编写简单好用的流程图?

写于19年底,历时1个半月。程序主要用于绘制审批的各个节点流程。先看看效果(开源在社区的精简版):

flow-design-center.jpg

为什么要写这个?S

公司内部为国内 TOP3 建筑集团分公司写内部一体化平台,其中许多业务涉及到需要定制审批流程。最终经过 PM 的一番调研,它们觉得钉钉后台的审批流程最简单好用,于是去看了看。用 React + antd 技术栈实现,而我们团队使用的是 Vue + element-ui 和基于 VueMfe 的微前端架构。

而这个审批系统,就是 wf, 即 work flow system。

它在整个平台应用中占了很大部分权重。除了所有服务都依赖的基础服务像 auth(权限)、mfe(微前端)之外,所有的重要业务几乎都要接入审批流程应用,用于上级批复。

所以办公场景中,审批,真的太多了吧。比如入职、请假、发工资、出差、报销、申请等等~~

多到泪目,审批起来不累吗?领导们,走流程好累。但是也必须得走,确实很累。

找了那些参考?T

这个说来惭愧,当时没找什么参考(害怕~~)。

看了钉钉后台的实现,觉得简单又方便。why?因为只用 div + css 绘制流程图,没有 canvas,也没有 贝塞尔曲线,不需要很复杂的背景知识。懂点数据结构和撸得出来初中级算法,就可以了。

有考虑过痛过 canvas 来实现,但是看了一些实现,觉得体验堪忧。可能是并没有找到很好的参考产品。记得当时产品找到的同类参考产品还有简道云的审批流,但是拖来拖去的并不如直接添加来得好用。

最终 TL 决定实现钉钉这种交互的审批流。

要怎么去做?A

根据产品和业务的需求,系统内部存在的几个节点。基本需求跟钉钉的基本一致,发起人、审批人、并行分支、条件分支、抄送人,不过节点的配置结合自身业务做了修改。

怎么处理数据?

最有意思的是这些节点的关系在UI上的直观体现是连线,节点与节点之间的连线。但是在代码中要用什么数据结构来表达这种关系?怎么渲染?用那种数据结构最方便?工作量最小?

如果没有分支节点,单纯的只有子节点,那么就是一个很简单的树形结构,递归渲染即可。但因为存在分支节点,而分支节点在同级中可以被不停的添加。且所有的节点,最终都会有指向一个结束节点。

事情变得复杂了。得出的结论是用树形数据结构可以渲染,但不能绘制点与点之间的结束关系。举个例子:

const tree = {
  key: 1,
  children: [{
    key: 2.1,
    children: []
  }, {
    key: 2.2,
    children: [{
      key: 3.1,
      children: []
    }]
  }]
}

根据上述数据,能画出如下结构:

但是审批流应用,最终都有一个兜底的流程结束节点(EndNode),像这样:

这个用什么实现呢?这不是 Tree 了,这明明是 Graph。下一个问题来了?Graph 要如何渲染呢?我懵了。

Tree 的数据结构很简单,TreeItem.Children: TreeItem[] 就是叶子节点。但是 Graph 很明显还不够,Graph 需要知道 prevItem 和 nextItem。而实现 graph 的方式有两种,邻接矩阵和邻接表。

在 wf 中使用邻接列表实现 graph,同时使用 LinkedList 关联以实现 graph 中的 edge(vertex1,vertex2) 关系。

怎么渲染 view?

节点类型

节点存在两种,普通节点分支节点。在 wf 中,普通节点就是开始、发起人、审批人、抄送人、结束节点,而分支节点就是条件分支和并行分支节点。

普通节点

普通节点的渲染非常简单,FlowNode = Node(Title+Content)+PlusBtn+Line。

分支节点

分支节点的渲染则分为了 FlowBranchNode = BranchStartNode + (FlowBranchCol * N>FlowNode) + BranchEndNode。

渲染节点

首先实现了 Flow 的两个工厂方法,createNoderegisterNode 用于新建节点和注册节点。而 createNode 返回新的 Node 实例。Node 实现了 add, remove, move, updateProps, updateModel 等方法。分别用于追加节点、删除节点、移动节点、更新节点属性和model。

现在回想起来,这里的 Node 节点应该使用继承来设计,提供 Node 作为基类并定义上述接口,不同的节点类型再定义自身方法的实现。从而遵从 S.O.L.I.D 设计原则。

BaseNode 基类,伪代码实现 :

export default class Node {
  constructor(opts) {
    Object.keys(opts).forEach((key) => (this[key] = opts) })
  }

  // 新增
  create() {}
  // 删除
  remove() {}
  // 更新
  update(nodeOptions) {}
  // 追加节点
  append(nextNode) {}
  // 移动节点
  move() {}
  // 上一个节点
  prev() {}
  // 下一个节点
  next() {}
  // 返回末尾节点(如果是分支节点返回分支结束节点,如果是普通节点返回自身)
  end() {}
  // 绘制节点
  render() {}
}

StartNode 发起人节点的伪代码实现:

import BaseNode from './BaseNode'
import NodeManager from '../helpers/NodeManager'
import { geneNodeId, isBranchChildNode } from '../helpers/NodeUtils'
import { FLOW_NODE_TYPE } from '../constants/FLOW_NODE_TYPE'
import { FLOW_NODE_MODEL } from '../constants/FLOW_NODE_MODEL'
import StartNode from '../components/StartNode'

export default class StartNode extends BaseNode {
  // 模拟单例
	static _startNode = null

  constructor(baseOpts = {}) {
    if (StartNode._startNode) {
      throw new Error(`The 'SponsorNode.created()' only could be called once.`)
    } else {
      super(baseOpts)

      this.nodeId = geneNodeId()
      this.type = FLOW_NODE_TYPE.START

      StartNode._startNode = this
      NodeManager.addNode(this)
    }
  }

  remove() {
    StartNode._startNode = null
    NodeManager.removeNode(this)

    return null
  }

  update(opts = {}) {
    Object.keys(opts).forEach((key) => {
      this[key] = opts[key]
    })

    return this
  }

  // 之前已存在节点
  // Old LinkedList: S -> node -> E
  // New LinkedList: S -> nextNode -> node -> E, S -> node -> nextNode -> E
  append(nextNode) {
    if (nextNode && nextNode instanceof BaseNode) {
      // 分支节点的子节点是个数组
      if (Array.isArray(nextNode)) {
        this.childrenNodes = nextNode.map((node) =>
          node.update({ prevId: this.nodeId })
        )
      } else {
        if (isBranchChildNode(nextNode)) {
          this.childrenNodes.push(nextNode)
        } else {
          if (this.childNode) {
            let oldChildNode = this.childNode

            this.childNode = nextNode
            nextNode.childNode = oldChildNode
            // this.childNode.append(oldChildNode)
          } else {
            this.childNode = nextNode
          }

          node.update({ prevId: this.nodeId })
        }
      }
      
      return nextNode
    }
  }

  prev() {
    return null
  }

  next() {
    return this.childNode
  }
  
  render() {
    const viewModel = FLOW_NODE_MODEL[this.type]

    return <StartNode {...viewModel} />
  }
}

ApproverNode 审批人节点,

NotifierNode 抄送人节点,

EndNode 结束节点,

ConditionBranchStartNode 条件开始节点,

ConditionBranchNode 条件节点,

ConditionBranchEndNode 条件结束节点,

ParallelBranchStartNode 并行开始节点,

ParallelBranchEndNode 并行结束节点

NodeTypeEnum:

enum NodeTypeEnum = 'start' | 'approver' | 'cbs' | 'condition' | 'cbe' | 'pbs' | 'parallel' | 'pbe' | 'notifier' | 'end'

NodeOptions:

intereface NodeOptions = {
  type: NodeTypeEnum,
  childNode: Node | null,
  childrenNodes: Node[] | null,
  prevNodeId: Node | null,
}
普通节点
  1. 手动调用
const startNode = new StartNode({ type: 'start' })
const approverNode = new ApproverNode({ type: 'approver' })
const endNode = new EndNode({ type: 'end' })

startNode.append(approverNode).append(endNode)
  1. 工厂方法
const startNode = Flow.createNode(Node.TYPE.START)
const approverNode = Flow.createNode(Node.TYPE.APPROVAER)
const endNode = Flow.createNode(Node.TYPE.END)

startNode.append(approverNode).append(endNode)
分支节点
  1. 条件分支
/**
 * createConditionNode
 * @param {{type: string, prevId: string, formFieldList: []}} [opts]
 */
const createConditionNode = (opts) => {
  return Flow.createNode({
    type: Node.TYPE.CONDITION_NODE,
    conditionType: GENERAL_CONDITION,
    ...opts,
  })
}

/* ConditionNode = ConditionBranchStartNode -> [ConditionBranchNode, ConditionBranchNode] -> ConditionBranchEndNode */
const conditionBranchNode = Flow.createNode(Node.TYPE.CONDITION_BRANCH)
const conditionChildrenNodes = [
  	// 第一个条件为用户设置的条件
    createConditionNode(opts),
  	// 默认第二个条件为"其他条件"
    createConditionNode({
      ...opts,
      conditionType: OTHER_CONDITION,
    }),
  ]
const conditionBranchEndNode = Flow.createNode(Node.TYPE.CONDITION_BRANCH_END)

conditionBranchNode.append(conditionChildrenNodes)
conditionChildrenNodes.forEach(node => node.append(conditionBranchEndNode ))


// 分支条件提供 end() 方法直接定位到分支结束节点
/* StartNode -> ApprovaerNode -> ConditionNode -> EndNode */
startNode.append(approverNode).append(conditionBranchNode).end().append(endNode)
  1. 并行分支
/**
 * createParallelNode
 * @param {{type: string, prevId: string, formFieldList: []}} [opts]
 */
const createParallelNode = (opts) => {
  return Flow.createNode({
    // 并行分支节点是审批节点
    type: Node.TYPE.APPROVER_NODE,
    ...opts,
  })
}

/* ParallelBranch = ParallelBranchStartNode -> [Parallel1, Parallel2] -> ParallelBranchEndNode */
const parallelBranchNode = Flow.createNode(Node.TYPE.PARALLEL_BRANCH)
const parallelChildrenNodes = [
  createParallelNode(opts),
  createParallelNode(opts),
]
const parallelBranchEndNode = Flow.createNode(Node.TYPE.PARALLEL_BRANCH_END)

parallelBranchNode.append(parallelChildrenNodes)
parallelChildrenNodes.forEach(node => node.append(parallelBranchEndNode ))

startNode.append(parallelBranchNode).end().append(endNode)

最终数据

去看代码吧 github 传送门。

渲染数据

去看代码吧 github 传送门。

怎么转换数据?

当时对接的后端同事对数据的要求是,需要返回一个一维数组。数组 item 的数据结构是:

{
  type: node.conditionType, // Node Type Enum
  sourceTaskId: sourceNode.nodeId, // source node
  targetTaskId: targetNode.nodeId, // target node
  conditionList: [] // 如果是条件节点,则 conditionList 为用户设置的触发条件规则
}

因为后端 sever 使用 activit,其接受的 JSON 数据需要一维数组记录各个线段,其数据结构是 SeverModel: { sourceNodeId: Node.nodeId, targetNodeId: Node.nodeId }[],因此编写了 FlowGraph 通过其实例的 graph.addEdge(s: graphVertex, e: graphVertex) 来记录和生成各个节点的线段,并最终转成一维数组。

因此数据转换复杂度会有一些高,

adapter/toJSON:

ViewModel(类linkedList) -> NodeModel - flowGraph(edges) -> ServerModel([edge])

adapter/toModel:

ServerModel([edge]) -> flowGraph(edge) - NodeModel -> ViewModel

具体的转换步骤不想写了。

talk is cheap, show you my code.

github 传送门。

谢谢阅读。

posted @ 2021-03-01 13:06  月光宝盒造梦师  阅读(728)  评论(6编辑  收藏  举报