Vue 项目流程图实战:G6 从环境搭建到性能优化全指南
1. 为什么流程图这件事我最后选了 G6 而不是手写 SVG先说结论在 Vue 项目里做流程图能选现成图库就别自己画。我最早接手一个后台配置系统需求是让运营同学能拖拽节点、连线、保存成 JSON还要支持缩放和导出图片。当时第一反应是不就是画几个矩形加箭头吗于是用 SVG 手写了一套。结果两周后需求变成节点要能折叠、连线要能自动避让、要支持撤销重做我当场就想把键盘砸了。手写 SVG 的问题不在于画不出来而在于状态管理会失控。每个节点的坐标、每条线的路径、拖拽时的临时状态、缩放后的坐标换算这些东西一旦超过二十个节点维护成本是指数级上升的。后来我换成了 G6同样的需求大概三天就跑通了核心链路。G6 是阿里开源的一款图可视化引擎专门处理关系图、流程图、拓扑图这类场景。它把节点、边、锚点、交互行为都抽象成了可配置的模型你只需要描述有哪些节点、它们怎么连剩下的渲染、布局、交互它帮你兜底。在 Vue 里用它核心要解决的是生命周期对齐和数据响应这两件事这也是后面几节要重点聊的。这篇文章适合两类人一类是刚接触图可视化、想知道 G6 到底能干什么的 Vue 开发者另一类是已经用过 G6 但被数据更新不生效容器尺寸拿不到节点拖拽后位置对不上这些问题折磨过的同学。我会把踩过的坑和验证过的方案都摊开讲代码可以直接抄。2. 环境搭建版本选择与容器初始化的那些细节2.1 G6 版本差异带来的 API 断层G6 目前主流是 4.x 和 5.x 两个大版本这俩的 API 差异大到什么程度呢——4.x 里注册自定义节点用G6.registerNode5.x 改成了G6.register而且配置项结构也变了。我建议新项目直接上 5.x老项目如果已经跑在 4.x 上就别折腾迁移除非你有充足的重构时间。安装本身没什么难度npm install antv/g6 --save但这里有个坑G6 依赖的渲染层在不同版本下不一样4.x 默认用 Canvas5.x 支持 Canvas 和 SVG 两种渲染器。如果你的流程图节点数量超过 500 个建议用 Canvas如果节点少但样式复杂比如带渐变、阴影、富文本SVG 渲染出来的清晰度更好。切换方式是在初始化时传renderer参数。2.2 Vue 组件里容器的尺寸陷阱这是新手最容易翻车的地方。G6 初始化时必须知道容器的宽高但 Vue 的mounted钩子里如果容器是通过flex或者百分比布局撑开的clientWidth很可能拿到 0。我见过太多人在这里卡半天以为是 G6 的问题其实是 DOM 还没完成布局计算。我的做法是给容器一个明确的初始尺寸或者用ResizeObserver监听尺寸变化后再初始化// Vue 3 Composition API 写法 import { onMounted, ref, onBeforeUnmount } from vue import G6 from antv/g6 const containerRef ref(null) let graph null let resizeObserver null onMounted(() { const container containerRef.value // 关键先确保容器有尺寸 if (!container.clientWidth || !container.clientHeight) { console.warn(容器尺寸为 0请检查布局) return } graph new G6.Graph({ container: container, width: container.clientWidth, height: container.clientHeight, fitView: true, modes: { default: [drag-canvas, zoom-canvas, drag-node] } }) // 监听容器尺寸变化同步给 G6 resizeObserver new ResizeObserver(() { if (!graph || graph.get(destroyed)) return graph.changeSize(container.clientWidth, container.clientHeight) }) resizeObserver.observe(container) }) onBeforeUnmount(() { // 必须销毁否则内存泄漏 if (resizeObserver) resizeObserver.disconnect() if (graph !graph.get(destroyed)) graph.destroy() })注意graph.destroy()一定要调用而且要在组件卸载前调用。我遇到过页面切换十几次后浏览器卡死的情况排查下来就是 G6 实例没销毁Canvas 一直在后台跑。2.3 数据格式节点和边的标准结构G6 的数据模型很直白一个nodes数组加一个edges数组const data { nodes: [ { id: node1, x: 100, y: 200, label: 开始 }, { id: node2, x: 300, y: 200, label: 审批 } ], edges: [ { source: node1, target: node2, label: 提交 } ] }id是唯一标识source和target指向节点的id。这里有个经验id 不要用数组下标因为删除节点后下标会错位导致连线指向错误的节点。用业务主键或者 UUID 都行。3. 从零跑通第一个流程图节点、连线与布局3.1 内置节点类型够不够用G6 内置了circle、rect、ellipse、diamond、triangle等基础图形还有modelRect这种带标题和描述的复合节点。大部分流程图场景rect加个label就能满足。但如果你要做审批流那种标题状态负责人三行信息的节点内置的就不够看了得自定义。先看一个用内置节点跑通的最小示例graph.data({ nodes: [ { id: start, x: 150, y: 100, label: 发起申请, type: circle }, { id: check, x: 150, y: 250, label: 主管审批, type: rect }, { id: end, x: 150, y: 400, label: 完成, type: circle } ], edges: [ { source: start, target: check }, { source: check, target: end } ] }) graph.render()跑起来你会发现节点挤在一起连线是直的整体很丑。这时候需要配置布局和边的样式。3.2 布局算法怎么选Dagre 还是 Force流程图最常用的是dagre布局它专门处理有向无环图会自动分层、对齐、避免连线交叉。配置方式const graph new G6.Graph({ // ...其他配置 layout: { type: dagre, rankdir: TB, // 从上到下LR 是从左到右 nodesep: 40, // 同层节点间距 ranksep: 60, // 层间距 controlPoints: true // 让连线带控制点更平滑 } })rankdir这个参数很关键。审批流一般用TBtop to bottom数据流向图用LRleft to right。我试过在节点很多的时候用LR横向会拉得很长用户得一直拖画布体验不好。force布局是力导向的适合展示网状关系但流程图别用因为它每次渲染位置都不一样用户会懵。3.3 边的样式让连线不再像蜘蛛网默认的直线边在节点错位时很难看我一般会改成折线或者带箭头的曲线const defaultEdge { type: polyline, // 折线也可以用 quadratic 曲线 style: { stroke: #b0b0b0, lineWidth: 1.5, endArrow: { path: G6.Arrow.triangle(8, 8, 6), fill: #b0b0b0 } }, labelCfg: { autoRotate: true, style: { fontSize: 12, fill: #666 } } }polyline会自动根据节点位置计算折线路径比直线规整很多。endArrow是箭头配置G6.Arrow.triangle的三个参数分别是宽度、长度和偏移量调一调就能找到顺眼的效果。4. 自定义节点把业务信息塞进方框里4.1 什么时候必须自定义内置节点只能显示一个label但真实业务里节点往往要展示多个字段。比如一个任务节点要显示任务名、负责人、截止时间、状态标签。这时候就得自定义节点用G6.registerNode注册一个带多个文本和图形的节点类型。4.2 用 rect 组合出多行信息节点下面这个例子注册了一个task-node包含标题、负责人、状态三个区域G6.registerNode(task-node, { draw(cfg, group) { const width 180 const height 70 const keyShape group.addShape(rect, { attrs: { x: 0, y: 0, width, height, radius: 6, fill: #fff, stroke: cfg.status done ? #52c41a : #d9d9d9, lineWidth: 2 }, name: task-box }) // 标题 group.addShape(text, { attrs: { x: 12, y: 24, text: cfg.label || 未命名任务, fontSize: 14, fontWeight: 500, fill: #262626 }, name: title }) // 负责人 group.addShape(text, { attrs: { x: 12, y: 48, text: 负责人${cfg.owner || 待分配}, fontSize: 12, fill: #8c8c8c }, name: owner }) // 状态标签 group.addShape(rect, { attrs: { x: width - 60, y: 12, width: 48, height: 20, radius: 10, fill: cfg.status done ? #f6ffed : #fff7e6 }, name: status-bg }) group.addShape(text, { attrs: { x: width - 36, y: 26, text: cfg.status done ? 已完成 : 进行中, fontSize: 11, textAlign: center, fill: cfg.status done ? #52c41a : #fa8c16 }, name: status-text }) return keyShape }, // 定义锚点位置决定连线从哪进出 getAnchorPoints() { return [ [0.5, 0], // 上 [1, 0.5], // 右 [0.5, 1], // 下 [0, 0.5] // 左 ] } })这里有几个细节值得说。keyShape是必须返回的它决定了节点的点击热区和包围盒。getAnchorPoints定义了四个锚点连线会自动吸附到最近的锚点。如果不定义G6 会用默认的中心点连线会穿过节点很难看。4.3 状态驱动的样式更新节点状态变化时比如从进行中变成已完成不需要重新渲染整个图调用graph.updateItem就行graph.updateItem(node1, { status: done })G6 会重新执行draw方法但只更新这个节点。不过要注意如果你的draw里用了cfg.xxx判断样式更新后必须保证cfg里有这个字段否则会报错。5. 交互实战拖拽、连线、右键菜单5.1 拖拽节点的边界控制drag-node模式默认允许节点拖到画布外用户一不小心就把节点拖没了。我一般会加个边界限制modes: { default: [ drag-canvas, zoom-canvas, { type: drag-node, enableDelegate: false, // 拖拽结束后触发 afterDrag(e) { const node e.item const model node.getModel() const canvasWidth graph.get(width) const canvasHeight graph.get(height) // 限制在画布内 const x Math.max(0, Math.min(model.x, canvasWidth)) const y Math.max(0, Math.min(model.y, canvasHeight)) graph.updateItem(node, { x, y }) } } ] }afterDrag里拿到的model.x和model.y是节点中心坐标所以严格来说边界应该是[节点宽/2, 画布宽 - 节点宽/2]我这里简化了实际项目里要按节点尺寸算。5.2 手动连线从锚点拖出边G6 提供了create-edge交互但默认行为是点击两个节点就连线不够直观。更符合用户习惯的是从锚点拖拽出边。这需要监听mouseenter和mouseleave事件动态显示锚点graph.on(node:mouseenter, (e) { const node e.item const group node.getContainer() // 显示锚点小圆点 const anchors node.getAnchorPoints() anchors.forEach((anchor, index) { group.addShape(circle, { attrs: { x: anchor.x, y: anchor.y, r: 4, fill: #1890ff, stroke: #fff, lineWidth: 2, cursor: crosshair }, name: anchor-${index}, // 标记为锚点方便后续识别 isAnchor: true }) }) }) graph.on(node:mouseleave, (e) { const group e.item.getContainer() // 移除所有锚点 group.get(children).forEach(shape { if (shape.get(isAnchor)) shape.remove() }) })然后在锚点上监听mousedown进入连线模式mousemove时画一条临时线mouseup在目标节点上时创建边。这套逻辑代码量不小但用户体验最好。如果项目时间紧直接用create-edge也能凑合。5.3 右键菜单与节点操作流程图里删除节点、重命名、复制是高频操作右键菜单比工具栏按钮更顺手。G6 本身不提供菜单组件需要自己用 DOM 实现graph.on(node:contextmenu, (e) { e.preventDefault() const { item, canvasX, canvasY } e // 把画布坐标转成屏幕坐标 const point graph.getCanvasByPoint(canvasX, canvasY) showContextMenu({ x: point.x, y: point.y, items: [ { label: 重命名, action: () renameNode(item) }, { label: 复制, action: () copyNode(item) }, { label: 删除, action: () graph.removeItem(item) } ] }) })菜单本身用一个绝对定位的div渲染点击其他地方时隐藏。这里要注意contextmenu事件必须preventDefault否则浏览器默认菜单会弹出来。6. 数据同步Vue 响应式和 G6 的配合方式6.1 为什么不能直接把响应式数据丢给 G6很多人第一反应是graph.data(this.nodeList)然后期望nodeList变化时图自动更新。G6 不是响应式的它只在调用graph.data()和graph.render()时读取一次数据。后续数据变了图不会动。正确做法是监听数据变化手动调用 G6 的更新方法import { watch } from vue watch(nodeList, (newVal) { if (!graph) return // 方式一全量更新简单但性能差 graph.changeData({ nodes: newVal, edges: edgeList.value }) // 方式二增量更新性能好但逻辑复杂 // 对比新旧数据找出新增、删除、修改的节点 }, { deep: true })changeData会重新计算布局节点位置会重置。如果用户手动拖过节点位置就丢了。所以更稳妥的做法是增量更新function syncNodes(newNodes) { const currentNodes graph.getNodes().map(n n.getModel()) const currentIds new Set(currentNodes.map(n n.id)) const newIds new Set(newNodes.map(n n.id)) // 删除 currentIds.forEach(id { if (!newIds.has(id)) graph.removeItem(id) }) // 新增和更新 newNodes.forEach(node { if (currentIds.has(node.id)) { graph.updateItem(node.id, node) } else { graph.addItem(node, node) } }) }6.2 保存与回显JSON 序列化的坑保存流程图时graph.save()返回的数据包含节点的x、y、style等运行时信息直接存数据库会很臃肿。我一般只存业务字段function serialize() { const nodes graph.getNodes().map(node { const model node.getModel() return { id: model.id, label: model.label, owner: model.owner, status: model.status, x: model.x, y: model.y } }) const edges graph.getEdges().map(edge { const model edge.getModel() return { source: model.source, target: model.target, label: model.label } }) return { nodes, edges } }回显时把这些数据塞回graph.data()再render()就行。但要注意如果保存时用了dagre布局回显时节点位置是布局算出来的和保存的x、y可能不一致。解决办法是回显时关掉布局直接用保存的坐标graph.updateLayout({ type: preset })preset布局就是用数据里的坐标不做任何计算。7. 性能优化节点上百之后怎么不卡7.1 关闭不必要的交互和动画节点超过 200 个后drag-canvas和zoom-canvas会明显掉帧。这时候可以按需开启const graph new G6.Graph({ // ... modes: { default: nodeCount 200 ? [drag-canvas] // 只保留拖拽关掉缩放 : [drag-canvas, zoom-canvas, drag-node] }, animate: false, // 关掉动画 animateCfg: { duration: 0 } })animate: false能省不少性能代价是节点更新时没有过渡效果。数据量大时这个取舍是值得的。7.2 视口裁剪与虚拟渲染G6 4.x 之后支持viewport裁剪只渲染可视区域内的节点const graph new G6.Graph({ // ... minZoom: 0.2, maxZoom: 4, // 开启视口裁剪 enabledOptimize: true })不过这个特性在不同版本里名字不一样5.x 里改成了optimize配置。用之前建议查一下当前版本的文档别照搬网上的配置。7.3 用 Canvas 替代 SVG前面提过节点多的时候用 Canvas 渲染。切换方式const graph new G6.Graph({ renderer: canvas, // 默认就是 canvas显式写出来更清楚 // ... })Canvas 的缺点是导出图片时清晰度不如 SVG但 G6 提供了toDataURL方法可以指定倍率const dataURL graph.toDataURL({ pixelRatio: 2, // 2 倍图导出后更清晰 backgroundColor: #fff })8. 几个让我加班到深夜的坑8.1 节点拖拽后连线不跟随这个问题通常是因为拖拽时只更新了节点的x、y但边的路径没有重算。G6 默认会自动重算但如果你自定义了边的draw方法就得手动处理。我的建议是不要轻易重写边的 draw用style配置能解决 90% 的样式需求。8.2 容器隐藏后重新显示图变形Tab 切换或者弹窗关闭再打开时容器尺寸从 0 变回正常值但 G6 还记着旧尺寸。解决办法是在容器显示后调用graph.changeSize(container.clientWidth, container.clientHeight) graph.fitView()fitView会自动缩放让所有节点可见配合changeSize一起用效果最好。8.3 自定义节点里的文本换行G6 的text图形不支持自动换行长文本会溢出。需要自己计算换行位置function wrapText(text, maxWidth, fontSize) { const lines [] let current for (const char of text) { const test current char // 粗略估算中文按 fontSize 算英文按 0.6 倍 const width test.length * fontSize * 0.6 if (width maxWidth) { lines.push(current) current char } else { current test } } if (current) lines.push(current) return lines }然后逐行addShape(text)每行 y 坐标递增fontSize * 1.4。这个估算方法不精确但够用。要精确的话得用canvas.measureText性能开销大。8.4 事件冒泡导致误触节点上的点击事件会冒泡到画布如果你同时监听了node:click和canvas:click点节点时两个都会触发。解决办法是在节点事件里e.stopPropagation()或者用e.target判断事件源。9. 写在最后的一点个人习惯我现在做 G6 项目会先花半小时把数据模型定死——节点有哪些字段、边有哪些字段、状态怎么流转。这个模型定好了后面的自定义节点、交互、保存回显都是围绕它转不会乱。反过来如果一开始就急着画图写到一半发现字段不够用改起来就是牵一发动全身。另外G6 的官方示例库是个宝库但别直接复制粘贴。它的示例为了展示效果往往把配置写得很全实际项目里很多配置是冗余的。我的做法是先把示例跑起来然后一条条删配置删到图出问题为止这样能快速搞清楚每个配置到底管什么。还有一个习惯每次升级 G6 版本前先在测试环境跑一遍所有交互。G6 的小版本升级偶尔会有 breaking change尤其是自定义节点那块。我吃过一次亏升级后锚点位置全偏了排查了两小时才发现是getAnchorPoints的返回值格式变了。