Vuex与antv/g6协同:在线流程图编辑器状态同步实践 简介基于Vue与antv/g6实现的流程图在线绘制演示Demo面向需要集成可视化流程编辑功能的前端开发者。资源抽离了流程图创建、节点链接、节点删除、菜单操作、重新渲染与本地存储等核心交互重点演示框架层面的实现思路供有类似需求的中级前端学习者参考。压缩包共93个文件以38个js脚本和34个vue组件为主体分别承载交互逻辑与页面结构辅以scss样式、json配置及少量png、字体等静态资源整体仅189KB结构紧凑便于快速阅读源码与本地调试。当前已有12821人学习下载。通过该Demo可理解Vuex状态管理与antv/g6图形引擎的协同方式掌握节点拖拽、连线、右键菜单等操作的事件处理流程并可直接复用本地存取、渲染刷新等基础模块作为二次开发的起点。1. 在线绘制流程图不简单难的不是画布是 Vuex 与 antv/g6 的状态同步很多人拿到一个基于 Vue 的流程图组件第一反应是去看画布怎么画、节点怎么渲染。但把 Vuex 和 antv/g6 组合出来的这个在线绘制流程图 Demo 跑一遍就会发现画布反而是整个项目里最老实的一部分——真正花心思的是让画布上的每一次拖拽、连线和编辑都准确落回到 Vuex 的状态树里。这个 Demo 用 antv/g6 做图可视化用 Vuex 管理节点和边的数据完整实现了流程图绘制分析场景里的基本闭环从工具栏拖拽节点入画布、连线、编辑节点属性、导出/导入 JSON 数据。适合想快速在浏览器里画流程图做分析的人也适合要在后台系统里集成流程编辑器、需要一份能跑起来的参考实现的前端开发者。它最大的价值是给你演示了命令式的 G6 画布和响应式的 Vuex 状态仓库怎么协同工作而不是各写各的。2. 技术选型与目录结构Vue 2 Vuex 3 的配套关系以及每个文件各管什么2.1 目录结构src 里各目录各管什么整个工程是一个典型的 Vue 2 单页应用没有引入额外重型 UI 框架核心依赖就两个vuex 和 antv/g6。我拆这个 Demo 的时候按职责把 src 分成了四块每一块的归属非常清晰src ├─ main.js ├─ App.vue ├─ store │ └─ index.js ├─ components │ ├─ FlowCanvas.vue # G6 画布容器负责初始化 graph、绑定事件 │ ├─ Toolbar.vue # 左侧工具栏保存可拖拽的节点类型 │ └─ PropertyPanel.vue # 右侧属性面板编辑选中节点的字段 ├─ views │ └─ FlowEditor.vue # 页面组装布局画布、工具栏、属性面板 └─ utils ├─ graphConfig.js # G6 的默认配置如 defaultNode / defaultEdge └─ serializer.js # 流程图数据的导入导出与校验这么拆的理由很直接FlowCanvas 只关心画布实例和 G6 事件不持有业务数据Toolbar 只保存节点类型清单拖拽时通过 DataTransfer 传递类型名PropertyPanel 读取 Vuex 里当前选中的节点展示和编辑它的属性。三个组件之间没有直接通信全部通过 store 中转。FlowEditor.vue 是唯一一个既引用组件又引用 store 的页面它做的事情只有一件把三个子组件按左中右布局拼到一屏里并且给 FlowCanvas 传入画布宽高。如果你要拆掉这个 Demo 自己重写界面只需要动这个文件核心逻辑都在子组件和 store 里这是我喜欢这个结构的地方。serializer.js 里放的是 JSON 导出的序列化和导入校验。因为 G6 的graph.save()会带出一堆内部样式字段直接存库会很脏所以 Demo 里导出走的是手动序列化函数只保留 nodes 和 edges 的关键字段这一点后面避坑章节还会重点讲。2.2 技术选型为什么是 Vuex 3 G6 4.x而不是其他搭配这个 Demo 的技术栈组合是有讲究的。先说 Vuex画布组件要读数据属性面板要改数据工具栏要加数据如果在没有 Vuex 的情况下写FlowCanvas 和 PropertyPanel 之间的双向同步会变成一层一层往上冒事件节点一多就没法维护。Vuex 在这里不是走形式它确实承担了“唯一数据源”的角色画布只负责渲染和交互所有变更都以 mutation 的形式落到 store 里再由 store 的变化回推画布更新。版本方面我按 Vue 2 的项目习惯默认它用的是 Vuex 3.x。Vuex 4 是给 Vue 3 用的API 大体一致但安装和注入方式有差异如果硬把 Vuex 4 配到 Vue 2 的 createApp 写法里反而是给自己挖坑。G6 同理这个 Demo 的图配置和事件回调都是 G6 4.x 的写法new G6.Graph({...})G6 5.x 改成了new Graph({...})和插件化模式。拿到代码先看一眼 package.json 里的版本如果发现是 5.x第 5 章我会顺带说差异点。为什么这个组合值得拆因为 G6 是典型的命令式库它不会因为你改了某个变量就自动重绘画布必须手动调用graph.updateItem()或graph.read()。而 Vuex 是响应式的它天然希望数据变、视图自动跟着变。把这两套思维放在一起核心矛盾就浮现了画布事件回写 storestore 变化又触发画布更新怎么避免死循环这正是这个 Demo 最值得读的部分。3. 核心链路拆解从 Vuex 的 state 到 G6 画布渲染再到事件回写的完整闭环3.1 Vuex 里的数据模型节点和边分开存合并成一个 graphData 对象先看 store 里的数据设计。G6 读取图数据的标准格式是{ nodes: [], edges: [] }所以 Vuex 的 state 也按这个结构存没有额外做一套“业务模型”再转换这是减少联动复杂度的关键决定。// src/store/index.js import Vue from vue import Vuex from vuex Vue.use(Vuex) export default new Vuex.Store({ state: { graphData: { nodes: [], edges: [] }, selectedNodeId: null }, getters: { selectedNode(state) { return state.graphData.nodes.find(n n.id state.selectedNodeId) } }, mutations: { SET_GRAPH_DATA(state, payload) { state.graphData payload }, ADD_NODE(state, payload) { state.graphData.nodes.push(payload) }, REMOVE_NODE(state, id) { state.graphData.nodes state.graphData.nodes.filter(n n.id ! id) state.graphData.edges state.graphData.edges.filter(e e.source ! id e.target ! id) }, ADD_EDGE(state, payload) { state.graphData.edges.push(payload) }, UPDATE_NODE_POSITION(state, payload) { const node state.graphData.nodes.find(n n.id payload.id) if (node) { node.x payload.x node.y payload.y } }, UPDATE_NODE_DATA(state, payload) { const node state.graphData.nodes.find(n n.id payload.id) if (node) { Object.assign(node, { label: payload.label, data: payload.data }) } }, SET_SELECTED_NODE(state, id) { state.selectedNodeId id } } })这段代码有几个细节值得说明。第一没有给每个 mutation 写一堆“辅助函数”因为节点和边的操作就那么几种增、删、改坐标、改属性。第二REMOVE_NODE 里连边一起删这是一个很容易漏的连带操作——很多新手删了节点边还残留在画布上导致导出数据校验失败。第三UPDATE_NODE_POSITION 直接改 x 和 y不会触发整图重建。为什么强调合并成 graphData 而不拆成独立的 nodes 和 edges 两个字段因为 G6 的graph.read()接收的是一个完整对象拆开存会让“渲染前合并”变成强迫性的额外步骤。合在一起setTimeout 也好重新 read 也好直接传 store 里的对象就行。3.2 画布初始化Graph 实例、read 方法与渲染时序FlowCanvas.vue 的 mounted 里做了画布初始化。这是 G6 项目里最容易出问题的点位因为 G6 要求容器在初始化时必须已经有确定宽高。// src/components/FlowCanvas.vue mounted() { this.$nextTick(() { this.initGraph() }) }, methods: { initGraph() { this.graph new G6.Graph({ container: flow-canvas, width: this.containerWidth, height: this.containerHeight, fitView: true, modes: { default: [drag-canvas, zoom-canvas, drag-node] }, defaultNode: { type: rect, size: [160, 48], style: { fill: #fafafa, stroke: #d9d9d9, radius: 4 }, labelCfg: { style: { fontSize: 14, fill: #333 } } }, defaultEdge: { type: line, style: { stroke: #91d5ff, lineWidth: 2, endArrow: true } } }) const data this.$store.state.graphData this.graph.read(data) this.bindEvents() }, bindEvents() { this.graph.on(node:dragend, evt { const model evt.item.getModel() this.$store.commit(UPDATE_NODE_POSITION, { id: model.id, x: model.x, y: model.y }) }) this.graph.on(node:click, evt { const model evt.item.getModel() this.$store.commit(SET_SELECTED_NODE, model.id) }) this.graph.on(canvas:click, () { this.$store.commit(SET_SELECTED_NODE, null) }) } }先说初始化参数。container接受的是 DOM 的 id 字符串不是 Vue 的 ref 对象这一点和多数组件库的用法不一样写错会静默失败。fitView: true会在首次 read 时自动缩放让整张图适合画布可视区域但注意它是“一次性”的后续再 add 节点不会再次全局缩放。modes.default里的三个行为是并存的drag-canvas拖动画布空白处平移zoom-canvas滚轮缩放drag-node拖拽节点。任何一项漏了交互就会有缺失。节点拖拽结束的回调里evt.item.getModel()拿到的是画布内部维护的节点模型x 和 y 在当前时刻已经是拖拽后的新坐标所以组件里不需要再做额外计算。事件绑定这里踩过的坑是node:dragend一定要在graph.read()之后绑定。如果先绑事件再灌数据G6 内部节点还没建立引用某些事件会丢失。实际上放在 initGraph 的末尾是最稳妥的确保画布和模式都已经就绪。3.3 从 store 变化回推画布watch 驱动增量更新避免循环第三段是闭环的另一半用户在属性面板改了节点名称或通过代码导入新数据store 变了之后画布怎么知道答案是 watch。但这里有个非常容易翻车的循环问题。// src/components/FlowCanvas.vue watch: { graphData.nodes: { deep: true, handler() { this.syncNodesToGraph() } }, graphData.edges: { deep: true, handler() { this.syncEdgesToGraph() } } }, methods: { syncNodesToGraph() { const nodes this.$store.state.graphData.nodes nodes.forEach(node { const item this.graph.findById(node.id) if (item) { const model item.getModel() const posChanged model.x ! node.x || model.y ! node.y const labelChanged model.label ! node.label if (posChanged || labelChanged) { this.graph.updateItem(item, { x: node.x, y: node.y, label: node.label }) } } else { this.graph.addItem(node, node) } }) }, syncEdgesToGraph() { const edges this.$store.state.graphData.edges edges.forEach(edge { if (!this.graph.findById(edge.id)) { this.graph.addItem(edge, edge) } }) } }这里的关键设计是“先比对再更新”。用户拖拽节点后node:dragend回调会 commit 新的坐标到 storewatch 随之触发。如果同步代码里无脑地调updateItemG6 会再次走一遍节点更新流程造成坐标闪烁极端情况下会震荡。所以 syncNodesToGraph 里先取画布上的旧模型用当前位置对比 store 里的新位置只有确认变化才调用updateItem。对于 store 里有但画布上没有的节点直接addItem插入这是处理“从工具栏拖入新节点”这一类操作的统一入口。边同理但不做属性比对因为 Demo 里边的变更只有新增一种。数据流梳理一下就是画布交互事件 → Vuex mutation → watch 触发 →updateItem增量更新。整个过程里 Vuex 是中枢画布只是执行者。这个模式一旦建立后面加节点属性、加边样式都只需要沿着链路加 mutation 和 watch 分支不需要在组件间穿线。4. 交互实现拖拽入画布、连线、属性面板与数据导出4.1 工具栏拖拽入画布从 HTML5 拖放到 G6 坐标换算工具栏到画布的拖拽是这个 Demo 里最直观的交互入口。Toolbar 里把节点类型一个个渲染成可拖拽的卡片拖拽开始的时候把类型名写进 DataTransfer画布容器监听 drop 事件拿到类型后计算落点坐标再 commit 到 store。// src/components/Toolbar.vue关键片段 div v-fortype in nodeTypes :keytype classtoolbar-node draggabletrue dragstartonDragStart($event, type) {{ type }} /div methods: { onDragStart(event, type) { event.dataTransfer.setData(nodeType, type) } }// src/components/FlowCanvas.vue关键片段 handleDrop(event) { event.preventDefault() const type event.dataTransfer.getData(nodeType) if (!type) return const point this.graph.getPointByClient(event.clientX, event.clientY) this.$store.commit(ADD_NODE, { id: node_${Date.now()}, type, label: type, x: point.x, y: point.y, data: {} }) }getPointByClient是这里最值得讲的一个 API。drop 事件里拿到的坐标是浏览器视口坐标而画布内部有自己的坐标系中间隔了画布缩放和可能的平移。如果直接把 clientX 和 clientY 当作节点坐标写入 store缩放过的画布里节点位置会完全偏掉。G6 的graph.getPointByClient()会结合当前的 zoom 和 translate 参数把视口坐标换算成画布逻辑坐标这一步省不掉。节点类型注册方面G6 有两种做法用内置节点类型比如 rect、circle或者用G6.registerNode()注册自己的类型。这个 Demo 使用了内置 rect 加 label 的方式简单直接。如果后续要加业务图标建议从自定义节点入手第 6 章会展开。4.2 连线交互把边的创建收敛到统一入口连线不是直接用 G6 的默认 drag-edge 行为解决的默认行为虽然能拉线但边数据不会进入 store。这个 Demo 的处理方式是把连线也作为一次显式的数据变更。bindEvents() { this.graph.on(node:dragend, evt { /* 更新坐标见上一节 */ }) this.graph.on(edge:create, evt { const model evt.item.getModel() this.$store.commit(ADD_EDGE, { id: edge_${Date.now()}, source: model.source, target: model.target }) }) }G6 事件系统里一条边创建成功后画布会抛edge:create事件此时evt.item.getModel()拿到的 model 里已经包含 source 和 target 两个字段。这里用 Date.now() 拼边 id比写死序号靠谱因为图数据是增量的时间戳几乎不会重复。关于连线还有个细节默认配置里defaultEdge只定义了样式没有指定source和target。G6 在用户拖拽过程中会把这两个字段自动填上所以 commit 到 store 的数据是完整的。如果你自己写连线逻辑比如点击两个节点生成边一定要记得手动补 source 和 target否则 store 里会混入一条画布上看不见的边。边的方向由endArrow: true体现。做流程分析时方向是语义的一部分所以默认给它开了箭头。如果需要双向箭头G6 的 style 里可以分别配置startArrow和endArrow但在流程图表单里大多数场景单向就够了。4.3 属性面板与数据导出分析功能落地的两件事属性面板是“分析”这件事的核心承载者。选中节点右侧面板显示它的 label 和扩展数据比如耗时、成本、负责人。修改后 commit 到 store再调 updateItem 让画布和面板保持一致。// src/components/PropertyPanel.vue关键片段 saveNodeData() { const node this.$store.getters.selectedNode if (!node) return this.$store.commit(UPDATE_NODE_DATA, { id: node.id, label: node.label, data: { duration: this.form.duration, cost: this.form.cost, owner: this.form.owner } }) const item this.graph.findById(node.id) if (item) { this.graph.updateItem(item, { label: node.label }) } }这里的思路是面板编辑的是自己的 form 副本点保存才统一推动 store 和画布避免每个字段 change 都触发一次 mutation。做分析场景时节点上的自定义字段会越来越多data这个字段就是给业务数据留的口子。导出时也要把 data 一起序列化否则下次导入就丢了。数据导出的实现放在 serializer.js 里核心逻辑是从 store 读 graphData 而不是从 graph.save() 读。手动序列化可以把 G6 自动生成的一堆样式字段过滤掉只留干净的业务数据。// src/utils/serializer.js export function serializeGraphData(graphData) { return { nodes: graphData.nodes.map(n ({ id: n.id, type: n.type, label: n.label, x: n.x, y: n.y, data: n.data || {} })), edges: graphData.edges.map(e ({ id: e.id, source: e.source, target: e.target })) } } export function parseGraphData(json) { const data JSON.parse(json) if (!Array.isArray(data.nodes) || !Array.isArray(data.edges)) { throw new Error(流程图数据格式不正确缺少 nodes 或 edges 数组) } return data }导入的时候先parseGraphData校验结构再 commitSET_GRAPH_DATA最后在 watch 链路里完成画布重建。注意不要直接graph.read()旧对象再触发 store 更新那样会出现 store 和画布短暂不同步的窗口期。先清空 store 再让 watch 去驱动画布时序更干净。5. 在线流程图绘制常见排查五个让我翻过车的细节5.1 画布白屏mounted 初始化时拿不到正确宽高现象浏览器打开页面工具栏和属性面板都在唯独画布区域一片空白控制台也没有报错。原因G6 初始化时读取 container 的宽高如果此时容器还没渲染完成或父级布局还在自适应宽高会拿到 0。G6 拿 0 作为画布尺寸自然画不出东西。解决把初始化放在this.$nextTick()回调里并且给画布容器一个明确的高度比如height: calc(100vh - 64px)不要用纯 auto 撑开。如果还有白屏在浏览器调试面板里看一眼容器元素的 computed 宽高确认不是样式冲突。5.2 节点渲染不出来类型名对不上现象从工具栏拖一个节点进画布节点没出现但 store 里确实多了这条数据。原因G6 渲染节点时按node.type查注册表如果 type 写的是rect这种内置类型没问题但如果你自动注册过自定义类型比如business-node而 store 里存的 type 是rect画布不知道用哪个渲染函数就跳过了。解决在 ADD_NODE 的调用处统一维护一个类型清单所有节点的 type 必须来自同一个枚举。我一般会写一个nodeTypes.js把可选类型和对应图标、样式放在一起工具栏和 store 都从这份配置取避免两处写两套字符串。5.3 拖拽后节点跳回原位watch 回写和画布事件打架现象每次拖完节点松手瞬间节点闪一下又弹回原来的位置再等一下才归位甚至一直归位失败。原因拖拽触发node:dragend提交新坐标到 storewatch 检测到 nodes 变化回调里如果无脑执行整图graph.read()画布会按新数据重建。但 read 后节点位置被重置成 store 里的坐标而这个坐标可能还没等 G6 内部完成拖拽状态清理就应用了视觉上就表现为回跳。解决watch 里不要整图重建而是用graph.updateItem()做节点级更新并且先比对getModel()里的旧坐标与 store 新坐标没变化就不动。这一段是第 3.3 节代码里那两行if (posChanged)的核心价值你以后集成类似图表库都能复用这个写法。5.4 graph.save() 导出的数据是脏数据现象功能上能把图导出成 JSON但拿 JSON 重新导入节点样式、位置和原来对不上打开文件看里面全是 G6 塞进去的内部样式对象。原因graph.save()是画布层 API它把当前画布的内存状态完整序列化包含样式、容器信息、G6 自生成字段。它适合做画布快照不适合做业务存储。解决从 store 的 graphData 走手动序列化只挑 id、type、label、x、y、data、source、target 这些业务字段导出前再用 serializer 里的校验函数检查一遍结构。存库格式干净以后要迁移或者做版本对比都方便。5.5 锐化大图时卡顿整图重绘是性能杀手现象节点多了以后每次编辑一个节点属性画布明显卡一下拖拽体验很糟。原因属性面板保存时如果调用graph.read()或者graph.render()整图重绘所有节点边的布局、样式、文本都重新计算一遍。十来个节点还好四五十个节点时开销立刻上来。解决更新类操作一律走updateItem只重绘变化的节点。新增节点用addItem删除用removeItem只有导入整个 JSON 时才允许clear read。这是 G6 性能优化的基本盘也是 Demo 里 watch 链路为什么用增量更新的原因。6. 二次开发思路把 Demo 改造成业务流程编辑器需要动哪几处6.1 用业务模型替换 label 和 dataDemo 里节点只存了一个 label 和空 data真实业务流程编辑器需要的是像{ nodeType: approval, approver: 某角色, duration: 3 }这样的结构化数据。改造时不需要动 store 和画布链路只需要做两件事一是定义每个节点类型的专属数据结构放在第 5 章提过的 nodeTypes.js 里二是在UPDATE_NODE_DATA的提交方组装一次字段把 form 数据映射到业务节点模型。画布只感知 id、type、坐标和 label其余业务字段全部挂在 data 内部这是 G6 官方推荐的扩展姿势。6.2 接入布局算法让导入数据自动排列手拖节点在节点少时没问题但导入一份几十个节点的旧数据时没有自动布局会很绝望。G6 4.x 内置了 dagre、radial、force 等布局方案在new G6.Graph()时加一个layout: { type: dagre, rankdir: LR, align: UL }配置再让 graph 执行layout()方法就能按层级自动排列流程节点。注意加入 layout 配置之后节点坐标由布局算法覆盖导入数据和拖拽出来的手工坐标会产生优先级冲突。我一般只在导入时跑一次布局后续拖拽位置以用户为准。6.3 自定义节点类型给节点加业务图标和颜色内置 rect 节点只有边框和文字流程图的语义层次基本靠颜色区分。G6 的registerNode()允许注册带图标和状态色的自定义节点模板里可以用draw()方法画矩形、圆角矩形、图标位和文本位或直接返回一组 shape 组合。改造时只需把 ADD_NODE 里传入的 type 换成自定义类型名graphConfig.js的 defaultNode 里补上对应配置画布和 store 代码一行都不用动。这一步做完整个视觉风格就开始像真正的业务产品了。到现在这个 Demo 我已经跑过两三遍也把它的联动链路原样搬到过自己的项目里。每次往流程图组件加交互我都会强制走一遍“画布事件 → Vuex mutation → watch 增量回写”这条链路先确认数据流闭环在哪断裂再动手写代码。这个习惯帮我少踩了很多状态同步的坑。希望帮到你。本文还有配套的精品资源点击获取