news 2026/6/11 6:42:54

Vue + G6 实现拖拽连线、右键编辑、本地存取的流程图交互方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue + G6 实现拖拽连线、右键编辑、本地存取的流程图交互方案

本文还有配套的精品资源,点击获取

简介:基于 Vue 2/3 技术栈,集成 AntV/G6 图可视化库,实现开箱即用的流程图交互能力。支持鼠标拖拽创建节点、自动吸附连线、点击绑定/断开边关系、右键弹出菜单执行删除等操作;画布支持实时重绘与缩放,所有图数据通过 JSON 序列化后存入 localStorage,刷新页面可完整恢复编辑状态。状态统一交由 Vuex 管理,不直接操作 DOM,便于后续接入后端接口或扩展校验逻辑。项目结构清晰:包含标准 Webpack 构建配置(区分 dev/prod)、模拟路由导航(navlist.js)、通用工具函数(utils.js、yule.js)、Mock 数据占位(mock 目录)、基础 UI 封装(App.vue)及模块化组件组织(components/router/store)。无复杂算法封装,聚焦 G6 渲染层与 Vue 响应式系统的协作细节,适合中后台系统快速嵌入轻量级流程编排功能,可直接 npm install 后运行调试,也可按需剥离节点编辑、存储或菜单模块单独复用。

1. 项目概述:为什么这个流程图方案值得你花十分钟读完

我在做第三个审批流系统时,终于把“画个流程图”这件事从后端硬编码搬到了前端可视化编辑器里。不是那种拖拽完导出 BPMN XML、再扔给引擎跑的重型方案,而是真正让业务同学自己在页面上点几下就能搭出一个可用流程的轻量级交互——节点拖进来、连线自动吸附、右键删节点、缩放平移不卡顿、关掉浏览器再打开,连连线的弯曲弧度都一模一样。这套方案的核心,就是 Vue + G6 的组合拳。

它解决的不是“能不能画”,而是“画得稳不稳、改得顺不顺、存得住存不住、接得上接不上”。比如你肯定遇到过:拖拽节点时画布抖动、连线断开后残留虚线、右键菜单弹出来位置偏移半个屏幕、localStorage 存的 JSON 里多了个 undefined 导致整个图加载失败……这些不是边缘问题,是用户第一次点击“新建流程”就可能卡住的体验断点。而这个方案,从第一天起就把这些细节钉死在代码里:G6 的graph.on('before:dragstart')拦截了原生拖拽冲突;连线用edge.type = 'cubic-horizontal'配合edge.style.lineDash = [4, 2]实现视觉引导;右键菜单用event.clientX/event.clientY动态计算坐标,避开 Vue 指令与 G6 事件冒泡打架;localStorage 存取前强制JSON.stringify(JSON.parse(JSON.stringify(data)))做深度净化,防循环引用炸掉整个页面。

关键词里写的“vue,g6,流程图编辑,本地存储,节点连线”,每一个都不是标签,而是实打实踩坑后留下的锚点。Vue 负责状态响应和组件组织,G6 负责渲染精度和交互手感,本地存储不是简单setItem,而是带版本号、带校验、带降级兜底的持久化策略,节点连线不是静态 SVG,而是可编程的边生命周期管理(create → bind → update → destroy)。它适合两类人:一类是正在选型中后台流程编排模块的前端负责人,想快速验证可行性、评估接入成本;另一类是已经卡在 G6 事件绑定或 Vue 响应式同步上的开发者,需要一份能直接抄作业、改两行就能跑起来的参考实现。不需要懂图论算法,不需要研究 G6 源码,但你要愿意跟着我把graph.add('node', {...})store.commit('ADD_NODE', payload)这两行代码之间的那层胶水,一层层剥开来看。

2. 整体架构设计:为什么选 Vuex 而不是 Pinia?为什么 G6 不直接挂 Vue 实例?

2.1 状态流设计:三层隔离,各司其职

这个方案的状态管理不是“为了用 Vuex 而用 Vuex”,而是被 G6 的底层机制倒逼出来的必然选择。G6 是一个典型的命令式绘图库:你调graph.add(),它立刻渲染;你调graph.remove(),它立刻删 DOM 元素。但 Vue 是声明式的:数据变了,视图才更新。如果让 Vue 组件直接调 G6 API,就会出现经典的时间差问题——比如用户点了删除按钮,Vue 还没来得及把节点从nodes数组里 splice 掉,G6 已经把对应 DOM 删了,结果 store 里的状态还是旧的,下次刷新页面,那个节点又诡异地回来了。

所以整个架构被切成三层:

  • View 层(App.vue 及子组件):只负责接收用户操作(拖拽开始、右键点击、缩放手势),把原始事件参数(如e.x,e.y,e.item.get('id'))打包成 payload,通过this.$store.dispatch()抛给 Store;
  • Store 层(store/modules/graph.js):作为唯一真相源,管理所有图元数据(nodes、edges、groups)、画布状态(zoom、center、dragging)、UI 状态(contextMenuVisible、selectedNodeId);所有变更必须走 mutation,不允许组件直改 state;
  • Render 层(G6 实例封装):完全剥离 Vue 响应式,只暴露render(),updateNode(),removeEdge()等纯函数接口;它监听 Store 的变化(通过store.subscribe()),一旦发现nodesedges数组有增删,立刻调用 G6 原生 API 同步画布。

这三层之间没有双向绑定,只有单向数据流:View → Store → Render。好处是清晰可控——你想知道某个节点为什么没删掉,直接断点REMOVE_NODEmutation;想知道连线为什么没更新,去watchstore 里的edges;想调试渲染性能,把render()函数单独抽出来压测。我试过把 G6 实例直接挂在 Vue 的data里,结果拖拽时 Vue 的依赖收集疯狂触发,帧率直接掉到 15fps;也试过用ref存 graph 实例再watchnodes,但 G6 的graph.changeData()会触发内部重绘,导致 watch 回调里又调changeData(),形成死循环。最终这个三层结构,是用三天时间、七次内存泄漏排查换来的最稳解法。

2.2 G6 版本与 Vue 兼容性取舍:为什么锁定 G6 3.8.3?

项目摘要里没提 G6 版本,但实际代码锁死在3.8.3。这不是随意选的,而是踩过两个大坑后的精准卡位:

  • G6 4.x 的破坏性升级:4.x 彻底重构了插件系统,GridPluginSnaplinePlugin这些基础辅助线功能被拆进独立包,且 API 全面 Promise 化。但我们的右键菜单需要同步获取当前鼠标下的节点 ID,而graph.findById()在 4.x 里返回的是 Promise,意味着菜单弹出要等异步 resolve,用户体验变成“右键→空白→0.3秒后菜单闪现”。3.8.3 的findById()是同步的,毫秒级响应。
  • Vue 2 与 Vue 3 的响应式差异:项目同时支持 Vue 2 和 Vue 3(通过@vue/compat),但 G6 3.8.3 的item.get('model')返回的是普通对象,能被 Vue 2 的Object.defineProperty和 Vue 3 的Proxy同时劫持;而 G6 4.x 的 model 对象加了Symbol.iterator,在 Vue 2 下会触发TypeError: Cannot convert a Symbol value to a string。我们线上系统还有大量 Vue 2 项目,不能为新特性放弃存量。

所以package.json里明确写"@antv/g6": "3.8.3",而不是"^3.8.3"。顺便说,yule.js里那个deepCloneForG6()函数,就是专门处理 G6 3.8.3 的 model 对象里隐藏的_cfg_children这些不可序列化字段的——直接JSON.stringify()会报错,必须手动过滤。

2.3 本地存储策略:为什么不用 IndexedDB?为什么加 version 字段?

localStorage看似简陋,但在这个场景里反而是最优解。原因很实在:流程图数据量极小。一个典型审批流,最多 20 个节点、30 条边,JSON 字符串撑死 5KB。IndexedDB 的优势在于海量数据索引查询,而我们只需要“存一次、取一次”,用localStorage.setItem('flow-graph-v2', JSON.stringify(data))就够了,多写一行代码都是冗余。

但直接存 raw data 会翻车。我遇到过三次典型故障:
- 第一次:开发环境用了JSON.stringify(graph.save()),但graph.save()返回的对象里包含canvas引用,序列化时报错;
- 第二次:测试同学清缓存后,旧版代码存的数据被新版解析,node.type字段名从shape改成了type,导致整个图白屏;
- 第三次:用户在 A 标签页编辑,B 标签页刷新,两个页面互相覆盖 localStorage。

解决方案就藏在store/modules/graph.jspersistToStorage()里:

const STORAGE_KEY = 'flow-graph'; const CURRENT_VERSION = 'v2'; const persistToStorage = (data) => { try { // 1. 深度净化:移除 canvas、group、_cfg 等非序列化字段 const cleanData = cleanG6Data(data); // 2. 加版本号:避免跨版本数据污染 const payload = { version: CURRENT_VERSION, data: cleanData }; // 3. 带校验:存之前先 parse 一遍,防脏数据 JSON.parse(JSON.stringify(payload)); localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } catch (e) { console.warn('Local storage save failed:', e); // 4. 降级兜底:存失败时,至少保留内存数据 store.commit('SET_STORAGE_ERROR', true); } };

cleanG6Data()函数会递归遍历 nodes 和 edges,删掉所有以_开头的属性、canvasgroupparent等 G6 内部引用。version 字段则让loadFromStorage()可以做兼容判断:如果是v1数据,就走迁移脚本转成v2结构;如果是v3,直接拒绝加载并提示用户“请刷新页面”。这个设计,让本地存储从“可能炸掉整个应用”的风险点,变成了“稳如老狗”的默认选项。

3. 核心交互实现:拖拽、连线、右键、重绘,每一处都是精心设计

3.1 节点拖拽创建:为什么不用 G6 内置的addBehavior('drag-node')

G6 官方文档推荐用graph.addBehavior('drag-node')实现拖拽,但这个方案在 Vue 环境里会和v-model冲突。原因在于:drag-node行为会直接修改节点 model 的x/y,触发 G6 内部重绘;而 Vue 组件如果绑定了:x="node.x",就会收到x变更通知,试图同步更新,结果两边都在改同一个值,画布疯狂抖动。

我们采用的是“伪拖拽”方案:
1. 用户按住节点图标(来自components/NodePalette.vue)开始拖拽时,Vue 组件记录鼠标初始位置和节点模板;
2. 监听document.mousemove,计算鼠标相对初始位置的偏移量,动态生成一个半透明预览节点(用graph.add('node', { id: 'preview', x: e.x, y: e.y, ... }));
3. 鼠标松开(mouseup)时,把预览节点的x/y传给 Store,触发ADD_NODEmutation;
4. Store 提交后,Render 层调用graph.add()创建真实节点,并立即删掉预览节点。

关键代码在src/utils/dragHelper.js

export const startDragNode = (template, e) => { const previewId = `preview-${Date.now()}`; // 创建预览节点(不加入 store,仅用于视觉反馈) graph.add('node', { id: previewId, x: e.clientX, y: e.clientY, ...template, style: { opacity: 0.7 } }); const moveHandler = (moveEvent) => { graph.updateItem(previewId, { x: moveEvent.clientX, y: moveEvent.clientY }); }; const upHandler = () => { document.removeEventListener('mousemove', moveHandler); document.removeEventListener('mouseup', upHandler); // 获取最终位置,提交到 store const finalPos = graph.findById(previewId).get('model'); store.dispatch('ADD_NODE', { ...template, x: finalPos.x, y: finalPos.y }); graph.remove(previewId); // 清理预览 }; document.addEventListener('mousemove', moveHandler); document.addEventListener('mouseup', upHandler); };

这个方案牺牲了一点代码量,但换来三个确定性收益:
- 拖拽过程完全由 Vue 控制,不会和 G6 的drag-node行为打架;
- 预览节点可以加阴影、缩放动画,用户体验比原生拖拽更细腻;
- 松开鼠标那一刻,Store 才收到创建指令,状态变更可预测、可回溯。

提示:NodePalette.vue里的节点图标用的是 SVG Sprite,不是 PNG。因为 SVG 可以直接用 CSS 控制fill颜色,当用户选中某个节点类型时,图标自动高亮,不用切图。

3.2 连线绑定与断开:吸附逻辑怎么写才不卡顿?

连线是整个方案里最考验性能的地方。G6 的edge默认是直线,但我们想要“智能吸附”:当鼠标靠近节点 20px 范围时,连线终点自动吸附到节点中心;拖离时,再平滑过渡回鼠标位置。如果每帧都计算所有节点距离,100 个节点就要做 100 次勾股定理运算,FPS 直接崩。

我们用的是空间分区优化法:
1. 把画布按 100×100 像素划分为网格;
2. 每个节点创建时,记录它所在的网格坐标(Math.floor(node.x / 100) + '-' + Math.floor(node.y / 100));
3. 连线时,只计算鼠标所在网格及相邻 8 个网格内的节点距离。

核心函数在src/utils/snapHelper.js

// 构建网格索引 export const buildGridIndex = (nodes) => { const grid = {}; nodes.forEach(node => { const gx = Math.floor(node.x / 100); const gy = Math.floor(node.y / 100); const key = `${gx}-${gy}`; if (!grid[key]) grid[key] = []; grid[key].push(node); }); return grid; }; // 快速查找附近节点 export const findNearbyNodes = (grid, x, y, radius = 20) => { const gx = Math.floor(x / 100); const gy = Math.floor(y / 100); const candidates = []; // 只查 3×3 网格 for (let dx = -1; dx <= 1; dx++) { for (let dy = -1; dy <= 1; dy++) { const key = `${gx + dx}-${gy + dy}`; if (grid[key]) candidates.push(...grid[key]); } } return candidates.filter(node => { const dx = node.x - x; const dy = node.y - y; return dx * dx + dy * dy <= radius * radius; }); };

实际连线时,graph.on('edge:drag', e)里调用findNearbyNodes(gridIndex, e.x, e.y),最多查 9 个网格、30 个节点,计算量降到原来的 1/3。吸附效果用 CSS transition 实现:edge.style.lineDash = [4, 2]画虚线,吸附瞬间edge.style.lineDash = [0, 0]变实线,视觉上就是“啪”一下吸住了。

断开逻辑更简单:右键菜单里点“断开连接”,Store 触发REMOVE_EDGE,Render 层调graph.remove(edgeId)。但有个细节:G6 删除边后,关联的节点model.edges数组不会自动更新。所以我们加了afterRemoveEdge钩子,在store/mutation.js里手动清理:

REMOVE_EDGE(state, edgeId) { state.edges = state.edges.filter(e => e.id !== edgeId); // 清理节点上的边引用 state.nodes.forEach(node => { node.model.edges = node.model.edges?.filter(id => id !== edgeId) || []; }); }

3.3 右键菜单:为什么菜单不随画布缩放?如何避免菜单遮挡节点?

右键菜单看起来简单,实则暗坑无数。第一个问题是:G6 画布支持缩放(graph.zoom(1.5)),但原生<div>菜单不会跟着缩放,导致菜单尺寸错乱。第二个问题是:菜单弹出位置用e.clientX/clientY,但 G6 的坐标系原点在左上角,而画布可能被translate平移过,直接算会偏移。

解决方案是双坐标系转换:
1. 用graph.getCanvasByPoint(e.clientX, e.clientY)把屏幕坐标转成画布坐标;
2. 再用graph.getPointByCanvas(x, y)把画布坐标转回屏幕坐标(此时已考虑缩放和平移);
3. 菜单style.left/top设置为转换后的值。

components/ContextMenu.vue关键代码:

<template> <div v-show="visible" class="context-menu" :style="{ left: `${screenX}px`, top: `${screenY}px`, transform: `scale(${1 / zoom})`, // 反向缩放菜单 transformOrigin: '0 0' }" > <ul> <li @click="handleDelete">删除节点</li> <li @click="handleEdit">编辑属性</li> <li @click="handleDisconnect">断开连接</li> </ul> </div> </template> <script> export default { computed: { screenX() { // 1. 屏幕坐标 → 画布坐标 const canvasPoint = this.graph.getCanvasByPoint(this.clientX, this.clientY); // 2. 画布坐标 → 屏幕坐标(含缩放修正) const point = this.graph.getPointByCanvas(canvasPoint.x, canvasPoint.y); return point.x; }, screenY() { const canvasPoint = this.graph.getCanvasByPoint(this.clientX, this.clientY); const point = this.graph.getPointByCanvas(canvasPoint.x, canvasPoint.y); return point.y; } } }; </script>

transform: scale(${1 / zoom})是精髓——画布放大 2 倍,菜单就缩小 0.5 倍,视觉尺寸永远一致。另外,菜单z-index设为9999,但加了pointer-events: none,菜单里的<li>再设pointer-events: auto,这样鼠标划过菜单时,底层节点不会触发mouseenter,避免误操作。

3.4 画布重绘与缩放:为什么graph.refresh()不够用?

G6 的graph.refresh()只重绘可见区域,但当我们动态增删节点、切换主题色、或者从 localStorage 恢复数据时,需要全量重绘。直接graph.changeData()会触发 G6 内部布局计算,但如果节点太多,会导致主线程阻塞,页面卡顿 200ms。

我们采用分帧渲染策略:
- 把nodesedges数组按每 10 个一组切片;
- 每帧用requestAnimationFrame()渲染一组;
- 渲染完一组,检查是否还有剩余,有则继续下一帧。

src/utils/renderHelper.js

export const batchRender = (graph, nodes, edges, callback) => { const nodeChunks = chunkArray(nodes, 10); const edgeChunks = chunkArray(edges, 10); let nodeIndex = 0; let edgeIndex = 0; const renderFrame = () => { // 先渲染节点 if (nodeIndex < nodeChunks.length) { nodeChunks[nodeIndex].forEach(node => graph.add('node', node)); nodeIndex++; requestAnimationFrame(renderFrame); return; } // 再渲染边 if (edgeIndex < edgeChunks.length) { edgeChunks[edgeIndex].forEach(edge => graph.add('edge', edge)); edgeIndex++; requestAnimationFrame(renderFrame); return; } // 全部完成 callback?.(); }; requestAnimationFrame(renderFrame); };

这个函数在store/actions.jsLOAD_FROM_STORAGE里被调用。实测 50 个节点+60 条边,分帧渲染耗时 120ms,而一次性changeData()耗时 380ms,且后者会让页面明显卡顿。对于用户来说,就是“点加载按钮→画面流畅渐显→200ms 后全部就绪”,而不是“点加载→卡顿半秒→突然全出来”。

4. 工程化与扩展性:Webpack 配置、Mock 数据、组件拆分,为什么这样组织?

4.1 Webpack 双环境配置:dev 环境为什么加devServer.overlay = false

config/index.js里 dev 环境的devServer配置有一行容易被忽略的设置:

devServer: { overlay: false, // 关键!禁用全屏错误覆盖 stats: 'minimal', hot: true }

G6 报错时,错误信息常包含canvas.getContextWebGLRenderingContext等底层调用栈,Webpack Dev Server 的默认overlay: true会把整个页面盖住一个红色错误框,用户根本看不到画布上哪个节点出错了。禁用 overlay 后,错误打印在控制台,画布保持可见,开发者能直接看到“节点 A 的 x 坐标是 NaN,所以没渲染出来”,调试效率提升 3 倍。

生产环境配置更激进:
-optimization.splitChunks强制把g6vue打包进vendor.js,避免首屏加载时重复下载;
-new CompressionPlugin()开启 gzip,G6 的 JS 文件压缩后从 1.2MB 降到 420KB;
-HtmlWebpackPlugin注入defer属性,确保main.js在 DOM 解析完后再执行,防止document.getElementById('mount')找不到节点。

4.2 Mock 数据与 navlist.js:为什么路由模拟不用 Vue Router?

项目目录里有navlist.js,但没有router/index.js。这是因为中后台系统往往已有统一的权限路由体系,强行接入 Vue Router 会造成两套路由系统打架。navlist.js只是一个纯 JSON 数组:

export default [ { path: '/flow/editor', name: '流程编辑', icon: 'edit' }, { path: '/flow/history', name: '历史版本', icon: 'history' }, { path: '/flow/export', name: '导出配置', icon: 'export' } ];

App.vue里用v-for渲染导航栏,点击时this.$router.push(item.path)。这样做的好处是:
- 业务方可以无缝替换navlist.js为后端接口,fetch('/api/nav').then(res => this.navList = res)
- 不依赖 Vue Router 的 history 模式,部署到 Nginx 子路径(如/admin/flow/)时无需额外配置;
- 导航栏图标用的是iconfontassets/fonts/iconfont.css里定义了所有图标,比引入element-ui图标库省 80KB。

Mock 数据放在mock/目录,每个文件对应一个接口:
-mock/flow-list.js:返回流程列表,含id,name,updatedAt
-mock/flow-detail.js:返回指定流程的完整 nodes/edges 数据;
-mock/flow-save.js:模拟保存请求,返回success: true

所有 mock 接口通过webpack-dev-serverbefore钩子注入:

// config/dev.env.js devServer: { before(app) { app.use('/api/flow', require('../mock/flow-list')); app.use('/api/flow/:id', require('../mock/flow-detail')); } }

这样,前端调axios.get('/api/flow')就能拿到 mock 数据,上线时只需把axios.defaults.baseURL指向真实后端,零代码修改。

4.3 组件拆分逻辑:为什么NodePalette.vue不叫NodeSelector.vue

组件命名反映设计意图。NodePalette.vue(调色板)强调“可拖拽的素材集合”,而NodeSelector.vue(选择器)暗示“单选/多选操作”。我们刻意用Palette,是因为它承载了三个隐含契约:
-可扩展性:新增节点类型,只需在paletteItems数组里加一项,不用改任何逻辑;
-视觉一致性:所有图标尺寸、间距、hover 效果统一由.palette-itemCSS 类控制;
-交互语义:拖拽行为是 Palette 的固有属性,不是附加功能。

paletteItems定义在src/data/nodeTemplates.js

export default [ { id: 'start', label: '开始', shape: 'circle', style: { fill: '#52c418', stroke: '#13c2c2' }, width: 60, height: 60 }, { id: 'task', label: '任务', shape: 'rect', style: { fill: '#1890ff', stroke: '#40a9ff' }, width: 120, height: 60 }, { id: 'end', label: '结束', shape: 'circle', style: { fill: '#f5222d', stroke: '#fa541c' }, width: 60, height: 60 } ];

NodePalette.vuev-for渲染,每个项绑定@dragstart事件。这里有个关键技巧:dragstart事件里不能直接e.dataTransfer.setData(),因为 G6 的graph.add()需要完整 model 对象。所以我们用e.dataTransfer.setData('text/plain', item.id)存 ID,dragover时再从nodeTemplates里取真实配置。这样既满足 HTML5 拖拽规范,又保持数据纯净。

4.4 工具函数封装:yule.js里那个debounce为什么用setTimeout而不用requestIdleCallback

yule.js是项目里的“瑞士军刀”,里面封装了debouncethrottledeepCloneuuid等高频工具。其中debounce函数长这样:

export const debounce = (func, wait) => { let timeout; return function executedFunction() { const later = () => { clearTimeout(timeout); func(...arguments); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; };

有人会问:为什么不直接用requestIdleCallback?答案是兼容性。requestIdleCallback在 Safari 15 以下、所有 IE 版本都不支持,而我们的目标系统要求支持 IE11。setTimeout虽然不够“空闲时执行”,但在流程图场景里足够用——比如画布缩放时,我们用debounce(graph.zoom, 16)(16ms ≈ 60fps),保证每秒最多触发 60 次,既防抖又不丢帧。

另一个函数deepCloneForG6更有意思:

export const deepCloneForG6 = (obj) => { if (obj === null || typeof obj !== 'object') return obj; if (obj instanceof Date) return new Date(obj); if (obj instanceof Array) return obj.map(item => deepCloneForG6(item)); if (obj instanceof Object) { const cloned = {}; for (let key in obj) { if (key.startsWith('_') || key === 'canvas' || key === 'group') continue; cloned[key] = deepCloneForG6(obj[key]); } return cloned; } return obj; };

这个函数专门处理 G6 的 model 对象。G6 的node.model里藏着_cfg(配置对象)、_children(子节点数组)、canvas(画布引用)等不可序列化字段。直接JSON.parse(JSON.stringify())会报错,而这个手写深克隆,精准过滤掉所有以_开头的私有字段,保留业务需要的idxylabel等,是本地存储能跑通的关键。

5. 常见问题与避坑指南:那些 README 里不会写的实战经验

5.1 问题速查表

问题现象根本原因解决方案复现概率
拖拽节点时画布闪烁Vue 组件绑定了:x="node.x",G6 修改x触发 Vue 更新,Vue 又调graph.updateItem(),形成循环使用“伪拖拽”方案,所有拖拽操作只走 Store,View 层不绑定 G6 model 字段★★★★★
右键菜单弹出位置偏移 50pxe.clientX/clientY未转换为画布坐标,G6 的getPointByCanvas()未考虑graph.translate()偏移严格按“屏幕坐标→画布坐标→屏幕坐标”三步转换,菜单left/top用转换后值★★★★☆
localStorage 加载后连线消失graph.save()返回的对象包含canvas引用,JSON.stringify()报错deepCloneForG6()过滤_cfgcanvas等字段,再序列化★★★★☆
缩放后节点文字模糊Canvas 渲染时未根据devicePixelRatio调整像素比graph.init()时设置pixelRatio: window.devicePixelRatio || 1★★★☆☆
多个标签页同时编辑,数据互相覆盖localStorage是全局共享的,没有锁机制storage事件监听,当其他标签页存数据时,当前页主动 reload 或提示“检测到其他编辑”★★☆☆☆

5.2 实操心得:三个血泪教训

第一,永远不要在graph.on('click')里直接调graph.remove()
G6 的 click 事件会穿透到 canvas 底层,如果用户点在节点边缘,可能同时触发node:clickcanvas:click,导致节点被删两次。正确做法是:
- 只监听node:clickedge:click
- 在canvas:click里只做取消选中操作(graph.clearItemStates());
- 删除操作统一走右键菜单或工具栏按钮,由 Store 控制。

第二,G6 的group不要滥用
项目里mock/group-data.js有分组示例,但实际业务中,90% 的流程图不需要分组。G6 的 group 会改变坐标系原点,导致getPointByCanvas()计算失准。如果真要分组,必须在graph.group()后,手动调group.set('matrix', [1,0,0,1,0,0])重置变换矩阵,否则吸附逻辑全乱。

第三,本地存储的降级策略比加密更重要
有同事提议给 localStorage 数据加 AES 加密,防止敏感流程泄露。我否决了,因为:
- 流程图数据本身不含密码、密钥等敏感字段;
- 加密解密消耗 CPU,低端手机上加载慢 300ms;
- 真正的风险是“用户清缓存后流程丢失”,所以我们在App.vuemounted()里加了兜底:

mounted() { // 尝试从 localStorage 加载 const saved = loadFromStorage(); if (saved) { this.$store.dispatch('LOAD_FROM_STORAGE', saved); } else { // 降级:加载内置 demo 数据 this.$store.dispatch('LOAD_FROM_STORAGE', DEMO_DATA); } }

DEMO_DATA是一个简单的审批流,3 个节点+2 条边,确保用户第一次打开页面,永远能看到一个可操作的流程图,而不是一片空白。

5.3 后续扩展建议:如何对接后端、增加校验、支持 BPMN

这个方案定位是“轻量级流程编排”,所以没做复杂扩展。但如果你需要向上演进,这里有三条平滑路径:

路径一:对接后端保存
- 替换store/actions.js里的persistToStorage()axios.post('/api/flow/save', data)
- 在saveaction 里加 loading 状态,按钮置灰防重复提交;
- 后端返回version字段,前端存到 localStorage 作乐观锁,下次保存时带上if-match: version

路径二:增加业务校验
- 在ADD_EDGEmutation 里插入校验逻辑:
js ADD_EDGE(state, edge) { // 禁止自环 if (edge.source === edge.target) throw new Error('不能连接自身'); // 禁止重复边 if (state.edges.some(e => e.source === edge.source && e.target === edge.target)) { throw new Error('该连接已存在'); } state.edges.push(edge); }
- 校验失败时,store.dispatch('SHOW_ERROR', '连接不合法'),右键菜单里加“校验全部”按钮。

路径三:导出 BPMN 2.0
- 不需要重写渲染器,只需在exportBPMN()方法里,把nodes/edges映射成 BPMN XML 结构;
- 节点类型映射:start<bpmn:startEvent>task<bpmn:task>
- 边映射:edges数组转<bpmn:sequenceFlow>,用sourceRef/targetRef关联;
- 用xmlbuilder2库生成 XML,downloadBlob()触发下载。

最后分享一个小技巧:G6 的graph.downloadImage()默认导出 PNG,但很多业务需要 SVG。我们封装了exportSVG()函数,原理是:
1. 用graph.getCanvas().toDataURL('image/svg+xml')获取 SVG 字符串;
2. 替换<svg<svg xmlns="http://www.w3.org/2000/svg"
3. 用Blob创建下载链接。
实测 50 节点流程图,SVG 导出大小 120KB,缩放到 4K 屏幕依然锐利,比 PNG 方案节省 80% 流量。

我在实际项目里用这套方案,把流程图模块的交付周期从 3 周压到 3 天。不是因为它多高级,而是因为每一个交互细节、每一处工程配置、每一条避坑经验,都来自真实战场。你现在看到的代码,是删掉了 7 个废弃分支、重写了 4 次渲染逻辑、熬了 3 个通宵调试内存泄漏后,剩下的最精炼、最可靠的部分。它不承诺解决所有问题,但承诺:你照着抄,一定能跑起来;你改两行,一定能用上。

本文还有配套的精品资源,点击获取

简介:基于 Vue 2/3 技术栈,集成 AntV/G6 图可视化库,实现开箱即用的流程图交互能力。支持鼠标拖拽创建节点、自动吸附连线、点击绑定/断开边关系、右键弹出菜单执行删除等操作;画布支持实时重绘与缩放,所有图数据通过 JSON 序列化后存入 localStorage,刷新页面可完整恢复编辑状态。状态统一交由 Vuex 管理,不直接操作 DOM,便于后续接入后端接口或扩展校验逻辑。项目结构清晰:包含标准 Webpack 构建配置(区分 dev/prod)、模拟路由导航(navlist.js)、通用工具函数(utils.js、yule.js)、Mock 数据占位(mock 目录)、基础 UI 封装(App.vue)及模块化组件组织(components/router/store)。无复杂算法封装,聚焦 G6 渲染层与 Vue 响应式系统的协作细节,适合中后台系统快速嵌入轻量级流程编排功能,可直接 npm install 后运行调试,也可按需剥离节点编辑、存储或菜单模块单独复用。


本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/11 6:32:53

BilibiliDown终极指南:如何5分钟实现B站视频批量下载与高效管理

BilibiliDown终极指南&#xff1a;如何5分钟实现B站视频批量下载与高效管理 【免费下载链接】BilibiliDown (GUI-多平台支持) B站 哔哩哔哩 视频下载器。支持稍后再看、收藏夹、UP主视频批量下载|Bilibili Video Downloader &#x1f633; 项目地址: https://gitcode.com/gh_…

作者头像 李华
网站建设 2026/6/11 6:30:52

智慧树学习神器:3分钟搞定自动连播与倍速播放的终极指南

智慧树学习神器&#xff1a;3分钟搞定自动连播与倍速播放的终极指南 【免费下载链接】zhihuishu 智慧树刷课插件&#xff0c;自动播放下一集、1.5倍速度、无声 项目地址: https://gitcode.com/gh_mirrors/zh/zhihuishu 还在为智慧树平台的繁琐操作而烦恼吗&#xff1f;每…

作者头像 李华
网站建设 2026/6/11 6:28:36

5个项目管理难题,GanttProject如何帮你轻松搞定?

5个项目管理难题&#xff0c;GanttProject如何帮你轻松搞定&#xff1f; 【免费下载链接】ganttproject Official GanttProject repository. 项目地址: https://gitcode.com/gh_mirrors/ga/ganttproject 还在为复杂的项目管理而头疼吗&#xff1f;面对任务堆积、资源冲突…

作者头像 李华