Vue 3 + AntV X6 实现流程编辑功能

本文将通过代码实例详细讲解如何使用 Vue 3 和 AntV X6 构建一个可视化流程编辑器,重点介绍核心功能的实现方法。

效果图

点击查看源码

为什么选择 AntV X6?

AntV X6 是蚂蚁集团开源的图编辑引擎,专门为流程图、拓扑图等场景设计:

  • 开箱即用:内置节点、连线、拖拽等功能
  • Vue 友好:与 Vue 3 完美集成
  • 文档完善:中文文档,示例丰富
  • 功能强大:支持自定义节点、连线规则等

项目结构

我们的流程编辑器由 4 个核心组件组成:

bash 复制代码
流程编辑器
├── ProcessToolbar.vue    # 工具栏(保存、导出等)
├── NodePanel.vue         # 左侧节点面板
├── ProcessCanvas.vue     # 中间画布区域
└── NodeConfig.vue        # 右侧属性配置

核心功能实现

1. 画布初始化

首先创建 X6 画布,这是整个流程编辑器的核心:

javascript 复制代码
// ProcessCanvas.vue
import { Graph } from '@antv/x6'

export default {
  setup() {
    const container = ref(null)
    const graph = ref(null)
    
    const initGraph = () => {
      graph.value = new Graph({
        container: container.value,
        width: '100%',
        height: 600,
        
        // 显示网格,方便对齐
        grid: {
          visible: true,
          type: 'doubleMesh'
        },
        
        // 支持鼠标滚轮缩放
        mousewheel: true,
        
        // 支持画布拖拽
        panning: {
          enabled: true,
          eventTypes: ['leftMouseDown']
        },
        
        // 连线配置
        connecting: {
          router: { name: 'manhattan' }, // 直角连线
          connector: { name: 'rounded' }, // 圆角连线
          allowBlank: false, // 不允许连接到空白处
          allowLoop: false,  // 不允许自连接
          snap: true,        // 自动吸附到连接桩
          
          // 连接验证规则
          validateConnection({ sourceView, targetView }) {
            const sourceType = sourceView.cell.getData()?.type
            const targetType = targetView.cell.getData()?.type
            
            // 开始节点不能作为目标
            if (targetType === 'start') return false
            // 结束节点不能作为源
            if (sourceType === 'end') return false
            
            return sourceView !== targetView
          }
        }
      })
      
      // 监听节点点击事件
      graph.value.on('node:click', ({ node }) => {
        emit('nodeSelected', node)
      })
    }
    
    onMounted(() => {
      initGraph()
    })
    
    return {
      container,
      graph
    }
  }
}

关键配置说明:

  • grid: 显示网格,帮助用户对齐节点
  • mousewheel: 支持滚轮缩放
  • panning: 支持拖拽画布
  • connecting: 连线规则配置
  • validateConnection: 自定义连接验证逻辑

2. 节点拖拽功能

从左侧面板拖拽节点到画布是核心功能,我们需要定义不同类型的节点:

javascript 复制代码
// NodePanel.vue - 节点面板
export default {
  setup() {
    // 定义节点类型
    const nodeList = [
      { name: '开始', type: 'start', iconClass: 'el-icon-video-play' },
      { name: '流程', type: 'process', iconClass: 'el-icon-s-operation' },
      { name: '判断', type: 'inspection', iconClass: 'el-icon-help' },
      { name: '结束', type: 'end', iconClass: 'el-icon-video-pause' }
    ]
    
    // 开始拖拽
    const startDrag = (nodeConfig, event) => {
      emit('startDrag', nodeConfig, event)
    }
    
    return {
      nodeList,
      startDrag
    }
  }
}
javascript 复制代码
// ProcessCanvas.vue - 处理拖拽
import { Dnd } from '@antv/x6-plugin-dnd'

export default {
  setup() {
    // 创建不同类型的节点
    const createNode = (nodeConfig) => {
      const baseConfig = {
        width: 100,
        height: 40,
        label: nodeConfig.name,
        data: { type: nodeConfig.type }
      }
      
      // 根据类型设置不同样式
      switch (nodeConfig.type) {
        case 'start':
          return {
            ...baseConfig,
            shape: 'circle',
            width: 60,
            height: 60,
            attrs: {
              body: {
                fill: '#e8f7ee',
                stroke: '#67c23a'
              }
            }
          }
          
        case 'end':
          return {
            ...baseConfig,
            shape: 'circle',
            width: 60,
            height: 60,
            attrs: {
              body: {
                fill: '#fef0f0',
                stroke: '#f56c6c'
              }
            }
          }
          
        case 'inspection':
          return {
            ...baseConfig,
            shape: 'polygon',
            width: 80,
            height: 80,
            attrs: {
              body: {
                fill: '#fef9e7',
                stroke: '#e6a23c',
                refPoints: '0,10 10,0 20,10 10,20' // 菱形
              }
            }
          }
          
        default: // process 类型
          return {
            ...baseConfig,
            shape: 'rect',
            attrs: {
              body: {
                fill: '#fff',
                stroke: '#1890ff'
              }
            }
          }
      }
    }
    
    // 处理拖拽事件
    const handleStartDrag = (nodeConfig, event) => {
      const node = graph.value.createNode(createNode(nodeConfig))
      
      const dnd = new Dnd({
        target: graph.value
      })
      
      dnd.start(node, event)
    }
    
    return {
      handleStartDrag
    }
  }
}

实现要点:

  • 不同节点类型使用不同的形状(圆形、矩形、菱形)
  • 通过 attrs.body 设置节点样式
  • 使用 X6 的 Dnd 插件实现拖拽功能

3. 节点右键菜单

为节点添加右键菜单,提供删除等操作:

javascript 复制代码
// ProcessCanvas.vue
export default {
  setup() {
    // 显示右键菜单
    const showContextMenu = (node, event) => {
      event.preventDefault()
      
      // 创建菜单
      const menu = document.createElement('div')
      menu.innerHTML = `
        <div class="context-menu">
          <div class="menu-item" data-action="delete">
            删除节点
          </div>
        </div>
      `
      
      // 设置菜单位置
      menu.style.cssText = `
        position: fixed;
        left: ${event.clientX}px;
        top: ${event.clientY}px;
        z-index: 9999;
        background: white;
        border: 1px solid #ddd;
        border-radius: 4px;
        box-shadow: 0 2px 8px rgba(0,0,0,0.1);
      `
      
      document.body.appendChild(menu)
      
      // 处理菜单点击
      menu.addEventListener('click', (e) => {
        const action = e.target.dataset.action
        
        if (action === 'delete') {
          graph.value.removeNode(node)
          emit('nodeSelected', null) // 清空选中状态
        }
        
        document.body.removeChild(menu)
      })
      
      // 点击外部关闭菜单
      const closeMenu = (e) => {
        if (!menu.contains(e.target)) {
          document.body.removeChild(menu)
          document.removeEventListener('click', closeMenu)
        }
      }
      
      setTimeout(() => {
        document.addEventListener('click', closeMenu)
      }, 100)
    }
    
    // 监听右键事件
    graph.value.on('node:contextmenu', ({ node, e }) => {
      showContextMenu(node, e)
    })
    
    return {
      showContextMenu
    }
  }
}

关键点:

  • 使用原生 DOM 创建菜单
  • 根据鼠标位置定位菜单
  • 删除节点后清空选中状态

4. 导入导出功能

实现流程图的保存和加载:

javascript 复制代码
// ProcessToolbar.vue - 工具栏
export default {
  setup() {
    // 导出为 JSON
    const exportJSON = () => {
      const data = graph.value.toJSON()
      
      // 创建下载链接
      const blob = new Blob([JSON.stringify(data, null, 2)], {
        type: 'application/json'
      })
      
      const url = URL.createObjectURL(blob)
      const link = document.createElement('a')
      link.href = url
      link.download = `process-${Date.now()}.json`
      link.click()
      
      URL.revokeObjectURL(url)
    }
    
    // 导入 JSON
    const importJSON = () => {
      const input = document.createElement('input')
      input.type = 'file'
      input.accept = '.json'
      
      input.onchange = (e) => {
        const file = e.target.files[0]
        if (!file) return
        
        const reader = new FileReader()
        reader.onload = (event) => {
          try {
            const data = JSON.parse(event.target.result)
            
            // 清空画布并导入数据
            graph.value.clearCells()
            graph.value.fromJSON(data)
            
            ElMessage.success('导入成功')
          } catch (error) {
            ElMessage.error('导入失败:' + error.message)
          }
        }
        
        reader.readAsText(file)
      }
      
      input.click()
    }
    
    // 导出为图片
    const exportImage = async () => {
      try {
        const dataURL = await graph.value.exportPNG('process-diagram', {
          backgroundColor: '#f5f5f5',
          padding: 20
        })
        
        // 下载图片
        const link = document.createElement('a')
        link.href = dataURL
        link.download = `process-${Date.now()}.png`
        link.click()
        
        ElMessage.success('导出成功')
      } catch (error) {
        ElMessage.error('导出失败')
      }
    }
    
    return {
      exportJSON,
      importJSON,
      exportImage
    }
  }
}

核心方法:

  • toJSON(): 将画布数据转换为 JSON
  • fromJSON(): 从 JSON 数据恢复画布
  • exportPNG(): 导出为图片格式

常见问题与解决方案

1. 内存泄漏问题

问题:组件销毁时 X6 实例没有正确清理。

解决方案

javascript 复制代码
// 在组件销毁时清理资源
onUnmounted(() => {
  if (graph.value) {
    graph.value.dispose() // 销毁 X6 实例
  }
})

2. 响应式数据优化

问题:将整个 graph 实例设为响应式会影响性能。

解决方案

javascript 复制代码
// ❌ 错误做法
const graph = ref(new Graph(...))

// ✅ 正确做法
const graph = shallowRef(null) // 使用 shallowRef
const currentNode = ref(null)   // 只将必要的状态设为响应式

3. 连接点不显示

问题:节点的连接点有时不显示。

解决方案

javascript 复制代码
// 在节点配置中确保连接点可见
ports: {
  groups: {
    default: {
      attrs: {
        circle: {
          style: { visibility: 'visible' } // 关键设置
        }
      }
    }
  }
}

4. 删除节点后右侧配置未清空

问题:删除节点后,右侧属性配置面板仍显示已删除节点的信息。

解决方案

javascript 复制代码
// 删除节点时清空选中状态
const deleteNode = (node) => {
  graph.value.removeNode(node)
  emit('nodeSelected', null) // 清空当前选中节点
}

学习要点总结

1. 核心技术栈

  • Vue 3: 使用 Composition API 组织代码逻辑
  • AntV X6: 专业的图编辑引擎,功能强大
  • Element Plus: 提供 UI 组件支持

2. 关键实现步骤

  1. 初始化画布: 配置 X6 实例,设置网格、缩放、连线规则
  2. 节点拖拽: 定义不同类型节点,实现从面板到画布的拖拽
  3. 事件处理: 监听节点点击、右键菜单等交互事件
  4. 数据持久化: 实现流程图的导入导出功能

3. 开发建议

  • 使用 shallowRef 而不是 ref 来存储 X6 实例
  • 组件销毁时记得调用 graph.dispose() 清理资源
  • 合理设置连接验证规则,避免无效连接
  • 删除节点时要清空选中状态

4. 扩展方向

  • 添加更多节点类型(网关、子流程等)
  • 实现撤销/重做功能
  • 支持节点分组和折叠
  • 添加流程验证功能
相关推荐
前端工作日常1 小时前
我理解的`npm pack` 和 `npm install <local-path>`
前端
李剑一1 小时前
说个多年老前端都不知道的标签正确玩法——q标签
前端
嘉小华1 小时前
大白话讲解 Android屏幕适配相关概念(dp、px 和 dpi)
前端
姑苏洛言1 小时前
在开发跑腿小程序集成地图时,遇到的坑,MapContext.includePoints(Object object)接口无效在组件中使用无效?
前端
七八书1 小时前
Vue3 组件通信全解析:从基础到进阶的实用指南
vue.js
奇舞精选1 小时前
Prompt 工程实用技巧:掌握高效 AI 交互核心
前端·openai
用户3802258598242 小时前
vue3源码解析:模块总览
vue.js
Danny_FD2 小时前
React中可有可无的优化-对象类型的使用
前端·javascript
用户757582318552 小时前
混合应用开发:企业降本增效之道——面向2025年移动应用开发趋势的实践路径
前端
P1erce2 小时前
记一次微信小程序分包经历
前端