AntV-G6实战:5分钟搞定可交互拓扑图编辑器(附完整代码)
AntV-G6实战:5分钟搞定可交互拓扑图编辑器(附完整代码)
最近在做一个内部工具的原型,需要快速搭建一个能让业务方自己拖拽、连线、配置关系的流程图工具。时间紧,要求是“今天提需求,明天就要看效果”。这种场景下,再去从零搭建一个图形编辑器显然不现实。我第一时间就想到了蚂蚁的AntV-G6,它本身就是一个成熟的图可视化引擎,但很多人可能不知道,利用它的registerBehavior机制,我们能在极短时间内,将一个静态的“展示图”变成一个功能完备的“编辑图”。
这篇文章,就是为你还原这个“5分钟”的实战过程。我们不谈复杂的渲染原理,也不做庞大的架构设计,就聚焦一件事:如何用最少的代码,让一个G6拓扑图“活”起来,支持节点的增、删、改、查以及边的自由绘制。最终,你会得到一个可以直接嵌入项目、功能完整的迷你编辑器。代码已经准备好,我们直接开始。
1. 环境准备与项目初始化
在开始编写交互逻辑之前,我们需要一个最基础的G6画布作为舞台。这个过程非常快,如果你已经熟悉G6的基础使用,可以快速浏览;如果你是新手,跟着做一遍,两分钟内就能看到第一个拓扑图。
首先,创建一个标准的HTML文件,并引入G6。目前社区推荐使用通过npm安装的方式,但为了演示的纯粹性和开箱即用,我们这里直接使用CDN。在实际项目中,你当然应该使用构建工具。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>G6交互式拓扑图编辑器</title> <script src="https://unpkg.com/@antv/g6@latest/dist/g6.min.js"></script> <style> #container { width: 100%; height: 600px; border: 1px solid #e8e8e8; border-radius: 4px; } .toolbar { margin-bottom: 10px; } .toolbar button { margin-right: 8px; padding: 6px 12px; cursor: pointer; } </style> </head> <body> <div class="toolbar"> <button id="addNodeMode">添加节点</button> <button id="addEdgeMode">添加连线</button> <button id="selectMode">选择模式</button> <button id="deleteItem">删除选中项</button> <button id="getData">获取图数据</button> </div> <div id="container"></div> <script> // 我们的所有代码将写在这里 </script> </body> </html>接下来,在<script>标签内初始化G6实例。我们从一个空画布开始,但为了有点内容,先添加两个默认节点和一条边。
// 初始化图实例 const graph = new G6.Graph({ container: 'container', width: document.getElementById('container').clientWidth, height: document.getElementById('container').clientHeight, // 启用画布平移和缩放 modes: { default: ['drag-canvas', 'zoom-canvas', 'drag-node'] }, // 简单的默认节点和边样式 defaultNode: { size: [60, 40], style: { fill: '#f0f5ff', stroke: '#adc6ff', lineWidth: 2, }, labelCfg: { style: { fontSize: 12, }, }, }, defaultEdge: { style: { stroke: '#a3b1bf', lineWidth: 2, endArrow: { path: G6.Arrow.triangle(6, 8, 0), fill: '#a3b1bf', }, }, labelCfg: { autoRotate: true, style: { fontSize: 12, background: { fill: '#ffffff', padding: [2, 4], radius: 2, }, }, }, }, }); // 渲染一些初始数据 const initialData = { nodes: [ { id: 'node1', x: 100, y: 200, label: '起始节点' }, { id: 'node2', x: 300, y: 200, label: '目标节点' }, ], edges: [{ source: 'node1', target: 'node2', label: '关联关系' }], }; graph.data(initialData); graph.render(); console.log('G6图实例初始化完成!');现在,打开这个HTML文件,你应该能看到一个静态的拓扑图,可以拖拽画布、缩放、拖拽节点。我们的“舞台”已经搭好,接下来就是让工具条上的按钮真正起作用的核心部分。
2. 核心交互:注册自定义行为(Behavior)
G6的强大之处在于其高度可扩展的交互机制。registerBehavior是实现自定义交互的钥匙。我们可以为画布、节点、边等元素定义在各种事件(点击、拖拽、鼠标移动)下的响应函数。下面,我们将逐一实现四种核心编辑行为。
2.1 点击画布添加节点
我们希望点击“添加节点”按钮后,再点击画布空白处,就能在点击位置生成一个新节点。这需要两个步骤:1. 切换到一个特定的“添加节点”模式;2. 在该模式下,监听画布点击事件。
首先,定义这个自定义行为:
// 全局变量,用于生成唯一ID和记录当前模式 let addedCount = 3; // 从3开始,因为已有两个节点 let currentMode = 'default'; // 当前交互模式 // 注册“点击添加节点”行为 G6.registerBehavior('click-add-node', { getEvents() { // 指定该行为需要监听的事件及其处理函数 return { 'canvas:click': 'onCanvasClick', }; }, onCanvasClick(ev) { // 只有处于“添加节点”模式时才执行 if (currentMode !== 'add-node') return; const graph = this.graph; const point = graph.getPointByClient(ev.clientX, ev.clientY); // 添加一个新节点 graph.addItem('node', { x: point.x, y: point.y, id: `node-${addedCount}`, // 使用唯一ID label: `新节点 ${addedCount}`, }); addedCount++; console.log(`节点 node-${addedCount - 1} 已添加`); }, });然后,我们需要修改图的modes配置,并提供一个切换模式的方法:
// 更新图的模式配置,加入我们的自定义行为 graph.updateBehaviors({ default: ['drag-canvas', 'zoom-canvas', 'drag-node'], 'add-node': ['click-add-node'], // 添加节点模式只启用这一个行为 'add-edge': [], // 添加边模式稍后定义 }); // 工具条按钮事件绑定 document.getElementById('addNodeMode').addEventListener('click', () => { currentMode = 'add-node'; graph.setMode('add-node'); // 切换图的行为模式 console.log('当前模式:添加节点。请在画布空白处点击。'); }); document.getElementById('selectMode').addEventListener('click', () => { currentMode = 'default'; graph.setMode('default'); console.log('当前模式:默认选择/拖拽。'); });注意:
setMode方法会清空当前所有已绑定的行为,然后启用新模式下定义的行为。因此,在add-node模式下,你将无法拖拽画布或节点,除非你在该模式配置中也加入它们。
2.2 点击节点添加边(拖拽连线)
添加边的交互比节点复杂一些,它通常是一个“点击源节点 -> 拖拽连线 -> 点击目标节点”的过程。我们需要处理鼠标按下(开始连线)、鼠标移动(实时预览连线)、鼠标点击(完成连线或取消)等多个事件。
// 注册“点击添加边”行为 G6.registerBehavior('click-add-edge', { getEvents() { return { 'node:mousedown': 'onNodeMouseDown', // 在节点上按下鼠标,开始连线 mousemove: 'onMouseMove', // 鼠标移动,更新连线预览 'node:mouseup': 'onNodeMouseUp', // 在节点上释放鼠标,完成连线 'canvas:click': 'onCanvasClick', // 点击画布空白处,取消连线 }; }, onNodeMouseDown(ev) { if (currentMode !== 'add-edge') return; const graph = this.graph; const node = ev.item; const model = node.getModel(); // 记录连线的起点 this.edgeSource = model.id; this.edgeSourcePoint = { x: ev.x, y: ev.y }; // 创建一条临时边,它的终点暂时是鼠标当前位置 this.tempEdge = graph.addItem('edge', { source: this.edgeSource, target: this.edgeSourcePoint, // 目标是一个坐标点,而不是节点ID style: { stroke: '#1890ff', lineDash: [5, 5], // 虚线表示预览 }, isTemp: true, // 自定义属性,标记为临时边 }); }, onMouseMove(ev) { if (!this.tempEdge || currentMode !== 'add-edge') return; const graph = this.graph; const point = graph.getPointByClient(ev.clientX, ev.clientY); // 实时更新临时边的终点位置 graph.updateItem(this.tempEdge, { target: point, }); }, onNodeMouseUp(ev) { if (!this.tempEdge || currentMode !== 'add-edge') return; const graph = this.graph; const targetNode = ev.item; const targetModel = targetNode.getModel(); // 防止连接到自身 if (this.edgeSource === targetModel.id) { graph.removeItem(this.tempEdge); this.clearTempEdge(); console.log('不能连接节点到自身'); return; } // 将临时边转换为正式边 graph.updateItem(this.tempEdge, { target: targetModel.id, style: { stroke: '#a3b1bf', lineDash: null, // 取消虚线 }, isTemp: false, label: `边 ${this.edgeSource} -> ${targetModel.id}`, }); console.log(`已创建边:${this.edgeSource} -> ${targetModel.id}`); this.clearTempEdge(); }, onCanvasClick(ev) { // 如果点击的是画布空白处(非节点),则取消正在进行的连线操作 if (this.tempEdge && currentMode === 'add-edge') { this.graph.removeItem(this.tempEdge); this.clearTempEdge(); console.log('连线已取消'); } }, // 清理临时状态 clearTempEdge() { this.tempEdge = null; this.edgeSource = null; this.edgeSourcePoint = null; }, });同样,需要更新模式配置并绑定按钮事件:
// 更新模式配置 graph.updateBehaviors({ // ... 其他模式 'add-edge': ['click-add-edge'], // 添加边模式 }); // 绑定“添加连线”按钮 document.getElementById('addEdgeMode').addEventListener('click', () => { currentMode = 'add-edge'; graph.setMode('add-edge'); console.log('当前模式:添加连线。请从一个节点拖拽到另一个节点。'); });现在,切换到“添加连线”模式,你就可以体验拖拽连线的效果了。鼠标移动时,会有一条虚线实时跟随,直观地展示了连线的预览。
3. 编辑与删除:完善编辑器功能
有了增,自然要有删和改。一个完整的编辑器还需要支持选中元素、修改属性以及删除元素。
3.1 选中与属性编辑
G6本身提供了'click-select'行为,可以让我们直接使用。我们将其加入默认模式,并监听选中事件来填充一个(假设存在的)属性编辑表单。
// 更新默认模式,加入点击选择 graph.updateBehaviors({ default: ['drag-canvas', 'zoom-canvas', 'drag-node', 'click-select'], // ... 其他模式 }); // 监听选中变化 graph.on('nodeselectchange', (e) => { const selectedNodes = e.selectedItems.nodes; if (selectedNodes && selectedNodes.length > 0) { const node = selectedNodes[0]; const model = node.getModel(); console.log('选中节点:', model.id, model.label); // 这里可以将 model 的数据填充到右侧的属性面板 // updatePropertyForm('node', model); } }); graph.on('edgeselectchange', (e) => { const selectedEdges = e.selectedItems.edges; if (selectedEdges && selectedEdges.length > 0) { const edge = selectedEdges[0]; const model = edge.getModel(); console.log('选中边:', model.id, `从 ${model.source} 到 ${model.target}`); // updatePropertyForm('edge', model); } });为了演示修改属性,我们模拟一个简单的场景:双击节点或边的标签,直接进行编辑。这里需要用到G6的label配置和updateItem方法。
// 监听节点标签的双击事件 graph.on('node:dblclick', (e) => { const item = e.item; const model = item.getModel(); const newLabel = prompt('请输入新的节点名称:', model.label); if (newLabel && newLabel !== model.label) { graph.updateItem(item, { label: newLabel, }); } }); // 监听边标签的双击事件 graph.on('edge:dblclick', (e) => { const item = e.item; const model = item.getModel(); const newLabel = prompt('请输入新的边描述:', model.label || ''); if (newLabel !== null) { // 允许设置为空字符串 graph.updateItem(item, { label: newLabel, }); } });3.2 删除选中元素
删除功能相对简单,我们需要获取当前选中的元素,然后调用removeItem方法。G6在'click-select'行为下,会将选中的元素存储在graph.get('selectedItems')中。
document.getElementById('deleteItem').addEventListener('click', () => { const selectedItems = graph.get('selectedItems'); if (!selectedItems || (selectedNodes.length === 0 && selectedEdges.length === 0)) { alert('请先选中一个节点或边!'); return; } // 通常建议先删除边,再删除节点,避免引用问题 const edgesToRemove = selectedItems.edges || []; const nodesToRemove = selectedItems.nodes || []; edgesToRemove.forEach(edge => graph.removeItem(edge)); nodesToRemove.forEach(node => { // 删除节点前,可以先删除与之相连的边(可选,根据业务逻辑) // const edges = node.getEdges(); // edges.forEach(edge => graph.removeItem(edge)); graph.removeItem(node); }); // 清空选中状态 graph.clearSelectedStates(); console.log('已删除选中元素'); });4. 数据持久化与导出
编辑器的价值在于生成数据。G6提供了graph.save()方法,可以非常方便地获取当前图的所有数据,包括节点位置、样式、标签等。
document.getElementById('getData').addEventListener('click', () => { const graphData = graph.save(); console.log('当前拓扑图数据:', graphData); // 为了更直观地查看,我们可以将其格式化为JSON字符串并展示 const dataStr = JSON.stringify(graphData, null, 2); alert(`当前图数据已复制到控制台。\n你也可以在此查看:\n\n${dataStr.substring(0, 1000)}...`); // 在实际应用中,你可以将这个数据发送到后端保存 // fetch('/api/save-graph', { method: 'POST', body: JSON.stringify(graphData) }) });save()方法返回的数据结构非常清晰,包含了nodes和edges两个数组。你可以直接将其存入数据库,下次加载时,使用graph.data(savedData).render()即可完全还原编辑状态。
为了更实用,我们还可以考虑以下增强功能:
| 功能点 | 实现思路 | 代码提示 |
|---|---|---|
| 导入数据 | 提供一个文件上传或文本框,将JSON数据解析后通过graph.data().render()加载。 | graph.data(JSON.parse(jsonStr)).render() |
| 撤销/重做 | 维护一个状态历史栈。每次图发生变化(graph.on('afterchange'))时,将当前数据快照入栈。 | 使用数组存储历史状态,指针指向当前状态。 |
| 多种节点/边样式 | 在addItem或updateItem时,传入不同的style、size或type属性。 | 可以预定义几种nodeConfig和edgeConfig模板。 |
| 对齐与布局 | 调用G6的内置布局算法,如Dagre、Force等,对选中的节点或全图进行重新布局。 | graph.updateLayout({ type: 'force', ... }) |
5. 项目集成与优化建议
至此,一个具备核心增删改查功能的拓扑图编辑器已经完成。你可以将所有这些代码整合到一个HTML文件中,直接运行。但在实际项目集成时,还有几点值得注意。
首先,关于代码组织。上面的示例为了演示,将所有逻辑写在了同一个脚本标签里。在Vue或React项目中,你应该将其拆分为组件。核心的G6图实例、行为注册、数据管理可以放在一个自定义Hook或Composable函数中,而UI按钮和状态显示则由框架组件控制。这样可以保持逻辑清晰,也便于复用。
其次,关于交互体验。我们目前通过工具栏按钮切换模式,用户需要频繁点击。一个更友好的设计是采用“模态”交互,例如:
- 按下
Ctrl键临时进入“添加节点”模式,松开即退出。 - 或者在画布右侧常驻一个迷你工具栏,提供更直观的图标按钮。 这些都可以通过扩展
registerBehavior,监听keydown、keyup事件来实现。
最后,关于性能。当节点和边数量很多(比如超过500个)时,频繁的交互操作可能会感到卡顿。除了选择性能更强的renderer(如webgl)外,还可以从数据层面优化:
- 在
addItem、updateItem、removeItem时,使用graph.updateLayout({ animate: false })暂停布局动画。 - 对于复杂的样式计算,可以考虑在非主线程(Web Worker)中进行。
- 使用
graph.getNeighbors(node)等方法时,注意缓存结果。
我在最近的一个项目中使用了这套方案,从零到交付可交互原型,确实只用了不到一个下午。最大的体会是,不要一开始就追求大而全的编辑器功能。先用最核心的“增删改查”把流程跑通,让业务方看到价值,后续的样式定制、分组、嵌套、规则校验等功能,都可以在此基础上迭代添加。G6的插件化行为机制,让这种渐进式增强变得非常自然。
