拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于Vuex和antv/g6构建流程图分析工具:从数据模型到画布交互

简介面向需要在 Vue 项目中基于 antv/g6 实现在线绘制分析流程图的开发者这份 Demo 从真实业务中解耦出基础流程覆盖创建、连线、节点删除、菜单操作、重新渲染与本地存储等交互适合已有 Vue 基础并想快速集成图编辑能力的读者。压缩包共 93 个文件以 38 个 js 脚本和 34 个 vue 组件为主另含少量 scss、json、字体与图片等静态资源整体仅 189KB目录同时保留 api、store、utils、components 等模块结构简洁方便按模块查阅。由于原业务算法与保密逻辑未包含在内读者可安全参考其 Vuex 状态管理、G6 图渲染与事件绑定方式也可把它作为二次开发的基础骨架或直接在其基础上补充业务算法与定制交互。目前已有 12812 人学习适合需要快速上手流程编辑器的前端开发者。 直接开工。这个项目一看就是接手过可视化编辑器的老哥都会心一笑的组合Vuex管状态antv/g6管渲染再套一个“分析”的壳。但真落地的时候细节远比想象中多。我这次把整个Demo从数据模型到画布交互再到分析功能的完整思路和踩坑过程都捋一遍基本上照着这个路子你也能搭出一个能用的流程图分析工具。1. 先搞清楚这个Demo要解决什么问题1.1 为什么不是“画个图”那么简单很多人一听到“在线绘制分析流程图”第一反应是“不就是拖几个框、连几条线嘛”。但你要是真拿g6去拖节点、连线、保存数据、做高亮、模拟状态流转从头走一遍就会发现这中间的坑集中在两个地方数据从哪来、数据往哪去。这个Demo要解决的核心问题有三个流程节点的可视化编辑包括新增、删除、移动、连线这些操作必须实时反映到界面上。分析功能的表现形态不是拿g6画个静态图而是要让流程图具备“分析能力”比如关键路径高亮、节点执行状态标记、分支走向说明。状态的可控性编辑过程中所有数据变化需要被集中管理这样后续做撤销、重做、路径计算、保存草稿都方便。换句话说这个Demo的本质是“一个带状态管理能力的轻量级流程分析设计器”而不是单纯画板。搞清楚这一点后面的技术选型和数据模型设计才有一个清晰的方向。1.2 技术选型为什么是Vuex antv/g6选Vuex不是说每个项目都一定要上状态管理。而是流程图这种场景天然就是一个“全局状态”密集的地方节点列表、边列表、当前选中项、画布缩放、路径计算结果这些数据往往被多个不相关的组件共享。比如侧边栏要显示选中节点的属性画布要高亮这个节点顶部工具栏要读取当前步骤状态如果靠props一层层传后面维护起来特别痛苦。选antv/g6是因为它本身在图形可视化场景里生态成熟节点/边/布局/交互这套东西开箱即用。尤其g6内置的dagre布局对流程图的层级展示非常友好。而且g6的事件机制很完善node:click、edge:click、canvas:click这些都能直接监听。两者的分工是这样的画布和节点的绘制是g6的事它管“看到什么”。数据的增删改查、选中态、分析结果是Vuex的事它管“数据是什么”。g6和Vuex之间靠事件和action进行通信不直接互相调来调去。这样的好处是如果哪天你想换成ECharts或D3做可视化只需要把g6那一层替换掉数据层根本不需要动。这也是我为什么强烈建议在动手写代码之前先把这条边界画清楚。2. Vuex状态管理流程图数据模型怎么设计2.1 状态数据的划分与建模先说结论我的store划分是这样的const state { nodes: [], // 节点列表 edges: [], // 连线列表 selectedNode: null, // 当前选中的节点 selectedEdge: null, // 当前选中的边 graphInstance: null, // g6实例 currentPath: [], // 分析得到的关键路径节点id列表 execStatus: {} // 节点执行状态id - running | success | error }很多人会纠结graphInstance要不要放进Vuex。我的建议是放。因为你在组件里可能需要在任何地方拿到这个实例去操作画布比如点击工具栏的“放大”按钮、点击侧边栏的某个节点让画布聚焦。如果不用Vuex存你只能在每个组件里自己引一个单例或者靠事件总线那还不如放store里统一拿。nodes和edges的数据结构我参考了g6的model定义但做了一层映射。因为g6的节点有一个id字段而v-model绑定时大家更喜欢用自增id或者其他业务id。我干脆让节点对象长这样{ id: node_1, label: 用户登录, type: start, // start | process | decision | end x: 100, y: 200, status: default, // 业务状态 properties: { // 自定义业务属性 description: 校验用户名密码, duration: 30 } }edges则是{ id: edge_1, source: node_1, target: node_2, label: 验证通过 }这里有一个容易忽略的点g6渲染时还需要节点的x、y坐标但你编辑时也会修改坐标。所以每次更新节点都需要把g6内部的model和Vuex里的数据保持同步否则刷新页面或重新读取数据时布局就会错乱。2.2 用dispatch串起整个交互链路Vuex里的actions是整个交互的中枢。我在Demo里封装了这么几个典型的action// 新增节点 addNode({ state }, payload) { const { type, x, y } payload const id node_ Date.now() const node { id, label: getDefaultLabel(type), type, x, y } state.nodes.push(node) Commit(ADD_NODE, node) }, // 新增连线 addEdge({ state }, { source, target }) { const edge { id: edge_ source _ target, source, target, label: } Commit(ADD_EDGE, edge) }, // 更新节点位置 updateNodePosition({ state }, { id, x, y }) { const node state.nodes.find(n n.id id) if (node) { node.x x node.y y Commit(UPDATE_NODE, node) } }可能有的朋友会问为什么我直接在action里改了state还要再Commit一次因为Vuex的mutation是同步的方便调试工具追踪。但g6在move节点时触发的频率太高如果每次都是严格mutationdevtools里会被刷爆。所以我在实际项目中的做法是高频的位置变化不commit只在dragend的时候commit一次。这也是一个值得注意的工程取舍状态管理追求可追踪但也要给高频交互留一条“非严格追踪”的路。Commit对应mutation的部分很简单就是改数据const mutations { ADD_NODE(state, node) { state.nodes.push(node) }, ADD_EDGE(state, edge) { state.edges.push(edge) }, UPDATE_NODE(state, node) { const index state.nodes.findIndex(n n.id node.id) if (index -1) { state.nodes.splice(index, 1, node) } } }这段看似平平无奇但它是后面所有分析功能的基础。因为只要数据是可靠的关键路径计算、状态模拟就只是遍历的问题而已。3. antv/g6画布层的接入与交互实现3.1 画布初始化与图形区分g6的初始化代码变化不大但有几点要注意。我的画布容器长这样div refcanvasRef classcanvas-wrapper/div初始化时我会在mounted里做this.graph new G6.Graph({ container: this.$refs.canvasRef, width: this.canvasWidth, height: this.canvasHeight, modes: { default: [drag-canvas, zoom-canvas, drag-node, click-select] }, defaultNode: { type: rect, size: [160, 50], anchorPoints: [[0.5, 0], [0.5, 1]], style: { radius: 6, fill: #ffffff, stroke: #8898aa, lineWidth: 1 }, labelCfg: { style: { fill: #333, fontSize: 14 } } }, layout: { type: dagre, rankdir: LR, nodesep: 60, ranksep: 80 }, fitView: true, fitViewPadding: [20, 20, 20, 20] })layout这里要单独说。Demo里我用了dagre因为流程图天然是有向无环图dagre能自动把层级排出来。但当你允许用户手动拖动节点后自动布局和手动位置会产生冲突。我的处理方式是初始加载数据的时候跑一次dagre布局之后用户拖动的坐标优先级最高不再强制布局。另外g6默认的矩形节点样式虽然能用但区分不了“开始”“判断”“结束”。我的做法是用自定义节点靠type字段区分。简单的方式是注册三个不同的节点类型G6.registerNode(process-node, { draw(cfg, group) { const rect group.addShape(rect, { attrs: { x: -80, y: -25, width: 160, height: 50, radius: 6, fill: cfg.type start ? #e6fffb : #ffffff, stroke: cfg.type decision ? #fa8c16 : #1890ff } }) group.addShape(text, { attrs: { text: cfg.label || , x: 0, y: 6, textAlign: center, fontSize: 14, fill: #333 } }) return rect } })这里我只是给不同type的节点用了不同颜色和圆角。你完全可以根据业务需要把decision节点画成菱形把start节点画成圆角胶囊形。关键在于节点的数据模型里有type字段自定义节点根据type渲染不同shape后面做形状联想也会更容易。3.2 节点、边的增删改操作增删节点我是在画布双击事件里触发的this.graph.on(canvas:click, evt { // 点击空白处取消选中 Commit(SET_SELECTED_NODE, null) Commit(SET_SELECTED_EDGE, null) this.graph.setCurrentMode(default) }) this.graph.on(canvas:dblclick, evt { const { x, y } this.graph.getPointByClient(evt.clientX, evt.clientY) this.$store.dispatch(addNode, { type: process, x, y }) }) this.graph.on(node:click, evt { const model evt.item.getModel() Commit(SET_SELECTED_NODE, model) this.$store.dispatch(syncGraphToStore) })需要特别提醒的是坐标转换。很多人直接拿evt.clientX和evt.clientY去存节点位置结果发现g6的图在画布中间节点却跑到左上角。因为clientX是浏览器坐标系而g6内部用的是画布坐标系中间隔着缩放比例和画布偏移。正确做法是用getPointByClient来转换。边的创建我用了g6的combo模式在modes里加了一个自定义交互// 内置的drag-node不能直接连边需要重写 modes: { default: [drag-canvas, zoom-canvas, click-select], addEdge: [drag-node, edge-create] }“edge-create”这个自定义模式的核心逻辑是监听node:dragstart记录起始节点拖动时显示一个临时边node:dragend时判断目标节点然后dispatch到store里addEdge。但这里有个我踩过的坑mode切到addEdge之后node:dragend如果不做目标节点判断用户只是拖动节点位置也会误创建边。所以必须判断拖拽终点是否落在另一个节点上且不能是自己连自己同时判断是否已存在相同source和target的边。删除操作相对简单。选中节点或边后键盘Delete键触发window.addEventListener(keydown, evt { if (evt.key Delete || evt.key Backspace) { const selectedNode Store.state.selectedNode if (selectedNode) { Store.dispatch(removeNode, selectedNode.id) } const selectedEdge Store.state.selectedEdge if (selectedEdge) { Store.dispatch(removeEdge, selectedEdge.id) } } })removeNode的action里要注意不仅要删节点还要把所有以该节点为source或target的边一起删掉。不然后续做路径计算时会因为边引用了不存在的节点而报错。4. “分析”功能是怎么落到实处的4.1 关键路径高亮流程图的“分析”最常用的一个功能就是高亮关键路径。比如用户点了一个起始节点希望看到从它出发到某个终止节点的完整链路。这个在数据层面其实就是图的遍历。我的做法是深度优先搜索加一个简单的路径收集// Vuex action findPath({ state }, { startId, endId }) { const graph buildGraph(state.nodes, state.edges) const visited {} const path [] const result [] function dfs(nodeId) { if (nodeId endId) { result.push([...path, nodeId]) return } visited[nodeId] true path.push(nodeId) const neighbors graph[nodeId] || [] for (const neighbor of neighbors) { if (!visited[neighbor]) { dfs(neighbor) } } path.pop() visited[nodeId] false } dfs(startId) return result }拿到路径后把这些节点和边在g6里高亮其他部分置灰。具体效果就是function applyPathHighlight(pathNodeIds, pathEdgeIds) { const nodes graph.getNodes() nodes.forEach(node { const id node.getModel().id if (pathNodeIds.includes(id)) { graph.setItemState(node, highLight, true) } else { graph.setItemState(node, dim, true) } }) const edges graph.getEdges() edges.forEach(edge { const model edge.getModel() if (pathEdgeIds.includes(${model.source}-${model.target})) { graph.setItemState(edge, highLight, true) } else { graph.setItemState(edge, dim, true) } }) }这里的关键是g6的itemState。我在注册节点/边的时候预先定义了状态对应的样式G6.registerNode(process-node, { // draw方法略 getStateStyle(name, value, item) { const style {} if (name highLight) { style.stroke #ff4d4f style.lineWidth 2 } if (name dim) { style.opacity 0.3 } return style } })这样一来“分析”就不是一句空话而是真正能帮使用者梳理流程链路的功能模块。4.2 执行状态模拟与分支分析除了高亮路径我还做了执行状态模拟。简单说就是点击“开始执行”让流程从“开始”节点依次进入running、success状态遇到decision节点时自动按条件走向其中一个分支。执行状态的模拟可以拆成两个部分。第一部分是遍历节点的顺序计算用拓扑排序即可。第二部分是在前端模拟时间差让节点按顺序变色。我用了async/await加sleepasync function simulateExecution({ state, commit }) { const order topoSort(state.nodes, state.edges) // 拓扑排序 for (const nodeId of order) { Commit(SET_EXEC_STATUS, { id: nodeId, status: running }) await sleep(500) // 模拟处理时间 if (state.nodes.find(n n.id nodeId).type decision) { // 决策节点根据业务条件决定走向这里简单取第一个后继 const nextEdge state.edges.find(e e.source nodeId) if (nextEdge) { Commit(SET_EXEC_STATUS, { id: nodeId, status: success }) } } else { Commit(SET_EXEC_STATUS, { id: nodeId, status: success }) } } }在g6里状态变更后要刷新节点的样式。我是监听Vuex里execStatus的变化然后调用graph.updateItemwatch: { execStatus: { handler(newVal) { Object.keys(newVal).forEach(id { const item this.graph.findById(id) if (item) { const status newVal[id] this.graph.setItemState(item, status, status) } }) }, deep: true } }同时给节点注册getStateStyle针对status状态返回对应的颜色if (name status) { if (value running) { style.fill #fff7e6 style.stroke #fa8c16 } else if (value success) { style.fill #f6ffed style.stroke #52c41a } }执行状态模拟对这个Demo的意义在于让用户能直观看到“分析流程”不是只画个静态图而是能推演流程的执行顺序。这在实际评审中很实用比如给业务方讲解流程时鼠标点一下节点一个个变绿谁先谁后一目了然。4.3 数据导出与多分支子流程弹窗流程图做完总得落地。我在Demo里加了两个实用小功能JSON导出和子流程层级展示。JSON导出其实很简单就是把Vuex里的nodes和edges原样序列化const data { nodes: Store.state.nodes, edges: Store.state.edges } const blob new Blob([JSON.stringify(data, null, 2)], { type: application/json }) const url URL.createObjectURL(blob) const a document.createElement(a) a.href url a.download flowchart.json a.click() URL.revokeObjectURL(url)子流程展示是针对标题热词里提到的“一个流程内某个环节有多个分支子流程”场景。我的方案是双击某个process节点弹出一个子画布里面维护一组独立的nodes和edges。子流程数据看板通过一个tab切换展示主流程和子流程用parentId关联。// 节点上加一个 children 字段 { id: node_3, label: 系统对接, type: subprocess, children: { nodes: [], edges: [] } }双击节点时判断type是否是subprocess如果是就读取它的children数据渲染到单独的子流程面板里。这个设计避免了把太多节点塞进同一张图里导致可读性变差也让“多分支子流程”的表达更清晰。5. 实战踩坑交互冲突、数据同步和性能问题5.1 drag-node和click-select的事件冲突第一个大坑是配置了drag-node之后点击节点也会频繁触发node:click导致明明只是想拖一下节点结果侧边栏的选中状态一直跳。更离谱的是如果drag-end和click都监听了有的机器上会连续触发两三次。我最终采取的方案是在node:mousedown时记录起始坐标和事件时间在node:mouseup时判断位移是否超过阈值比如5px如果超过就视为拖拽否则才触发click逻辑。let startXY null this.graph.on(node:mousedown, evt { startXY { x: evt.clientX, y: evt.clientY } }) this.graph.on(node:mouseup, evt { if (startXY) { const dx evt.clientX - startXY.x const dy evt.clientY - startXY.y if (Math.abs(dx) 5 || Math.abs(dy) 5) { // 是拖拽 } else { // 是点击 this.onNodeClick(evt) } } startXY null })这样虽然多写几步但比用debounce可靠得多尤其是不同浏览器鼠标事件触发的时机不完全一致。5.2 v-model绑定和deep: true的性能教训为了在侧边栏编辑节点属性时实时同步到画布我用过Vuex加上deep: true的watch。结果节点一多任何属性输入都卡得不行。原因是deep watch在每次输入时都会递归遍历整个nodes数组再触发g6的updateItem双重开销直接白给。后来调整成编辑时只维护一个本地草稿对象点击“保存”或失焦时才dispatch一次更新。当然如果你一定要实时预览也尽量只watch选中的那个节点而不是整个数组。另外一个和v-model相关的细节节点label在g6内部的model和Vuex里的数据是两份拷贝。如果你在Vuex里改了label却没有调用graph.updateItem画布上的文字不会变。我记得第一次做的时候改了侧边栏的label半天没反应排查了半天才发现g6不认Vuex的响应式。这个同步逻辑要显式地去做。5.3 常见问题速查表我把这个Demo开发中遇到的其他问题整理成一个表格方便你对照排查现象原因解决方案节点拖不动modes里没有drag-node在modes.default数组里加drag-node连线连不上自定义edge-create只在addEdge模式下生效切换到addEdge模式再尝试或把edge-create加到default模式保存后刷新位置乱了x/y没有从g6 model同步回Vuex每次node:dragend后调用syncGraphToStore节点被删除但边还在removeNode只删了自己的节点同时遍历edges删除source或target为该id的边路径高亮不生效没有调用render或refresh刷新画布改完itemState后调用graph.refresh()或graph.paint()图上文字重叠布局时没设置节点间距调大layout里的nodesep和ranksepfitView把图缩成蚊子大小图形初始化时容器宽高还没拿到在nextTick里初始化或用容器clientWidth/Height子流程弹窗数据丢失children字段没做深度监听子流程单独维护一组Vuex模块或对children做watch这张表的每一行都能对应到我前面讲过的某段逻辑。如果你在实现中遇到其他问题也欢迎一起交流。我自己的体会是这类Demo项目最大的价值不是把某个库用得多花哨而是把“状态管理”和“图形渲染”这两条线拧成一股绳。Vuex管好数据g6管好表现中间的交互靠清晰的事件链路串起来你就能在这个基础上玩出很多花样比如算法流程图的自动校验、模拟执行、多分支分析、甚至导出成图片给文档用。这套架构搭稳了以后接任何可视化编辑器类需求心里基本都有底。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门