ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vuex + antv/g6:从拖拽到数据流的流程图编辑器实现方案

Vuex + antv/g6:从拖拽到数据流的流程图编辑器实现方案

简介:基于 Vue 与 AntV G6 的流程图绘制演示 Demo,面向需要在线绘图、拓扑分析或可视化建模的前端开发者,以简洁工程还原流程图创建、节点链接、节点删除、菜单操作、重新渲染和本地存储等核心交互。资源共 93 个文件,主要由 38 个 JavaScript 文件、34 个 Vue 组件、5 个 SCSS 样式文件构成,同时含 mock 数据与标准构建配置,压缩包仅 189KB,结构紧凑、易读易改。已有 12821 人学习/下载,热度较高。整体工程包含 src 源码、store 状态管理、router 路由、build 脚本和静态资源目录,读者可直接运行查看效果,也可借鉴 G6 与 Vue 的集成方式,快速搭建可交互的分析流程图原型;描述中未包含复杂业务算法,便于把注意力放在 G6 基础能力与前端工程组织上,适合有类似需求的中高级前端开发者。

1. 从拖拽到数据流:用Vuex和antv/g6做流程图编辑器

我之前接手过一个内部工具,要在网页上画业务分析流程图,一开始直接拿 antv/g6 塞进组件里,数据全放在data()中。结果不到两周就发现不对劲:好几个页面要共享同一张图,有的要看着当前选中节点,有的要编辑节点属性,还有的要按节点状态做统计。组件间靠$emit传事件传到我头皮发麻,撤销重做更是没法写。后来把数据层整体迁到 Vuex,G6 只负责渲染和交互,画布所有变更都通过store.commit和store.dispatch走统一通道,这个Demo就是这套思路的浓缩版:一个基于 Vue + Vuex + @antv/g6 的最小在线流程图编辑器,支持从工具栏拖节点、拖边连线、编辑节点名称、删除节点和边,以及把画布数据序列化保存。适合正在用 G6 做可视化设计器、或者想搞明白「Vuex 到底怎么管理图形状态」的前端开发。对于只会把图写死在组件里的场景,这个Demo能直接给出可抄的状态模型。

2. 整体设计:Vuex状态模型与G6实例的生命周期

2.1 为什么用Vuex管理图数据而不是组件局部state

G6 的图结构本质上就是nodes: []和edges: []两个数组,外加每个元素的属性字段。这种数据结构放进 Vuex 的state里非常自然。组件里不再保存任何节点数据,只保存 G6 实例本身;画布上每一次增删改,都通过 action 修改 store,再调用实例的graph.changeData()或者addItem同步到渲染层。这样带来的第一个好处是:多组件可以共享同一份数据。比如左侧节点列表、右侧属性面板、顶部统计条,它们读的都是同一个flow.nodes,不需要跨组件传参。第二个好处是业务逻辑可以集中。比如删除节点时必须连带删除与它相连的所有边,这种规则写在 action 里,任何入口调用都能保证行为一致,不会出现某条边成了“孤儿”。

Vuex 的 state 必须是可序列化的,G6 默认的节点数据只包含id、label、x、y等基础字段,恰好满足要求。需要注意:G6 实例本身不要放进 Vuex,因为实例里有大量不可序列化的方法、内部事件对象,放进 state 会让 Vuex DevTools 直接卡死。正确做法是 G6 实例保存在组件的data或者一个独立的非响应式模块里,节点数据才进 store。这个Demo里我把graph实例放在FlowEditor.vue的data()中,用Object.freeze包了一层防止 Vue 对它做响应式劫持。

2.2 目录结构与职责划分

拿到 Demo 源码后先看目录,结构不大,但每一层职责分得很清楚。以下是我在本地解包后整理出的骨架:

├── package.json ├── vue.config.js ├── public/ │ ├── index.html │ └── favicon.ico └── src/ ├── main.js ├── App.vue ├── store/ │ ├── index.js │ └── modules/ │ └── flow.js ├── components/ │ ├── FlowEditor.vue │ ├── ToolBar.vue │ ├── NodePanel.vue │ └── PropertyPanel.vue └── views/ └── Home.vue

store/modules/flow.js是核心,里面定义state(nodes、edges、history)、mutations和actions。FlowEditor.vue负责创建 G6 实例、注册交互行为、把画布事件(拖拽、点击、连线)转发给 Vuex。NodePanel.vue提供了可拖拽的节点类型列表,拖到画布上生成新节点。ToolBar.vue放撤销、重做、清空、保存按钮。PropertyPanel.vue用来编辑当前选中节点的名称、颜色等属性。Home.vue只是把这些组件拼到同一张页面里。

这样的拆分逻辑是:组件只管「界面长什么样」和「触发什么动作」,至于流程图数据怎么变、变完以后怎么联动,全部收口到 store。如果以后要加“多标签页同时开几张图”,只需要把flow.js改成createFlowStore()工厂函数,按标签页实例化多个模块就行。

2.3 G6实例的创建与销毁:从Vuejs挂载到Vuex初始化

G6 实例必须在 DOM 渲染完成后才能创建,所以一般放在mounted()里。以下是FlowEditor.vue中创建图实例的代码,this.graph被挂到组件上,图数据不会走响应式:

import G6 from '@antv/g6'; import { mapActions } from 'vuex'; export default { name: 'FlowEditor', data() { return { graph: null, containerRef: null, }; }, mounted() { this.initGraph(); this.loadInitialData(); }, beforeDestroy() { if (this.graph) { this.graph.destroy(); this.graph = null; } }, methods: { ...mapActions('flow', ['loadInitialData', 'addNode']), initGraph() { const container = this.$refs.canvasContainer; const width = container.clientWidth; const height = container.clientHeight; this.graph = new G6.Graph({ container, width, height, fitView: true, modes: { default: ['drag-canvas', 'zoom-canvas', 'drag-node', 'click-select'], }, defaultNode: { type: 'rect', style: { width: 120, height: 48, fill: '#E8F4FF', stroke: '#5B8FF9', radius: 4, }, labelCfg: { style: { fontSize: 14 } }, }, defaultEdge: { type: 'cubic-horizontal', style: { stroke: '#87909E', lineWidth: 1.5 }, }, }); // 监听节点点击事件,选中后把节点id写入Vuex this.graph.on('node:click', (evt) => { const node = evt.item.getModel(); this.$store.commit('flow/setSelectedNode', node.id); }); }, }, };

这段代码最关键的参数是modes和defaultNode。modes.default数组里的drag-node负责节点拖拽,click-select负责点击选中;zoom-canvas和drag-canvas是画布缩放与平移,演示在线分析流程时很实用。fitView: true会让整个图自动适配画布尺寸,避免一开始节点太小。G6 实例创建后立刻绑定一个node:click事件,这是连接 Vuex 的第一个口子:用户点击节点,组件把节点的id提交到 store,属性面板就能响应式地展示节点信息。beforeDestroy里必须调用graph.destroy(),否则切路由时画布容器被移除但 G6 的事件监听还在,容易内存泄漏,重新进入页面还会出现“canvas already exists”的报错。

3. 核心模块拆解:拖拽建节点、连线交互与Vuex同步链路

3.1 从工具栏拖拽新节点到画布

在线画流程图的第一步是在画布上放置节点。NodePanel.vue里每一种节点类型都是一个可拖拽的卡片,拖拽时把节点类型信息挂在dataTransfer上,画布区域监听drop事件拿到这个信息,组装成 G6 节点数据并派发addNodeaction。

// NodePanel.vue 中的拖拽开始事件 onDragStart(event, nodeType) { event.dataTransfer.setData('application/flow-node-type', nodeType); event.dataTransfer.effectAllowed = 'move'; } // FlowEditor.vue 中的 conDainer 监听 handleDrop(event) { event.preventDefault(); const nodeType = event.dataTransfer.getData('application/flow-node-type'); if (!nodeType) return; const canvasRect = this.$refs.canvasContainer.getBoundingClientRect(); const x = event.clientX - canvasRect.left; const y = event.clientY - canvasRect.top; this.addNode({ id: `node_${Date.now()}`, type: nodeType, label: nodeType === 'start' ? '开始' : '处理', x, y, }); }

这里的dataTransfer是浏览器原生拖放 API 的传参通道,application/flow-node-type是自定义 MIME 类型,避免和系统内部拖拽冲突。计算x和y时用了getBoundingClientRect(),把鼠标相对页面的坐标转换成相对画布容器左上角的坐标,这样才能让节点出现在鼠标落点的正确位置。节点 id 用Date.now()生成,在单机Demo里不会重复,但生产环境最好换用 uuid。addNode这个 action 在store/modules/flow.js里会往数组 push 这条记录,然后FlowEditor通过watchstore 里的nodes变化,调用graph.addItem()把节点真正画出来。

3.2 节点拖拽和边连接的交互配置

节点放上去之后,要能拖动位置。G6 内置的drag-node行为会自动处理,但拖完以后坐标变更必须同步回 Vuex,否则刷新页面时节点会回到旧位置。我在node:dragend事件里更新 store:

graph.on('node:dragend', (evt) => { const model = evt.item.getModel(); this.$store.commit('flow/updateNodePosition', { id: model.id, x: model.x, y: model.y, }); });

连线交互是另一个关键。G6 中创建边有几种方式,最简单的是在modes里加一个add-edge行为,把defaultEdge的类型配好,然后让用户从一个节点拖到另一个节点:

modes: { default: ['drag-canvas', 'zoom-canvas', 'drag-node', 'click-select'], addingEdge: ['add-edge', 'drag-canvas'], },

在 ToolBar 里加一个「连线模式」按钮,点击后调用graph.setMode('addingEdge'),用户就可以拖边。边创建完成后 G6 会触发edge:add事件,此时需要获取边的source和target节点 id,提交给 Vuex:

graph.on('edge:add', (evt) => { const edge = evt.item.getModel(); this.$store.commit('flow/commitEdge', { id: edge.id, source: edge.source, target: edge.target, }); });

这里的edge是 G6 生成的 edge 模型,source和target对应两端节点 id。注意commitEdge时还要检查重复边,比如两个节点之间不允许画两条同方向边,否则后续做流程分析时会出现重复路径。我在 store 的 mutation 里做了查重:遍历已有 edges,如果source和target都相同,就返回并提示用户。这种业务规则放 mutation 里虽然有点“重”,但能确保所有入口(工具栏、右键菜单、脚本)都在同一条保护线上。

3.3 Vuex action处理增删改:以删除节点为例

删除节点是最容易出 bug 的操作,因为它牵扯到边。如果只删掉节点,不删关联边,G6 渲染时会因为找不到节点而报错,画布直接白屏。我在 Vuex 的 action 里统一处理这个逻辑:

// store/modules/flow.js actions: { deleteNode({ commit }, nodeId) { commit('deleteRelatedEdges', nodeId); commit('deleteNode', nodeId); }, }, mutations: { deleteRelatedEdges(state, nodeId) { state.edges = state.edges.filter( (edge) => edge.source !== nodeId && edge.target !== nodeId ); }, deleteNode(state, nodeId) { state.nodes = state.nodes.filter((node) => node.id !== nodeId); state.selectedNodeId = null; }, },

组件里调用时只需要这样:

graph.on('node:delete', (evt) => { const nodeId = evt.item.getModel().id; this.deleteNode(nodeId); });

删除节点时首先过滤掉所有以该节点为起点或终点的边,然后再从state.nodes里移除节点,最后清空选中状态。这样 Vuex 里的数据永远是干净的。组件里用graph.removeItem还是直接changeData?我建议删除操作直接用graph.removeItem(item),性能更好;但如果一次删除多个节点,直接用graph.changeData({ nodes, edges })会更省心。这个Demo的 ToolBar 里有「清空画布」按钮,就是先store.commit('clearFlow'),再graph.changeData({ nodes: [], edges: [] })完成的。

4. 运行与调试:本地跑通Demo的完整步骤

4.1 环境准备与依赖安装

这个 Demo 是基于 Vue CLI 搭建的,运行前需要确认本机有 Node.js 环境。我本地的 Node 版本是 16.x,npm 版本是 8.x,没有特殊要求,但建议不要低于 Node 12,否则新版 G6 的依赖可能装不上。项目依赖都写在package.json里,安装前先看一下核心依赖版本:

{ "dependencies": { "@antv/g6": "^4.8.0", "vue": "^2.6.14", "vuex": "^3.6.2" } }

这里用的是 Vue 2 + Vuex 3,因为 Vuex 4 是专门给 Vue 3 用的,如果拿 Vue 3 去配 Vuex 3 会出兼容问题。打开终端进入项目根目录,先执行依赖安装:

npm install

如果网络环境不好,或者之前装过其他项目导致 node_modules 状态混乱,可以删掉node_modules和package-lock.json再重装:

rm -rf node_modules package-lock.json npm install --registry=https://registry.npmmirror.com

--registry参数把 npm 源切到了大陆镜像,下载 G6 这种体积较大的包会快很多。安装完成后,可以顺便跑一次npm audit看看依赖有没有高危漏洞,不过Demo项目一般问题不大。

4.2 启动开发服务器与验证功能

安装完成后的启动命令很简单:

npm run serve

默认监听localhost:8080,如果端口被占用会提示你选8081。打开浏览器进入页面后,先看浏览器控制台有没有红色报错,重点看有没有「Container not found」或者「canvas not found」这类错误,出现这个多半是 G6 容器 div 没有渲染出来,或者容器高度为 0。正常界面应该是一块纯色画布,左侧是节点类型列表,上方是工具栏。

验证功能时按这个顺序走一遍:第一步从左侧拖一个「开始」节点到画布中间,应该出现一个浅蓝色矩形;第二步在左侧拖一个「处理」节点到下方,两个节点间拖出连线;第三步点击画布空白处取消选中,再点击「处理」节点,右侧属性面板应该出现该节点的 id 和 label;第四步点工具栏里的「保存」,刷新页面,数据应该还在。保存功能我用的是 localStorage,把整个nodes和edges存进了localStorage的flow_data键里,刷新后重新读取。

4.3 数据持久化:把画布存到localStorage

持久化在这个Demo里并不是必须的,但为了演示“在线分析流程”的场景,我加了简单的存/取。当用户点击保存时。

// 在 FlowEditor.vue 中 saveToLocal() { const data = { nodes: this.$store.state.flow.nodes, edges: this.$store.state.flow.edges, }; localStorage.setItem('flow_data', JSON.stringify(data)); this.$message.success('保存成功'); }

读取时在mounted里调用:

const saved = localStorage.getItem('flow_data'); if (saved) { const data = JSON.parse(saved); this.$store.commit('flow/setFlowData', { nodes: data.nodes, edges: data.edges, }); }

从 localStorage 读出来的数据必须先提交到 Vuex,再让组件去 watch 并graph.changeData()渲染。顺序不能反过来,否则画布先渲染了旧数据,Vuex 里的新数据一更新,又会触发一次重复渲染,导致节点闪烁。这个“先 store 后渲染”的顺序,也是后面排查问题时的核心判断点。

5. 避坑指南:G6 + Vuex联动的常见问题与排查

5.1 高发异常:画布白屏、节点重复、拖拽失灵

现象一:画布渲染出来是白屏,控制台报错Cannot read property 'getBoundingClientRect'。原因:G6 初始化的容器宽度或高度为 0,通常是父容器用了flex: 1但没设置overflow: hidden,或者组件挂载时容器还没完成布局。解决:在mounted里用this.$nextTick包一层,再读取clientWidth和clientHeight;同时给画布容器一个确定的最小高度,比如min-height: 600px。

现象二:所有节点堆在画布左上角,位置不在 drop 的落点。原因:计算坐标时没有减去容器偏移。我最初直接用了event.clientX和event.clientY,但 G6 内部用的是容器内相对坐标,因此需要通过getBoundingClientRect()转换。解决:参考 3.1 节的计算方式,并且注意页面是否有横向滚动条,有滚动条时还要加上scrollLeft的补偿。

现象三:删除节点后过一会儿节点又「复活」了。原因:Vuex 里的nodes数组删掉了数据,但 G6 的图形实例还保留着渲染项;第二次调用graph.changeData()时,G6 会拿 store 里的新数据做全量对比,发现该节点不在列表里才移除。如果你用了graph.addItem/graph.removeItem这种增量更新,不同步删掉 item 就会出现数据残留。解决:删除节点时,在 Vuex mutation 删除后,立刻调用graph.removeItem(evt.item)或者直接changeData全量刷新。最好是始终以 Vuex 数据为唯一真相,组件里的图形状态只做视图同步。

5.2 交互状态无法同步到属性面板

节点点击后属性面板没有反应,通常是事件绑错了目标。G6 里node:click事件的evt.item.getModel()才是节点数据,而evt.target可能是节点内部的 shape 元素。如果你操作evt.target.getModel(),有些版本会返回undefined。解决:统一用evt.item,它是 G6 的 Item 实例,包含完整的模型。另外,点击空白区域要取消选中节点,需要监听canvas:click,不能只监听node:click,否则空白处点击后面板还显示旧节点。事件绑定位置也要注意,如果绑定在某个子组件里,组件销毁后事件会丢。

5.3 Vuex状态更新了但画布不刷新

我在联调时遇到过最头疼的问题:Vuex 里nodes数量变了,graph却没新增节点。原因是我在组件里watch了一个嵌套数组,但 watch 的是引用地址。G6 的addItem是显式调用的,Vuex 数据变化并不会自动触发画布重绘。解决:不要在 mutation 里直接state.nodes.push()然后期待画布跟上,而是要在 mutation 之后由视图层监听某个计数器或者直接调用graph.read()。我这里的做法是:mutation 里维护一个version字段,每次增删改version++,组件 watchversion,然后统一做graph.changeData({ nodes: state.nodes, edges: state.edges })。虽然全量刷新多一点开销,但极大降低了状态不同步的概率。对节点数量几十个的流程图场景,性能完全没问题。

5.4 边连接不上或连线不到目标节点

出现这种情况要先确认两点:一是两个节点之间是否已经存在同方向边,我加了查重逻辑后就不会有重复边;二是目标节点是否被画布中其他节点挡住,add-edge行为默认只在鼠标移动到节点上方时触发连接。如果从边的终点拖到空白处,边会消失或变成自环。解决:在需求上要求用户必须从一个节点拖到另一个节点,不要从空白处开始拖。然后检查 G6 的defaultEdge是否设置了linkPoints,没有设置的话节点上不会有连接锚点,拖拽时会很难对准。建议在defaultNode中加上:

linkPoints: { top: true, right: true, bottom: true, left: true, size: 6, fill: '#fff', stroke: '#5B8FF9', }

这样每个节点四个方向都有可视化的连接点,连线的可用性会明显提升。

6. 二次开发:从Demo到业务系统的改造思路

如果你要把这个Demo改造成真正的业务流程图编辑器,第一件事不是加功能,而是把「固定数据类型」变成「可扩展 schema」。当前nodes里只有id、label、x、y和type,业务中肯定要加config字段,比如审批节点要配置「审批人」,条件节点要配置「判断表达式」。我的改造路径是这样的:先给 Vuex 模块增加一个nodeSchemas字典,把每种节点的默认配置存放好;然后 G6 的defaultNode不再写死宽高,而是根据node.type动态返回。画布最初的渲染数据也改成通过JSON.parse(JSON.stringify())深拷贝,避免从 store 读引用时被 G6 内部修改污染。

验证一个改动是否安全,我习惯做三步自测:一是在浏览器控制台手动 dispatch 一次addNode,强制走一遍「Vuex 数据变化 → 组件 watch → graph.changeData」的链路,确认不经过 Vue 组件也能驱动画布;二是用 G6 自带的graph.save()导出 JSON,对比前后两次保存结果,看新增字段是否完整;三是清空 localStorage 后在新浏览器会话里重新画一张图,排除旧数据干扰。做完这三步,再上线到业务系统。从那以后,我每次改这种联动逻辑都强制走一遍“先手动调 action,再 check 数据,最后画布比对”的流程,几乎没再遇到状态不同步的问题。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进