目录
- 引言
- 目的
- 适用场景
- 环境准备
- 基础组件 (index.vue)
- 自定义组件 (矩形、菱形等)
- RectangleNode.vue (矩形节点):
- DiamondNode.vue (菱形节点):
- ImageNode(自定义图片节点):
- 操作实现 (#操作实现)
- 拖拽节点 (#拖拽节点)
- 连线 (多连接点)
- 删除节点
- 保存为 JSON
- 导入 JSON
- 性能优化建议
- 常见问题与解决
- 总结
- `demo见:`
引言
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><divv-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><VueFlowv-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.typeif (type) {event.dataTransfer.setData('application/vueflow-node-type', type)}}
}function onDrop(event: DragEvent) {const type = event.dataTransfer?.getData('application/vueflow-node-type')if (!type) returnconst 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 = urla.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 = urla.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 的灵活性使其适用于工作流、数据可视化等场景,建议根据需求优化性能并处理异常。
demo见:
https://gitee.com/codingtodie/vue-integration-with-vue-flow