目录
引言
Vue Flow 是一个基于 Vue 的流程图库,结合 Vue.js 的组件化优势,可用于创建交互式、可视化的流程图。本文将逐步引导你整合 Vue Flow,涵盖从环境配置到自定义节点、拖拽、连线、删除、JSON 管理和优化,适合初学者和高级开发者。
目的
本文旨在提供一个全面的指南
,帮助开发者:
- 理解 Vue Flow 的核心功能和集成流程。
- 实现交互式流程图,包括拖拽、连线和节点管理。
- 掌握数据持久化(保存和导入 JSON),提升项目实用性。
- 优化性能并解决常见问题,确保生产环境稳定。
适用场景
- 工作流管理:设计审批或任务流程。
- 数据可视化:展示组织结构或网络拓扑。
- 工业自动化:模拟设备连接和生产流程。
- 教育工具:可视化算法或逻辑步骤。
- 说明插图:绘制一个工业流程图,包含 “设备1”, “工序1”, “设备2” 节点和连接线。
环境准备
-
项目初始化:使用 Vue 3 + Vite 创建项目。
-
依赖安装:
npm install @vue-flow/core @vue-flow/background @vue-flow/controls @vue-flow/minimap ant-design-vue
@vue-flow/core
:核心库。
@vue-flow/background
:背景网格。
@vue-flow/controls
:交互控制。
@vue-flow/minimap
:画布概览。
ant-design-vue
:用于按钮样式。
基础组件 (index.vue)
创建 src/components/FlowChart.vue 作为流程图容器。
<template>
<div class="flow-container">
<div class="layout">
<!-- 左侧可选项区域 -->
<div class="sidebar">
<h3>可拖拽节点</h3>
<div
v-for="option in nodeOptions"
:key="option.type"
class="draggable-node"
:data-type="option.type"
draggable="true"
@dragstart="onDragStart"
>
<div class="node-label">{{ option.label }}</div>
<div class="node-preview-wrapper">
<div :class="['inner-shape', option.shapeClass]">
<div class="label">{{ option.label }}</div>
</div>
</div>
</div>
</div>
<!-- 右侧 Vue Flow 画布 -->
<div class="flow-area">
<div class="toolbar">
<a-button type="primary" @click="addRandomNode">添加随机节点</a-button>
<!-- <a-button type="danger" @click="deleteSelected">删除选中节点</a-button>-->
<a-button type="default" @click="exportJson">导出 JSON</a-button>
<a-button type="default" @click="saveExportJson">保存流程图</a-button>
</div>
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:node-types="nodeTypes"
:default-edge-options="{
type: 'smoothstep',
animated: true,
markerEnd: { type: 'arrowclosed', color: '#ff0000' },
style: { stroke: '#ff0000', strokeWidth: 2, strokeDasharray: 'none' },
}"
:connection-line-style="{ stroke: '#ff0000', strokeWidth: 2, strokeDasharray: 'none' }"
:fit-view-on-init="true"
:connectable="true"
@drop="onDrop"
@dragover.prevent
@connect="onConnect"
@node-click="onNodeClick"
@node-drag-start="onNodeDragStart"
@node-drag-stop="onNodeDragStop"
@pane-click="onPaneClick"
>
<Background variant="dots" :gap="20" />
<Controls />
<MiniMap />
</VueFlow>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref, markRaw, onMounted} from 'vue'
import { VueFlow, useVueFlow } from '@vue-flow/core'
import type { NodeTypesObject } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
import { message } from 'ant-design-vue'
import DiamondNode from './DiamondNode.vue'
import CircleNode from './CircleNode.vue'
import ImageNode from './ImageNode.vue'
import DeviceNode from './DeviceNode.vue'
import UserNode from './UserNode.vue'
import RoundedRectangleNode from './RoundedRectangleNode.vue'
import { editingNodeId } from './store'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '../style/node-styles.css'
const nodes = ref([])
const edges = ref<any>([])
const nodeTypes: NodeTypesObject = {
diamond: markRaw(DiamondNode),
circle: markRaw(CircleNode),
image: markRaw(ImageNode),
user: markRaw(UserNode),
device: markRaw(DeviceNode),
roundedRectangle: markRaw(RoundedRectangleNode),
}
const { addNodes, getNodes, deleteElements,removeNodes } = useVueFlow()
const nodeOptions = ref([
{ type: 'circle', label: '开始节点', shapeClass: 'node-circle' },
{ type: 'circle', label: '结束节点', shapeClass: 'node-circle' },
// { type: 'image', label: '图片节点', shapeClass: 'node-image' }, // 新增图片节点
// { type: 'device', label: '设备节点', shapeClass: 'node-device' }, // 新增设备节点
// { type: 'user', label: '人员节点', shapeClass: 'node-user' }, // 新增人员节点
// { type: 'roundedRectangle', label: '圆角长方形节点', shapeClass: 'node-rounded-rectangle' },
])
function initData(){
return [
{ type: 'device', label: '设备节点', shapeClass: 'node-device',id:1,code:'123' },
{ type: 'device', label: '工序1', shapeClass: 'node-device',id:1,code:'123' },
{ type: 'device', label: '工序2', shapeClass: 'node-device',id:1,code:'123' },
{ type: 'device', label: '工序3', shapeClass: 'node-device',id:1,code:'123' },
]
}
function getData(){
nodes.value = [{"":""}];
edges.value = [{"":""}];
onMounted(() => {
let data =initData();
nodeOptions.value = [...nodeOptions.value, ...data];
getData();
})
// Drag-and-drop handlers
function onDragStart(event: DragEvent) {
if (event.dataTransfer && event.target instanceof HTMLElement) {
const type = event.target.dataset.type
if (type) {
event.dataTransfer.setData('application/vueflow-node-type', type)
}
}
}
function onDrop(event: DragEvent) {
const type = event.dataTransfer?.getData('application/vueflow-node-type')
if (!type) return
const bounds = (event.currentTarget as HTMLElement).getBoundingClientRect()
const position = {
x: event.clientX,
y: event.clientY,
}
const label = type === 'diamond' ? '判断节点' : type === 'circle' ? '开始节点' : '圆角长方形节点'
addNodes([
{
id: `${type}-${Date.now()}`,
type,
position,
data: { label },
},
])
}
// Connection handler
function onConnect(params: any) {
edges.value = [
...edges.value,
{
...params,
id: `edge-${params.source}-${params.target}`,
type: 'smoothstep',
animated: true,
markerEnd: { type: 'arrowclosed', color: '#ff0000' },
style: { stroke: '#ff0000', strokeWidth: 2, strokeDasharray: 'none' },
},
]
}
// Node interaction handlers
function onNodeClick(event: any, node: any) {
nodes.value = nodes.value.map(n => ({
...n,
selected: n.id === node.id ? true : false,
}))
// if (node.type === 'circle') {
// editingNodeId.value = node.id // 仅对圆形节点启用编辑模式
// }
}
function onNodeDragStart(event: any, node: any) {
console.log('Node drag started:', node)
}
function onNodeDragStop(event: any, node: any) {
console.log('Node drag stopped:', node)
}
function onPaneClick() {
nodes.value = nodes.value.map(n => ({ ...n, selected: false }))
editingNodeId.value = null // 退出编辑模式
}
// Add a random node
function addRandomNode() {
const types = ['diamond', 'circle', 'roundedRectangle','image','user','device']
const type = types[Math.floor(Math.random() * types.length)]
const label = type === 'diamond' ? '判断节点' : type === 'circle' ? '圆形节点' : '圆角长方形节点'
addNodes([
{
id: `${type}-${Date.now()}`,
type,
position: { x: Math.random() * 500, y: Math.random() * 500 },
data: { label },
},
])
}
// Delete selected nodes
function deleteSelected() {
const selectedIds = getNodes.value.filter(n => n.selected).map(n => n.id)
if (selectedIds.length === 0) {
message.warning('请先选中一个节点')
return
}
removeNodes({ selectedIds })
message.success('节点删除成功')
editingNodeId.value = null // 退出编辑模式
}
function saveExportJson(){
//todo 保存到数据库
}
// Export JSON
function exportJson() {
const json = JSON.stringify({ nodes: nodes.value ,edges: edges.value }, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'flowchart.json'
a.click()
window.URL.revokeObjectURL(url)
message.success('JSON 文件已导出')
}
// 更新节点标签
function updateNodeLabel(nodeId: string, newLabel: string) {
nodes.value = nodes.value.map(n =>
n.id === nodeId ? { ...n, data: { ...n.data, label: newLabel } } : n
)
editingNodeId.value = null // 编辑完成后退出编辑模式
}
</script>
说明:基础组件包含侧边栏(拖拽源)、工具栏(操作按钮)和 VueFlow 画布,绑定了节点和边数据。
自定义组件 (矩形、菱形等)
定义不同形状的节点,添加多连接点。
RectangleNode.vue (矩形节点):
<template>
<div class="node rectangle">
<Handle type="target" position="top" id="top-target" />
<Handle type="source" position="top" id="top-source" />
<Handle type="target" position="bottom" id="bottom-target" />
<Handle type="source" position="bottom" id="bottom-source" />
<Handle type="target" position="left" id="left-target" />
<Handle type="source" position="left" id="left-source" />
<Handle type="target" position="right" id="right-target" />
<Handle type="source" position="right" id="right-source" />
<div class="label">{{ data.label }}</div>
</div>
</template>
<script setup>
import { Handle } from '@vue-flow/core'
defineProps({ data: Object })
</script>
<style scoped>
.node {
width: 100px;
height: 60px;
border: 1px solid #333;
display: flex;
align-items: center;
justify-content: center;
}
.label { text-align: center; }
</style>
DiamondNode.vue (菱形节点):
<template>
<div class="diamond-node">
<!-- 顶部连接点:source 和 target -->
<Handle type="target" position="top" class="handle" id="top-target" />
<Handle type="source" position="top" class="handle" id="top-source" />
<!-- 左侧连接点:source 和 target -->
<Handle type="target" position="left" class="handle" id="left-target" />
<Handle type="source" position="left" class="handle" id="left-source" />
<!-- 右侧连接点:source 和 target -->
<Handle type="target" position="right" class="handle" id="right-target" />
<Handle type="source" position="right" class="handle" id="right-source" />
<!-- 底部连接点:source 和 target -->
<Handle type="target" position="bottom" class="handle" id="bottom-target" />
<Handle type="source" position="bottom" class="handle" id="bottom-source" />
<!-- 节点本体 -->
<div class="diamond">
<div class="label">{{ data.label }}</div>
</div>
</div>
</template>
<script setup>
import { Handle } from '@vue-flow/core'
defineProps({
data: Object,
})
</script>
<style scoped>
.diamond-node {
position: relative;
width: 80px;
height: 80px;
overflow: visible;
}
.diamond {
width: 100%;
height: 100%;
background: #2ec4b6;
transform: rotate(45deg);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #333;
color: white;
font-weight: bold;
box-sizing: border-box;
z-index: 1;
}
.label {
transform: rotate(-45deg);
text-align: center;
pointer-events: none;
font-size: 12px;
padding: 4px;
max-width: 90%;
word-break: break-all;
}
.handle {
width: 10px;
height: 10px;
background: #ff0000;
border-radius: 50%;
position: absolute;
z-index: 2;
}
:deep(.vue-flow__handle-top) {
top: -10px; /* 增加偏移量,使点移到顶部外部 */
left: 50%;
transform: translateX(-50%);
}
:deep(.vue-flow__handle-bottom) {
bottom: -10px; /* 增加偏移量,使点移到底部外部 */
left: 50%;
transform: translateX(-50%);
}
:deep(.vue-flow__handle-left) {
left: -10px; /* 增加偏移量,使点移到左侧外部 */
top: 50%;
transform: translateY(-50%);
}
:deep(.vue-flow__handle-right) {
right: -10px; /* 增加偏移量,使点移到右侧外部 */
top: 50%;
transform: translateY(-50%);
}
</style>
说明:每个节点包含上下左右的 target 和 source 连接点,允许多向连接。
ImageNode(自定义图片节点):
<template>
<div class="image-node">
<!-- 顶部连接点:source 和 target -->
<Handle type="target" position="top" class="handle" id="top-target" />
<Handle type="source" position="top" class="handle" id="top-source" />
<!-- 左侧连接点:source 和 target -->
<Handle type="target" position="left" class="handle" id="left-target" />
<Handle type="source" position="left" class="handle" id="left-source" />
<!-- 右侧连接点:source 和 target -->
<Handle type="target" position="right" class="handle" id="right-target" />
<Handle type="source" position="right" class="handle" id="right-source" />
<!-- 底部连接点:source 和 target -->
<Handle type="target" position="bottom" class="handle" id="bottom-target" />
<Handle type="source" position="bottom" class="handle" id="bottom-source" />
<!-- 节点本体 -->
<div class="image-container">
<img :src="imageSrc" alt="Image Node" class="node-image" />
</div>
</div>
</template>
<script setup>
import { Handle } from '@vue-flow/core'
import { computed } from 'vue'
defineProps({
data: Object,
})
const imageSrc = computed(() => {
return new URL('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iODBweCIgaGVpZ2h0PSI4MHB4IiB2aWV3Qm94PSIwIDAgODAgODAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDQ5LjEgKDUxMTQ3KSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5Hcm91cCAyPC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGRlZnM+CiAgICAgICAgPGNpcmNsZSBpZD0icGF0aC0xIiBjeD0iMzYiIGN5PSIzNiIgcj0iMzYiPjwvY2lyY2xlPgogICAgICAgIDxmaWx0ZXIgeD0iLTkuNyUiIHk9Ii02LjklIiB3aWR0aD0iMTE5LjQlIiBoZWlnaHQ9IjExOS40JSIgZmlsdGVyVW5pdHM9Im9iamVjdEJvdW5kaW5nQm94IiBpZD0iZmlsdGVyLTIiPgogICAgICAgICAgICA8ZmVPZmZzZXQgZHg9IjAiIGR5PSIyIiBpbj0iU291cmNlQWxwaGEiIHJlc3VsdD0ic2hhZG93T2Zmc2V0T3V0ZXIxIj48L2ZlT2Zmc2V0PgogICAgICAgICAgICA8ZmVHYXVzc2lhbkJsdXIgc3RkRGV2aWF0aW9uPSIyIiBpbj0ic2hhZG93T2Zmc2V0T3V0ZXIxIiByZXN1bHQ9InNoYWRvd0JsdXJPdXRlcjEiPjwvZmVHYXVzc2lhbkJsdXI+CiAgICAgICAgICAgIDxmZUNvbXBvc2l0ZSBpbj0ic2hhZG93Qmx1ck91dGVyMSIgaW4yPSJTb3VyY2VBbHBoYSIgb3BlcmF0b3I9Im91dCIgcmVzdWx0PSJzaGFkb3dCbHVyT3V0ZXIxIj48L2ZlQ29tcG9zaXRlPgogICAgICAgICAgICA8ZmVDb2xvck1hdHJpeCB2YWx1ZXM9IjAgMCAwIDAgMCAgIDAgMCAwIDAgMCAgIDAgMCAgIDAgMCAwIDAgMCAgMCAwIDAgMC4wNCAwIiB0eXBlPSJtYXRyaXgiIGluPSJzaGFkb3dCbHVyT3V0ZXIxIj48L2ZlQ29sb3JNYXRyaXg+CiAgICAgICAgPC9maWx0ZXI+CiAgICA8L2RlZnM+CiAgICA8ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4KICAgICAgICA8ZyBpZD0i5Z+656GA5rWB56iL5Zu+LTAxIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTA2LjAwMDAwMCwgLTkzLjAwMDAwMCkiPgogICAgICAgICAgICA8ZyBpZD0iR3JvdXAtMiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTEwLjAwMDAwMCwgOTUuMDAwMDAwKSI+CiAgICAgICAgICAgICAgICA8ZyBpZD0iT3ZhbCI+CiAgICAgICAgICAgICAgICAgICAgPHVzZSBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIxIiBmaWx0ZXI9InVybCgjZmlsdGVyLTIpIiB4bGluazpocmVmPSIjcGF0aC0xIj48L3VzZT4KICAgICAgICAgICAgICAgICAgICA8dXNlIGZpbGwtb3BhY2l0eT0iMC49MiIgZmlsbD0iI2NjY2M5OThjIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHhsaW5rOmhyZWY9IiNwYXRoLTEiPjwvdXNlPgogICAgICAgICAgICAgICAgICAgIDxjaXJjbGUgc3Ryb2tlPSIjY2NjYzMzYzkiIHN0cm9rZS13aWR0aD0iMSIgY3g9IjM2IiBjeT0iMzYiIHI9IjM1LjUiPjwvY2lyY2xlPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgPHRleHQgaWQ9Iue7k+adn+iKgueCuSIgZm9udC1mYW1pbHk9IlBpbmdGYW5nU0MtUmVndWxhciwgUGluZ0ZhbmcgU0MiIGZvbnQtc2l6ZT0iMTIiIGZvbnQtd2VpZ2h0PSJub3JtYWwiIGxpbmUtc3BhY2luZz0iMTIiIGZpbGw9IiMwMDAwMDAiIGZpbGwtb3BhY2l0eT0iMC42NSI+CiAgICAgICAgICAgICAgICAgICAgPHRzcGFuIHg9IjEyIiB5PSI0MSI+57uT5p2f6IqC54K5PC90c3Bhbj4KICAgICAgICAgICAgICAgIDwvdGV4dD4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+', import.meta.url).href
})
</script>
<style scoped>
.image-node {
position: relative;
width: 50px; /* 调整为适合图片的尺寸 */
height: 50px; /* 调整为适合图片的尺寸 */
overflow: visible;
}
.image-container {
width: 100%;
height: 100%;
border-radius: 50%;
border: 2px solid #333;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
box-sizing: border-box;
}
.node-image {
max-width: 50px;
max-height: 50px;
overflow: visible;
position: relative;
}
.handle {
width: 10px;
height: 10px;
background: #ff0000;
border-radius: 50%;
position: absolute;
z-index: 2;
}
:deep(.vue-flow__handle-top) {
top: -5px; /* 移到外部 */
left: 50%;
transform: translateX(-50%);
}
:deep(.vue-flow__handle-bottom) {
bottom: -5px; /* 移到外部 */
left: 50%;
transform: translateX(-50%);
}
:deep(.vue-flow__handle-left) {
left: -5px; /* 移到外部 */
top: 50%;
transform: translateY(-50%);
}
:deep(.vue-flow__handle-right) {
right: -5px; /* 移到外部 */
top: 50%;
transform: translateY(-50%);
}
</style>
操作实现 (#操作实现)
拖拽节点 (#拖拽节点)
原理
: 使用 draggable 和 onDrop,从侧边栏拖动节点到画布。
代码
: 见 index.vue 中的 onDragStart 和 onDrop。
效果
:拖动 “开始” 或 “决策” 到画布,生成对应节点,位置基于鼠标坐标。
使用 draggable 和 onDrop
,从侧边栏拖动节点到画布。
代码
:见 index.vue 中的 onDragStart 和 onDrop。
效果
:拖动 “开始” 或 “决策” 到画布,生成对应节点,位置基于鼠标坐标。
连线 (多连接点)
原理
:@connect 捕获连接,Handle 定义多连接点,smoothstep 提供平滑线。
代码
:见 index.vue 中的 onConnect。
效果
:点击节点任意连接点拖动,生成红线连接,箭头指向目标。
删除节点
原理
:@node-click 选中,deleteSelected 调用 removeElements。
代码
:见 index.vue 中的 onNodeClick 和 deleteSelected。
效果
:点击节点高亮,点击删除按钮移除节点及相关边。
保存为 JSON
原理
:exportJson 序列化 nodes 和 edges,生成下载文件。
代码
:见 index.vue 中的 exportJson。
unction exportJson() {
const json = JSON.stringify({ nodes: nodes.value ,edges: edges.value }, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'flowchart.json'
a.click()
window.URL.revokeObjectURL(url)
message.success('JSON 文件已导出')
}
效果
:点击按钮下载 flowchart.json,包含当前结构。
导入 JSON
原理
:importJson 解析上传文件,更新 nodes 和 edges。
代码
:见 index.vue 中的 importJson。
效果
:上传 JSON 文件,画布还原节点和边。
性能优化建议
- 限制节点数量:使用虚拟列表优化大规模节点渲染。
- 防抖处理:对 onDrop 和 onConnect 添加防抖,减少频繁更新。
- 懒加载:大图节点时,延迟加载图片资源。
常见问题与解决
- 拖拽位置偏移:检查 bounds 计算,调整 event.clientX - bounds.left。
- 连接点无效:确保 Handle ID 唯一,检查 connectable 属性。
- JSON 解析错误:验证文件格式,添加错误处理:
reader.onload = (e: any) => {
try {
const data = JSON.parse(e.target.result)
nodes.value = data.nodes || []
edges.value = data.edges || []
message.success('导入成功')
} catch (error) {
message.error('JSON 格式错误')
}
}
总结
通过 Vue 整合 Vue Flow,开发者可构建功能丰富的交互式流程图。本文从环境准备到自定义节点、拖拽、连线、删除和 JSON 管理,提供了完整指南。Vue Flow 的灵活性使其适用于工作流、数据可视化等场景,建议根据需求优化性能并处理异常。