简介:基于Vue与antv/g6实现的流程图在线绘制演示Demo,面向需要在前端项目中集成可视化流程编辑能力的中高级开发者。由于完整业务涉及保密,作者抽出基础流程,清晰演示流程图创建、节点链接、节点删除、右键菜单操作、画布重新渲染以及本地存储等核心交互,适合对g6生命周期与Vuex状态同步有初步了解后做二次扩展。压缩包共93个文件,以38个js脚本和34个vue组件为主体,辅以scss样式、json配置、字体图标及工程化配置文件,整体约189KB。目录结构涵盖src/api、components、router、store及build、config等模块,可对照研究功能模块拆分、路由配置与构建脚本写法。目前已有12821人浏览学习,社区关注度较高。读者可获得基于Vuex管理流程图数据的代码骨架,理解antv/g6图实例的创建、事件绑定与销毁时机,并掌握节点增删、菜单交互、数据持久化等常见需求的实现路径。
1. 在线绘制分析流程图:Vuex 管数据、G6 管画布,这套组合能省掉什么
做流程审批后台、巡检编排页面,或者任何"要画一张能算的图"的需求时,最头疼的往往不是画布渲染本身,而是图数据和画布显示两边要同步维护。Vuex 和 antv/g6 的组合,把这件事拆成两条清晰主线:G6 负责渲染、交互和视觉呈现,Vuex 负责存节点、边和画布状态。画布上每拖一下、每连一条线,本质上都在改 store 里的数据。
这套 Demo 的核心价值,是把"绘制"和"分析"打通了。图数据躺在 Vuex 里之后,拓扑排序、环检测、可达性分析就变成几个纯函数的事,算完再把结果映射回画布高亮,整套闭环非常干净。它不依赖重型业务流程引擎,也不引入额外的可视化框架。
适合想用最短路径快速交付一个可视化流程编辑器、又不想被重型业务库绑架的前端开发者。下面这份笔记按"数据模型 → 画布初始化 → 交互回写 → 分析落地 → 排坑"的顺序走,每一步都给可以直接复现的代码和参数说明,最后聊几个让 Demo 能拿去交付的收尾习惯。
2. 项目骨架与数据模型:先想清楚"图"在内存里长什么样
我见过不少人在需求还没理清时就直接开写 G6 初始化代码,结果图能画了,数据一多就乱。原因都一样:没先定义"图在内存里长什么样"。G6 对数据的要求其实很克制——节点数组里每个元素要有 id 和坐标,边数组里要有 source 和 target。你只要让 Vuex 里的数据结构长成这个形状,后面所有分析、持久化、回放功能都会顺手很多。
2.1 建工程与装依赖:最小可行的 Vite + Vue3 工程
npm create vite@latest flow-graph-demo -- --template vue cd flow-graph-demo npm install npm install vuex@4 @antv/g6 npm run dev第一行创建的是 Vue3 工程,所以 Vuex 必须装 4.x,vuex@3 是给 Vue2 用的,装错版本启动时直接报错。G6 我建议第一次跑先锁在 4.x 线上,原因后面细说,这里只要知道 5.x 的初始化方式、事件名、布局 API 都变了,网上能搜到的老案例大部分是 4.x 的写法,先照着 4.x 跑通再考虑升级。
装完后把src下的默认组件清理掉,留一个干净的入口。这个工程只需要两个核心目录:store放 Vuex,views放画布组件。
2.2 用 Vuex 承载图数据:节点、边、画布状态三分
// src/store/index.js import { createStore } from 'vuex' export default createStore({ state: { nodes: [], // [{ id, label, x, y, type }] edges: [], // [{ id, source, target }] canvas: { mode: 'edit', // edit | connect,切换交互模式 zoom: 1, selectedNodeId: null, selectedEdgeId: null } }, getters: { graphData: state => ({ nodes: state.nodes, edges: state.edges }), nodeCount: state => state.nodes.length, edgeCount: state => state.edges.length }, mutations: { ADD_NODE(state, node) { state.nodes.push(node) }, UPDATE_NODE_POSITION(state, { id, x, y }) { const target = state.nodes.find(n => n.id === id) if (target) { target.x = x target.y = y } }, REMOVE_NODE(state, id) { state.nodes = state.nodes.filter(n => n.id !== id) // 关联边必须一起清理,否则 G6 渲染会因找不到端点而抛错 state.edges = state.edges.filter(e => e.source !== id && e.target !== id) }, ADD_EDGE(state, edge) { state.edges.push(edge) }, REMOVE_EDGE(state, id) { state.edges = state.edges.filter(e => e.id !== id) }, SET_CANVAS_MODE(state, mode) { state.canvas.mode = mode }, SET_SELECTED(state, { nodeId, edgeId }) { state.canvas.selectedNodeId = nodeId || null state.canvas.selectedEdgeId = edgeId || null }, LOAD_GRAPH(state, { nodes, edges }) { state.nodes = nodes || [] state.edges = edges || [] } } })这里刻意把状态拆成三块:nodes和edges是分析功能的输入数据,canvas是界面状态。这样拆的好处是,后续写拓扑排序、可达性分析时,函数只管读nodes/edges,完全不碰界面状态,单元测试也好写。
REMOVE_NODE里连坐删除关联边,是我最早翻车的地方——删节点后画布上残留一条指向空气的边,G6 会直接在控制台报错,而且不是每次必现,属于"玄学"问题,后面统一排查。
2.3 从 Vuex 到 G6:数据怎么喂给画布
// src/views/FlowEditor.vue,setup 部分 import { useStore } from 'vuex' import G6 from '@antv/g6' const store = useStore() let graph = null // 这个标记是防止"事件 -> commit -> 同步 -> 事件"死循环的关键 let fromInteraction = false // 把 mutation 翻译成 G6 操作,这是整套联动的唯一出口 function syncMutationToGraph(mutation, state) { const { type, payload } = mutation if (type === 'ADD_NODE') { graph.addItem('node', { ...payload }) } else if (type === 'UPDATE_NODE_POSITION') { const item = graph.findById(payload.id) if (item) graph.updateItem(item, { x: payload.x, y: payload.y }) } else if (type === 'REMOVE_NODE') { graph.removeItem(payload) } else if (type === 'ADD_EDGE') { graph.addItem('edge', { ...payload }) } else if (type === 'REMOVE_EDGE') { graph.removeItem(payload) } } function initGraph() { // 先初始化 graph(代码见 3.1 小节),再订阅 store graph = new G6.Graph({ /* ... */ }) // 首次灌数据 const { nodes, edges } = store.state graph.data({ nodes, edges }) graph.render() // 订阅 Vuex 的 mutation,外部改数据时同步到画布 const unsubscribe = store.subscribe((mutation, state) => { if (fromInteraction) return syncMutationToGraph(mutation, state) }) // unsubscribe 记得存起来,组件销毁时要用 }数据流是单向的:Vuex 是数据源,G6 是视图层。所有外部行为(工具栏按钮、加载历史数据、后端推送)都通过 commit 改 store,再由subscribe统一翻译成addItem/updateItem/removeItem。fromInteraction标记在第三章详细讲,这里先记住它的存在。
为什么不直接watch(store.state.nodes)?watch只能感知到引用变化,push这种原地修改触发不了,除非搭配deep: true,但深度监听大图数据性能很糟。store.subscribe能拿到具体是哪个 mutation,同步逻辑可以写得很精确。
3. 画布交互与状态同步:拖拽、连边、编辑如何回写 Vuex
画布交互是另一条链路:用户操作 G6,G6 触发事件,事件回调里 commit 到 Vuex。两条链路在fromInteraction这个标记处汇合,避免 A 改 B、B 又改 A 的死循环。这条链路设计清楚了,整个 Demo 就稳了一大半。
3.1 初始化 G6 图实例:容器、行为模式与默认样式参数
graph = new G6.Graph({ container: containerRef.value, width: containerRef.value.clientWidth, height: containerRef.value.clientHeight, fitView: true, fitViewPadding: 20, animate: false, modes: { default: ['drag-canvas', 'zoom-canvas', 'drag-node'] }, defaultNode: { type: 'rect', size: [160, 48], style: { fill: '#FFFFFF', stroke: '#5B8FF9', lineWidth: 2, radius: 4 }, labelCfg: { style: { fill: '#333333', fontSize: 14 } } }, defaultEdge: { type: 'cubic-horizontal', style: { stroke: '#A3B1C6', lineWidth: 2, endArrow: true } } })| 参数 | 取值 | 作用 |
|---|---|---|
| container | 已挂载的 DOM | 必须等组件挂载完再初始化,否则拿不到宽高 |
| width / height | 容器实际像素值 | 不要写 100%,G6 认的是数字 |
| fitView | true | 数据量小时自动缩放到全图可见 |
| animate | false | 关闭布局动画,大数据量下能明显降掉帧 |
| modes.default | 三个行为 | 拖画布、滚轮缩放、拖节点,顺序按使用频率排 |
| defaultEdge.type | cubic-horizontal | 水平三次曲线,符合从左到右的流程阅读习惯 |
| endArrow | true | 有向边箭头,分析方向依赖它 |
container是最常见的翻车点:写在created里拿不到真实宽高,画布渲染出来是 0 或者一条线。必须在onMounted里初始化,而且容器最好给一个确定的高度,比如height: 600px,而不是完全靠父级撑开。
3.2 节点新增、删除与位置回写:事件回调到 store 的完整链路
// 双击画布空白处,添加一个节点 graph.on('canvas:dblclick', (evt) => { const node = { id: `node_${Date.now()}`, label: `节点${store.state.nodes.length + 1}`, x: evt.x, y: evt.y, type: 'rect' } fromInteraction = true store.commit('ADD_NODE', node) fromInteraction = false graph.addItem('node', node) }) // 拖拽结束,把最终坐标回写 Vuex graph.on('node:dragend', (evt) => { const model = evt.item.getModel() fromInteraction = true store.commit('UPDATE_NODE_POSITION', { id: model.id, x: model.x, y: model.y }) fromInteraction = false }) // 键盘删除选中的节点或边 window.addEventListener('keydown', (e) => { const { selectedNodeId, selectedEdgeId } = store.state.canvas if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNodeId) { const id = selectedNodeId fromInteraction = true store.commit('REMOVE_NODE', id) fromInteraction = false graph.removeItem(id) } else if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEdgeId) { const id = selectedEdgeId fromInteraction = true store.commit('REMOVE_EDGE', id) fromInteraction = false graph.removeItem(id) } })fromInteraction的用法是:Vuex 的 mutation 默认是同步执行的,所以commit之前置 true,commit返回后置 false,subscribe回调里读到 true 就直接跳过同步。这样既不会重复addItem,也不会让graph.updateItem触发新一轮事件。这条链路熟练之后,你可以在这个标记上再接一层防抖,拖拽高频回写时只在 dragend 提交一次就够了。
注意evt.x和model.x不是一回事。canvas:dblclick里的evt.x/y是画布坐标,可以直接当节点初始坐标用;而dragend里必须读item.getModel()拿拖完后的真实值,evt里传的不一定是最新的。
3.3 连边模式与边删除:点两点成一条有向边
let connectSourceId = null graph.on('node:click', (evt) => { const model = evt.item.getModel() // 普通模式:点击选中 if (store.state.canvas.mode !== 'connect') { graph.setAllItemStates?.([]) // 先清一次高亮 graph.setItemState(evt.item, 'selected', true) store.commit('SET_SELECTED', { nodeId: model.id, edgeId: null }) return } // 连线模式:第一次点击记源节点,第二次点击建边 if (!connectSourceId) { connectSourceId = model.id graph.setItemState(evt.item, 'selected', true) return } if (connectSourceId !== model.id) { const edge = { id: `edge_${Date.now()}`, source: connectSourceId, target: model.id } fromInteraction = true store.commit('ADD_EDGE', edge) fromInteraction = false graph.addItem('edge', edge) } connectSourceId = null // 连线结束后清空所有高亮,避免视觉残留 graph.findAllByState('node', 'selected').forEach(item => { graph.setItemState(item, 'selected', false) }) })连线模式通过store.state.canvas.mode切换,工具栏按钮 commit 一个SET_CANVAS_MODE就能进入"连线态"。代码里两个细节值得注意:一是connectSourceId !== model.id禁止自环,流程图里 A 到 A 的边在语义上基本都是误操作;二是ADD_EDGE之前没有查重,连续点同一对节点会生成多条边,实际交付时建议加一个edges.some(e => e.source === s && e.target === t)的防重判断。
setAllItemStates?.([])是清理全图高亮状态的一个便利写法,如果你的 G6 版本不支持就退回到findAllByState逐个清。边删除走edge:click事件选中,然后复用上面键盘删除的逻辑,链路和节点完全一致。
4. 分析功能落地:把"画的图"变成"能算的图"
流程图编辑器真正值钱的部分不在画布,在分析。这也是为什么前面坚持把数据放 Vuex——分析函数只需要读store.state.nodes和store.state.edges,不碰任何 G6 实例,既可以单元测试,也可以在后端复用同一套算法。
4.1 拓扑排序与环检测:Kahn 算法的纯函数实现
// src/utils/graph-analysis.js export function topoSort(nodes, edges) { const inDegree = {} const adjacency = {} nodes.forEach(n => { inDegree[n.id] = 0 adjacency[n.id] = [] }) edges.forEach(e => { if (!adjacency[e.source]) adjacency[e.source] = [] adjacency[e.source].push(e.target) inDegree[e.target] = (inDegree[e.target] || 0) + 1 }) const queue = nodes.filter(n => inDegree[n.id] === 0).map(n => n.id) const order = [] while (queue.length) { const id = queue.shift() order.push(id) ;(adjacency[id] || []).forEach(target => { inDegree[target]-- if (inDegree[target] === 0) queue.push(target) }) } return { hasCycle: order.length !== nodes.length, order } }算法逻辑是标准的 Kahn 算法:先统计每个节点的入度,入度为 0 的节点入队,逐个弹出并把它的后继节点入度减一,减到 0 再入队。order.length !== nodes.length这一步是判环的关键——如果图里有环,环上的节点永远无法入队,最终 order 长度一定小于节点总数。
在组件里调用它做结果展示:
import { topoSort } from '../utils/graph-analysis' function runTopoAnalysis() { const { hasCycle, order } = topoSort(store.state.nodes, store.state.edges) analysisResult.value = { hasCycle, order: order.map(id => { const node = store.state.nodes.find(n => n.id === id) return node ? node.label : id }) } }这个函数处理空图、单节点图、断开的多个子图都是安全的——空图返回hasCycle: false且 order 为空;多个不连通子图会按入度顺序正常排序。有了它,保存流程图之前可以先跑一遍,有环就直接拦截,避免把脏数据存进后端。
4.2 可达性分析:从某节点出发能影响哪些下游
export function reachableNodes(sourceId, nodes, edges) { const adjacency = {} nodes.forEach(n => { adjacency[n.id] = [] }) edges.forEach(e => { if (!adjacency[e.source]) adjacency[e.source] = [] adjacency[e.source].push(e.target) }) const visited = new Set() const queue = [sourceId] while (queue.length) { const id = queue.shift() if (visited.has(id)) continue visited.add(id) ;(adjacency[id] || []).forEach(t => queue.push(t)) } return [...visited] }这是最朴素的 BFS,从选中节点出发,沿有向边扩散,收集所有能走到的节点。典型的业务场景是"如果这个环节挂了,会影响哪些下游环节"。它和拓扑排序共用同一份邻接表构建逻辑,实际项目里可以把buildAdjacency抽成公共函数。
把分析结果反馈到画布,是这套架构最出效果的环节:
function highlightNodes(ids) { // 先清空所有节点的 selected 状态 graph.findAllByState('node', 'selected').forEach(item => { graph.setItemState(item, 'selected', false) }) // 再对目标节点逐个置高亮 ids.forEach(id => { const item = graph.findById(id) if (item) graph.setItemState(item, 'selected', true) }) }高亮前一定要先清空全图的 selected 状态,否则连续点两个不同的源节点,图上会残留上一轮的红色痕迹,用户会以为高亮逻辑有 bug。另外setItemState只是视觉状态,不会改节点数据,所以不需要 commit 回 Vuex,这也是画布层和数据的又一次解耦。
4.3 一键自动布局:接入 Dagre 层级布局
function applyAutoLayout() { if (!graph) return graph.updateLayout({ type: 'dagre', rankdir: 'LR', nodesep: 24, ranksep: 48, align: 'UL' }) }dagre是流程图场景最合适的层级布局算法,它会把节点按有向边的方向排成一层一层的。rankdir: 'LR'表示从左到右,nodesep是同一层里左右相邻节点的间距,ranksep是相邻两层之间的间距。这两个间距值决定了整张图的密度,节点多就往大了调,不然标签会挤在一起。
布局跑完节点坐标会被 G6 改写,必须写回 Vuex,否则一刷新又回弹。监听afterlayout事件:
graph.on('afterlayout', () => { fromInteraction = true graph.getNodes().forEach(item => { const m = item.getModel() store.commit('UPDATE_NODE_POSITION', { id: m.id, x: m.x, y: m.y }) }) fromInteraction = false })这里的fromInteraction置 true 是为了防止UPDATE_NODE_POSITION再触发subscribe里的updateItem,造成一次无意义的重复更新。布局把节点坐标改了,store 改的是数据快照,两者本来就是同一份内容,不需要再回写画布。
5. 避坑指南:Vuex 与 G6 联动的 5 个高频问题
这些坑是真实项目里踩出来的血泪经验,按出现频率排。每一条我都先给现象,再说原因,最后给解决方式,照着查比瞎猜快得多。
5.1 改了 store,画布无动于衷
现象:工具栏点"加载数据",Vuex 里 nodes 数组确实变了,画布还是老样子。
原因:只 commit 了数据,没人负责把变化同步给 G6。store.subscribe要么没注册,要么在graph初始化之前就注册了,回调执行时graph还是null,代码直接抛错被吞掉。
解决:subscribe必须放在initGraph函数的末尾,确保 graph 已经创建成功。回调开头加一个if (!graph) return的兜底,避免组件还没挂完就收到 mutation。另外确认syncMutationToGraph里每个分支都写了,特别是REMOVE_NODE和REMOVE_EDGE,漏一个就会出现"store 里没节点了、画布上还挂着"的诡异状态。
5.2 拖拽完一刷新,节点全部回弹
现象:拖拽布局很顺畅,一刷新页面节点全部回到初始位置,白拖了。
原因:node:dragend事件里没写UPDATE_NODE_POSITION,或者写了但用的是evt.x而不是model.x。evt里的坐标在拖拽结束瞬间可能不是最终值,只有item.getModel()才是画布上当前的真实坐标。
解决:统一在dragend里读evt.item.getModel(),提交{ id: model.id, x: model.x, y: model.y }。如果要持久化,保存时也统一从store.state.nodes取值,不要从画布上抓,两个源只信一个——我习惯只信 store。
5.3 把 graph 实例塞进 Vuex,页面直接卡死
现象:为了让多个组件都能拿到 graph,就把它放进 Vuex state,结果页面卡死,内存一路暴涨。
原因:Vuex 会把 state 里的对象变成响应式代理,G6 实例内部有大量 canvas 上下文、事件监听器、内部缓存对象,代理它们会触发无限递归。这是把响应式系统用在它不该用的地方。
解决:graph 实例永远不进 Vuex。放在组件局部变量里,或者单独建一个graph.js模块,用普通变量导出,需要用的组件直接 import。Vuex 里只放纯数据,也就是能被JSON.stringify序列化的东西。
5.4 节点一多就明显掉帧
现象:五六十个节点之后,拖动画布、连边都感觉黏手,缩放更是卡顿明显。
原因:最常见的两个来源——每次 commit 都触发render()全量重绘;布局动画默认开启,每次布局都有一段动画播放。还有一个隐蔽问题:syncMutationToGraph里用graph.addItem逐条添加,循环一百次就是一百次独立绘制。
解决:初始化时设animate: false;批量导入数据时用一次LOAD_GRAPH+graph.changeData(),不要循环ADD_NODE。拖拽位置回写只保留dragend一次,不要监听drag高频 commit。节点超过一百个还要进一步优化的话,考虑关闭节点标签、开canvas的delegate渲染模式。
5.5 组件销毁后控制台报错
现象:从编辑页切到列表页,控制台报一堆Cannot read property of undefined,页面虽然还能用,但很吓人。
原因:组件卸载了,G6 实例还在监听容器事件,而容器 DOM 已经被 Vue 移除,事件回调里拿到的是空引用。
解决:onBeforeUnmount里先调用store.subscribe返回的unsubscribe()解除订阅,再调用graph.destroy()销毁图实例。顺序不能反——先销毁图,subscribe 回调可能还在最后一次队列里执行,又去访问已销毁的 graph。这两个调用配对写好,路由切换就安静了。
6. 把 Demo 变成能交付的成果:持久化、导出与收尾习惯
6.1 localStorage 持久化与一键恢复
function saveToLocal() { const payload = { nodes: store.state.nodes, edges: store.state.edges, savedAt: Date.now() } localStorage.setItem('flow-graph-demo', JSON.stringify(payload)) } function loadFromLocal() { const raw = localStorage.getItem('flow-graph-demo') if (!raw) return try { const { nodes, edges } = JSON.parse(raw) store.commit('LOAD_GRAPH', { nodes, edges }) // changeData 会做新旧数据 diff,比 data()+render() 高效 graph.changeData({ nodes, edges }) graph.render() } catch (e) { // 本地数据损坏时给个提示即可,别让页面崩掉 } }保存和恢复的数据结构就是 Vuex 里那两棵数组,没有额外转换,这是当初把数据结构设计成 G6 原生格式的直接回报。changeData在 G6 4.x 里会比对新旧数据做增量更新,大批量加载时比先data()再render()省一次全量绘制。恢复后记得跑一次topoSort,把环检测结果吐出来,旧数据合不合法让用户一眼看到。
6.2 导出流程图图片
function exportImage() { // 第一个参数是下载文件名,第二个是图片格式 graph.downloadFullImage('flow-graph', 'image/png') }downloadFullImage会按当前画布内容生成图片并触发下载,比toDataURL再自己拼 Blob 省事得多。要带上分析结果时,我习惯先highlightNodes高亮关键路径,再导出,这样导出图自带分析结论,评审时不用口头解释。
这套 Demo 做下来,我最大的收尾习惯是:任何流程图功能,先写纯函数分析逻辑,再接画布。分析函数是最好单测的部分,画布代码只要保证"事件 → commit → subscribe"这条链路不断,整体就很少出幺蛾子。数据结构写死成{ nodes, edges }两个数组,所有功能都在这两个数组上做文章,后面接后端、接 mock、做历史版本对比都顺。希望帮到你。
本文还有配套的精品资源,点击获取