用flex布局实现一个流程设计器

6,900 阅读9分钟

最近接到一个需求,要做一个流程设计的功能,大概长下面这个样子:

支持添加、编辑和删除节点,节点只有四种类型:开始节点、普通节点、分支节点、结束节点。

因为每个节点只有一个进和一个出,且节点不需要支持拖拽,连线也是自动连接,所以整体比较简单,不用开源库,自己做的成本也不会很高。

初看其实比较麻烦的只有布局和连线,布局因为节点不需要支持拖拽,所以位置都是自动且固定的,更精确点说其实就是垂直居中,说到居中,你可能会想到flex布局,那么这里能不能使用flex布局呢,显然是可以的,另外连线通常可能会使用svg,但是其实直接使用div和伪元素也完全可以实现。

接下来我们就从零来实现一下,因为我们的项目原因,所以还是会基于Vue2版本来实现。

数据结构

整体数据是一个数组,数组的每一项代表一个节点。

开始节点

{ id: 'startEvent', type: 'start', title: '开始' }

id除了开始和结束节点外,其他节点的id随机生成即可,type代表节点的类型,title为节点的标题。

结束节点

{ id: 'endEvent', type: 'end', title: '结束' }

普通节点

{
    id: '随机id',
    type: 'normal',
    title: '审批人',
    content: '主管',// 节点内容
    configData: {},// 节点配置数据
    nodeList: []// 后续节点
}

默认titlecontent的内容会在节点上显示,而针对每个节点的配置数据保存在configData上,一般情况下,顶层节点会直接作为数组的一项,而当处于条件分支中时,则需要把后续节点保存在nodeList上。

分支节点

{
    id: '随机id',
    type: 'condition',
    title: '条件分支',
    children: [// 分支
        // 普通节点
    ]
}

分支节点的分支保存在children属性上,每个分支节点其实就是一个普通节点,普通节点里又可以嵌套分支节点。

布局

入口组件

首先创建一个入口组件:

<template>
  <div class="sfcContainer">
    <div class="sfcContent">
      <Node v-for="node in data" :key="node.id" :data="node"></Node>
    </div>
  </div>
</template>

<script>
export default {
  name: 'SimpleFlowChart',
  props: {
    data: {
      type: Array,
      default() {
        return []
      }
    }
  }
}
</script>

<style lang="less" scoped>
.sfcContainer {
  width: 100%;
  height: 100%;
  overflow: auto;// 超出显示滚动条
  box-sizing: border-box;
  background: rgba(0, 0, 0, 0.03);

  * {
    box-sizing: border-box;
  }

  .sfcContent {
    // 设置垂直居中
    display: flex;
    align-items: center;
    padding: 20px;
    // 最小宽高设为容器宽高,否则无法居中
    min-width: 100%;
    min-height: 100%;
    // 否则宽高以实际的内容为准
    width: max-content;
    height: max-content;
  }
}
</style>

流程数据通过props传入,循环数据渲染 Node组件,Node组件为所有节点组件的容器。

css中给sfcContent元素设置的display: flex;align-items: center;很关键,就是这两行样式,使得所有顶层节点可以水平排列并垂直居中。

基础组件Node

这个组件作为所有节点组件的容器,只要根据类型渲染不同节点组件即可:

<template>
  <div class="sfcNodeContainer">
    <!-- 开始节点 -->
    <StartNode v-if="data.type === 'start'" :data="data"></StartNode>
    <!-- 结束节点 -->
    <EndNode v-else-if="data.type === 'end'" :data="data"></EndNode>
    <!-- 分支节点 -->
    <ConditionNode v-else-if="data.type === 'condition'" :data="data"></ConditionNode>
    <!-- 普通节点 -->
    <NormalNode v-else :data="data"></NormalNode>
  </div>
</template>

<script>
export default {
  name: 'Node',
  props: {
    data: {
      type: Object,
      default: null
    }
  }
}
</script>

开始节点StartNode、结束节点组件EndNode

开始节点和结束节点差不多,除了样式稍微有点差别外,就是开始节点有根指向下一个节点的箭头线。

开始节点组件:

<template>
  <div class="sfcStartNodeContainer">
    <div class="sfcStartNodeContent">{{ data.title }}</div>
  </div>
</template>

<script>
export default {
  name: 'StartNode',
  props: {
    data: {
      type: Object,
      default: null
    }
  }
}
</script>

箭头线下一个小节再看,节点的基础样式因为不影响布局所以也没贴出来。

结束节点组件:

<template>
  <div class="sfcEndNodeContainer">{{ data.title }}</div>
</template>

<script>
export default {
  name: 'EndNode',
  props: {
    data: {
      type: Object,
      default: null
    }
  }
}
</script>

<style lang="less" scoped>
.sfcEndNodeContainer {
  // 省略不影响布局的节点样式
}
</style>

普通节点组件NormalNode

<template>
  <div class="sfcNormalNodeContainer">
    <!--节点内容-->
    <div class="sfcNormalNodeWrap">
      <div class="sfcNormalNodeContent">
        <div class="sfcNormalNodeTitle">
          {{ data.title || '' }}
        </div>
        <!--省略-->
      </div>
    </div>
    <!--递归渲染后续Node组件-->
    <Node v-for="node in (data.nodeList || [])" :key="node.id" :data="node"></Node>
  </div>
</template>

<script>
export default {
  name: 'NormalNode',
  props: {
    data: {
      type: Object,
      default: null
    }
  }
}
</script>

<style lang="less" scoped>
.sfcNormalNodeContainer {
  position: relative;
  // 使当前节点的内容和后续节点水平排列,并且垂直居中
  display: flex;
  align-items: center;
  flex-shrink: 0;

  // 省略节点基础样式
}
</style>

sfcNormalNodeWrap元素渲染节点自身的内容,如果当前节点的nodeList中有后续节点,那么遍历递归渲染Node节点。

通过在容器上设置display: flex样式,让节点自身内容和后续其他节点水平排列显示,再通过align-items: center样式让它们垂直居中对齐。

分支节点组件ConditionNode

<template>
  <div class="sfcConditionNodeContainer">
    <div class="sfcConditionNodeItemList">
      <div
        class="sfcConditionNodeItem"
        v-for="node in data.children"
        :key="node.id"
      >
        <div class="sfcConditionNodeItemNodeWrap">
          <Node :data="node"></Node>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'ConditionNode',
  props: {
    data: {
      type: Object,
      default: null
    }
  }
}
</script>

分支节点自身其实没有实际内容,只是作为一个容器来渲染childen中的具体分支节点,分支节点其实就是普通节点,所以遍历渲染Node组件传入数据即可。

到目前为止所有节点组件就已经创建完毕了,传入数据看一下现在的效果:

可以看到大体上已经成型了,只要连上线就大功告成了。

连线

箭头组件

箭头线的样式其实是一样的,所以我们创建一个箭头线的组件ArrowLine

<template>
  <div class="sfcArrowLine"></div>
</template>

<script>
export default {
  name: 'ArrowLine'
}
</script>

<style lang="less" scoped>
.sfcArrowLine {
  position: relative;
  width: 65px;
  user-select: none;

  &:before {
    position: absolute
    top: 0;
    left: 0;
    transform: translateY(-50%);
    height: 2px;
    width: 100%;
    background-color: #dedede;
    content: '';
  }

  &:after {
    position: absolute;
    width: 0;
    height: 0;
    border-left: 10px solid #dedede;
    border-top: 6px solid transparent;
    border-bottom: 6px solid transparent;
    content: '';
    right: 0;
    top: 0;
    transform: translateY(-50%);
  }
}
</style>

线使用before元素绘制,箭头三角形使用after元素绘制。

开始节点添加箭头

首先在开始节点中引入箭头组件:

<template>
  <div class="sfcStartNodeContainer">
    <div class="sfcStartNodeContent">{{ data.title }}</div>
    <ArrowLine></ArrowLine>
  </div>
</template>

效果如下:

箭头应该在右边,很简单,flex大法:

<style lang="less" scoped>
.sfcStartNodeContainer {
  display: flex;
  align-items: center;
}
</style>

普通节点添加箭头

同样先引入箭头组件:

<template>
  <div class="sfcNormalNodeContainer">
    <!--节点内容-->
    <div class="sfcNormalNodeWrap">
      <div class="sfcNormalNodeContent">
        <div class="sfcNormalNodeTitle">
          {{ data.title || '' }}
        </div>
        <!--省略-->
      </div>
      <!--箭头组件放在这里-->
      <ArrowLine></ArrowLine>
    </div>
    <!--递归渲染后续Node组件-->
    <Node v-for="node in (data.nodeList || [])" :key="node.id" :data="node"></Node>
  </div>
</template>

同样需要设置成flex布局:

<style lang="less" scoped>
    .sfcNormalNodeWrap {
        display: flex;
        align-items: center;
    }
</style>

分支节点添加箭头

<template>
  <div class="sfcConditionNodeContainer">
    <div class="sfcConditionNodeItemList">
      <div
        class="sfcConditionNodeItem"
        v-for="node in data.children"
        :key="node.id"
      >
        <div class="sfcConditionNodeItemNodeWrap">
          <Node :data="node"></Node>
        </div>
      </div>
    </div>
    <ArrowLine></ArrowLine>
  </div>
</template>

和分支节点列表并列,同样少不了flex样式:

<style lang="less" scoped>
.sfcConditionNodeContainer {
  display: flex;
  align-items: center;
}
</style>

到这里整体的效果如下:

离胜利只有一步之遥了。

完善分支节点的连线

首先给分支节点加个间距,现在都挨着一起:

<template>
  <div class="sfcConditionNodeContainer">
    <div class="sfcConditionNodeItemList">
      <div
        class="sfcConditionNodeItem"
        v-for="node in data.children"
        :key="node.id"
      >
        <div class="sfcConditionNodeItemNodeWrap">
          <Node :data="node"></Node>
        </div>
      </div>
    </div>
  </div>
</template>

<style lang="less" scoped>
.sfcConditionNodeItem {
  padding: 30px;
}
</style>

连接分支整体的竖线

需要添加如下的竖线:

仔细观察可以发现其实就是给分支节点的前后各添加一竖线,其中的间距其实是因为前面我们给分支节点的每个节点都设置了一个30pxpadding,但是其实尾部的间距是不需要的:

所以我们修改一下,把右内边距设为0

<style lang="less" scoped>
.sfcConditionNodeItem {
  padding: 30px;
  padding-right: 0;
}
</style>

你可能会想直接在分支节点的容器元素sfcConditionNodeContainer上直接前后绘制两条线,但是问题是这根线不是100%和容器元素一样高的,而是延伸到最外侧两个分支的高度的一半,通过纯css其实很难绘制出来,所以我们可以换种方法,让每个分支自己来绘制,这样其实就把一根线分成几段:

具体来说,就是最外侧的两个分支画一根一半高度的线,中间的分支画一根和高度一样的线。

要添加的线比较多,伪元素不够用,所以我们通过div元素来作为连线,然后通过绝对定位来显示。

<template>
  <div class="sfcConditionNodeContainer">
    <div class="sfcConditionNodeItemList">
      <div
        class="sfcConditionNodeItem"
        v-for="node in data.children"
        :key="node.id"
      >
        <!-- 左侧的竖线 -->
        <div
          class="sfcConditionNodeItemLine sfcConditionNodeItemFirstLine"
        ></div>
        <!-- 右侧的竖线 -->
        <div
          class="sfcConditionNodeItemLine sfcConditionNodeItemLastLine"
        ></div>
        <div class="sfcConditionNodeItemNodeWrap">
          <Node :data="node"></Node>
        </div>
      </div>
    </div>
  </div>
</template>

<style lang="less" scoped>
    .sfcConditionNodeItem {
        position: relative;// 设置相对定位

        // 前后竖线
        .sfcConditionNodeItemLine {
            position: absolute;
            height: 100%;// 默认为中间分支的的竖线,高度100%
            width: 2px;
            left: 0px;
            top: 0;
            background-color: #dedede;

            // 右侧竖线距离左侧为100%
            &.sfcConditionNodeItemLastLine {
                left: 100%;
            }
        }

        // 最外侧的两个分支的竖线高度为50%
        &:first-of-type {
            // 最顶部的分支的竖线距顶部50%
            > .sfcConditionNodeItemLine {
                top: 50%;
                height: 50%;
            }
        }
        &:last-of-type {
            // 最底部的分支的竖线距顶部0
            > .sfcConditionNodeItemLine {
                top: 0;
                height: 50%;
            }
        }
    }
</style>

效果如下:

连接分支整体和分支的水平线

画完了竖线,接下来是水平线,如下所示,我们要连接分支左侧竖线和分支节点:

这根线的宽度其实就是padding的大小,然后left0top50%,同样使用div来绘制:

<template>
  <div class="sfcConditionNodeContainer">
    <div class="sfcConditionNodeItemList">
      <div
        class="sfcConditionNodeItem"
        v-for="node in data.children"
        :key="node.id"
      >
        <div
          class="sfcConditionNodeItemLine sfcConditionNodeItemFirstLine"
        ></div>
        <div
          class="sfcConditionNodeItemLine sfcConditionNodeItemLastLine"
        ></div>
        <!-- 连接竖线和节点的水平线 -->
        <div class="sfcConditionNodeItemLinkLine"></div>
        <div class="sfcConditionNodeItemNodeWrap">
          <Node :data="node"></Node>
        </div>
      </div>
    </div>
  </div>
</template>

<style lang="less" scoped>
    // 连接竖线和节点的水平线
    .sfcConditionNodeItemLinkLine {
        position: absolute;
        width: 30px;
        height: 2px;
        left: 0px;
        top: 50%;
        transform: translateY(-50%);// 让线段真正居中
        background-color: #dedede;
    }
</style>

连接较短分支和分支整体右侧的水平线

最后还剩下如下图所示的较短分支和分支整体右侧的水平线:

这个也很简单,在每个分支的节点后面添加一个div作为连线,和分支节点作为兄弟节点,父级设置flex布局,连线宽度自适应即可:

<template>
  <div class="sfcConditionNodeContainer">
    <div class="sfcConditionNodeItemList">
      <div
        class="sfcConditionNodeItem"
        v-for="node in data.children"
        :key="node.id"
      >
        <div
          class="sfcConditionNodeItemLine sfcConditionNodeItemFirstLine"
        ></div>
        <div
          class="sfcConditionNodeItemLine sfcConditionNodeItemLastLine"
        ></div>
        <div class="sfcConditionNodeItemLinkLine"></div>
        <div class="sfcConditionNodeItemNodeWrap">
          <Node :data="node"></Node>
          <!-- 连接较短分支和分支整体右侧的水平线 -->
          <div class="sfcConditionNodeItemLinkCrossLine"></div>
        </div>
      </div>
    </div>
  </div>
</template>

<style lang="less" scoped>
    .sfcConditionNodeItemNodeWrap {
        // 父级设置flex布局,让连线和节点整体垂直居中
        display: flex;
        align-items: center;

        // 连接较短分支和分支整体右侧的水平线
        .sfcConditionNodeItemLinkCrossLine {
            height: 2px;
            flex-grow: 1;// 连线宽度自适应,填充剩余空间
            background-color: #dedede;
        }
    }
</style>

到这里,节点布局以及连线都已完成,最终效果如下:

是不是很简单。

新增、编辑、删除节点

新增节点

新增节点首先需要在每一个节点后面的连接线上添加一个按钮,点击按钮后选择要添加的节点的类型,然后进行添加。

除了分支节点外,只能添加普通节点,但是对于流程设计的业务来说,可以细分为很多类型,比如审批人、抄送人、发送短信等等,这个不同的业务可能不一样,所以肯定不能写死,需要开放出来可自定义。

首先创建一个添加节点的按钮组件:

<template>
  <div class="sfcAddNode">
    <div class="sfcAddNodeBtn">
      <!-- 省略 -->
    </div>
  </div>
</template>

<script>
export default {
  name: 'AddNode'
}
</script>

<style lang="less" scoped>
.sfcAddNode {
  position: absolute;
  right: 0;
  top: 0;
  width: 65px;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;

  .sfcAddNodeBtn {
    // 忽略按钮样式
  }
}
</style>

按钮组件绝对定位,宽度和箭头线宽度一致,为65px,高度100%,和节点一致,相当于覆盖在箭头线上,然后通过flex布局让真正的按钮居中即可。

先给普通节点加上:

<template>
  <div class="sfcNormalNodeContainer">
    <div class="sfcNormalNodeWrap">
      <div class="sfcNormalNodeContent">
          <!-- 省略 -->
      </div>
      <ArrowLine></ArrowLine>
      <!-- 添加节点组件 -->
      <AddNode></AddNode>
    </div>
    <Node v-for="node in (data.nodeList || [])" :key="node.id" :data="node"></Node>
  </div>
</template>

<style lang="less" scoped>
    .sfcNormalNodeWrap {
        position: relative;// ++
    }
</style>

接下来给每个分支也加上:

<template>
  <div class="sfcConditionNodeContainer">
    <div class="sfcConditionNodeItemList">
      <!-- 省略 -->
    </div>
    <ArrowLine></ArrowLine>
    <AddNode></AddNode>
  </div>
</template>

按钮默认都显示可能不太好看,可以隐藏起来,鼠标滑入按钮组件区域再显示。

然后当鼠标移入按钮时显示可添加的节点类型,点击要添加的节点类型后进行添加。添加一个节点其实就是往数组里插入一项,但不同的节点对应的数组是不一样的,如下图所示:

顶层节点添加下一个节点需要把节点插入顶层数组,分支里的节点插入下一个节点需要插入到自己的nodeList数组里,所以实现时需要区分一下。

当然分支也是可以添加条件的:

点击后往分支节点的children数组里添加一项即可。

效果如下:

点击添加节点和条件_20221229094533.gif

编辑

编辑主要是当点击某个节点以后可以修改节点标题,节点配置,节点显示的内容一般也是来自节点的配置。

所以对于库来说只要抛出一个点击事件即可,具体的编辑界面用户可根据业务自行开发。

点击节点显示侧边栏_20221229094520.gif

删除

当鼠标悬浮到节点内容上显示一个删除按钮,点击后删除掉当前节点即可,对于条件分支来说,如果删除到仅剩一个分支,那么这个条件分支也就没有了意义,直接整个条件分支自动删除。

删除节点_20221229094505.gif

自定义节点内容

因为组件树层级比较深,所以通过slot自定义节点内容不是很方便,所以我选择了一个比较low的方式,即将节点内容单独抽成一个组件,然后在注册组件的时候提供选项配置,那么如果想自定义节点内容,很简单,不要使用内置的节点内容组件,自行编写并注册一个即可,使用约定的组件名称就可以了。

const install = function (Vue, { notRegisterNodeContent } = {}) {
  Vue.component(ConditionNode.name, ConditionNode);
  Vue.component(EndNode.name, EndNode);
  Vue.component(Node.name, Node);
  Vue.component(NormalNode.name, NormalNode);
  Vue.component(StartNode.name, StartNode);
  Vue.component(Index.name, Index);
  // 需要自定义节点内容时通过选项参数指定不要注册内置节点内容组件即可
  if (!notRegisterNodeContent) {
    Vue.component(NodeContent.name, NodeContent);
  }
};

export default {
  install
};

然后自己编写一个内容节点并注册:

Vue.component(CustomNodeContent.name, CustomNodeContent)
Vue.use(SimpleFlowChart, {
  notRegisterNodeContent: true
})

同样添加节点悬浮面板也可以通过这种方式自定义。

垂直排列

支持垂直排列也很简单,基本上只要在所有设置了display:flex的地方加上flex-direction: column;,然后再把连线由竖的改成水平的,位置调一下就可以了:

最后

本文详细的介绍了一下如何使用flex布局实现一个简单的流程设计器,demo及完整的源码如下:

demo:wanglin2.github.io/simple-flow…

源码:github.com/wanglin2/si…