目錄
- 引言
- 目的
- 適用場景
- 環境準備
- 基礎組件 (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