简介:这是一套基于Antv-X6与Vue技术栈构建的轻量级组态编辑器源码,面向前端开发者及工业可视化项目工程师,解决图形化拖拽建模、管线连接、数据绑定与JSON导出等核心需求。资源包共27个文件,含14个JavaScript逻辑文件(实现节点管理、连线交互与状态同步)、2个Vue组件(封装编辑器主界面与元件面板)、2个JSON配置文件(定义元件元数据与默认布局)、2个PNG图标资源,以及babel、ESLint、EditorConfig等开发配置文件,整体压缩后仅566KB,结构清晰、开箱即用。已有379人学习下载,读者可直接运行调试,掌握Antv-X6图编辑器集成方案,复用拖拽布局、样式调整、实时预览与JSON序列化能力,并基于现有模块快速扩展自定义元件或对接IoT数据源。 年前接了一个内部监控平台的需求,要把车间里的设备状态、管路走向、阀门位置全部搬到网页上,做成一个可拖拽、可连线的组态页面。说白了,就是用户想要一个“网页版画图工具”,但这个画图工具还得能对接实时数据、保存场景、支持多人维护。当时第一反应是找现成的库,对比了一圈之后,我选了 AntV X6,基于它把整套组态编辑器源码工程搭了出来:侧边栏图元库、拖拽落画布、连线、属性面板、序列化保存、后端回读,一条链路全部打通。这篇文章就把这个源码项目的设计思路和关键实现从头到尾拆一遍,适合正在选型或者准备自研组态编辑器的前端同学参考。
1. 整体设计:为什么是 X6,不是 G6,不是自己画 SVG
1.1 组态编辑器到底要解决哪些问题
先别急着看代码,我建议先从场景倒推需求。组态编辑器不是简单的“拖个框、拉条线”,它背后其实有三层诉求。
第一层是图元编辑能力。用户要能自由创建设备节点、拖动位置、调整大小、旋转方向、连接管道,这些操作必须足够流畅,不能像传统表单那样输入坐标去“编辑”。第二层是数据绑定能力。每个图元本质上都是一个业务对象的可视化载体,背后挂着设备编号、实时数值、报警状态,属性面板里改了信息,图元上的文字、颜色要立刻跟着变。第三层是场景持久化能力。用户画完一张图要能保存,下次打开还能还原,这就要求画布里的所有信息都能序列化成结构化数据,再反序列化还原。
想清楚这三点之后,结论其实很明确:我需要一个成熟的图编辑引擎,而不是一个普通的可视化图表库。自己用 SVG 从零造轮子,光是框选、多选、拖拽吸附、撤销重做这些交互,就够写上半年,而且不一定做得比现成引擎好。
1.2 对比选型:X6 的核心优势在哪里
当时我在 X6、G6、JointJS、LogicFlow 这几个方案之间犹豫了很久,也搜了不少资料。这里直接说结论,方便你少走弯路。
G6 是蚂蚁的图可视化引擎,强在数据可视化分析、复杂图布局,但它本质上是“展示”导向的,交互编辑能力相对弱。JointJS 非常强大,但文档和生态偏学院派,遇到问题想找人问都难。LogicFlow 是做流程图出身的,体验很好,但自定义图元的自由度、底层渲染的可控性不如 X6;如果要做工业组态这种高度定制化的场景,LogicFlow 会比较吃力。X6 的优势在于它同时兼顾了渲染能力和编辑能力,图元可以通过自定义 SVG 或 HTML 渲染,交互上自带框选、对齐线、小地图、撤销重做、拖拽插件,而且它是框架无关的,React、Vue、原生 JS 都能接,扩展空间非常大。
另外一点很实际:X6 是蚂蚁金服开源的,社区活跃,文档虽然偶尔有些小坑,但基本都能搜到答案。对比之下,商业库的授权成本、小众库的维护风险,这些隐性成本都得算进选型里。
1.3 源码工程的整体架构
回到这个源码项目本身。我用了 Vue 3 + TypeScript + Vite 做外层框架,X6 作为图编辑内核。之所以选 Vue 3,是因为项目里其他系统都是 Vue 技术栈,组件复用方便;如果你更喜欢 React,X6 一样支持,架构思路完全通用。
工程拆成三大块。视图层负责渲染工具栏、左侧图元库、画布容器、右侧属性面板;核心层负责初始化 X6 Graph 实例、注册自定义节点、装配插件;数据层负责把画布 JSON 同步到 state 管理,再持久化到后端接口。这三层通过事件通知联动,画布里拖个节点,右侧属性面板马上响应;属性面板改个颜色,画布节点立刻刷新。整个源码工程在这个基础上展开,文件结构大致是这样的:
src/ components/ Toolbar/ // 顶部工具栏:撤销、重做、缩放、预览、保存 NodePanel/ // 左侧图元库,可拖拽的图元列表 PropertyPanel/ // 右侧属性面板,联动选中节点 graph/ index.ts // 初始化 X6 Graph 实例,全局唯一 nodes/ // 自定义图元:设备、阀门、管道标注等 edges/ // 自定义连线:不同状态下的管道样式 store/ graphStore.ts // 维护画布 JSON 数据,与后端交互2. 核心功能拆解:从画布初始化到业务图元绑定
2.1 画布基础配置:网格、缩放、滚动画布
X6 的 Graph 实例是整个编辑器的地基。初始化看起来很简单,但要配置得舒服,几个参数必须认真对待。
网格一定要开。组态场景里,用户拖节点总希望整齐一点,网格对齐是刚需。我设置了 size: 10 的小网格,配合画布背景,视觉上既不会太密影响注意力,又能提供对齐参考。缩放这边,我开启了鼠标滚轮缩放,并且设置了 zoomAtMousePosition: true,这样缩放时视角会以鼠标位置为中心,体验非常跟手。平移我用了右键拖拽,因为工业组态图经常很大,左键留给框选和拖动图元,右键平移更符合用户直觉。
面板和画布容器之间我用 flex 布局,画布容器必须设置显式高度,否则 X6 渲染不出来,这个坑新手很容易踩。画布初始化之后,我还接入了 Snapline 对齐线和 Selection 框选插件。对齐线在拖拽图元时自动吸附边缘和中心,框选则支持按住 Shift 加选,这两个插件是提升编辑效率的关键,几乎每个使用者都会用到,建议默认开启。
import { Graph } from '@antv/x6'; import { Snapline } from '@antv/x6-plugin-snapline'; import { Selection } from '@antv/x6-plugin-selection'; const graph = new Graph({ container: document.getElementById('container') as HTMLElement, autoResize: true, grid: { visible: true, size: 10, type: 'dot', }, background: { color: '#f8f9fa', }, panning: { enabled: true, eventTypes: ['rightMouseDown'], }, mousewheel: { enabled: true, modifiers: ['ctrl', 'meta'], minScale: 0.2, maxScale: 3, zoomAtMousePosition: true, }, connecting: { router: 'manhattan', connector: 'rounded', snap: true, allowBlank: true, allowMulti: false, }, }); graph.use(new Snapline({ enabled: true })); graph.use(new Selection({ enabled: true, multiple: true, rubberband: true, showNodeSelectionBox: true, }));connecting 配置是我重点调过的一块。router 设置了 manhattan,意思是连线走“曼哈顿路径”,不会直接从节点中间穿过去,而是绕行像城市街道一样,这在组态图里非常重要,工业管路都是走直角,不能斜穿设备。snap 开了连线吸附,新增连线时会自动捕捉附近的连接桩,这个对新手用户特别友好。
2.2 自定义业务图元:让节点能表达设备状态
X6 内置了 rect、circle、ellipse 这些基础形状,但组态编辑器里直接拿这些当设备节点肯定是撑不住场面的。所以源码项目的核心工作之一,就是用 Node.define 自定义一套业务图元。
以最常见的“设备”节点为例。它需要有一个矩形主体、一个标题文本、一个状态指示灯,还有若干连接桩。用 X6 的 markup 机制,我可以自由组合 SVG 标签,再通过 attrs 给每个标签分配样式。更实用的做法是把业务数据塞进 data 字段,比如 deviceId、status、value,然后通过节点更新方法让这些字段实时驱动界面显示。
我封装节点的时候,要求所有业务节点统一暴露 updateByData 方法。这个方法接收设备实时数据,如果状态是“运行中”,指示灯变成绿色;如果是“报警”,变成红色并闪烁;数值文本直接更新。这样往上对接设备数据、往下联动画布渲染,逻辑就非常清晰了,不会出现“数据到了但节点不知道更新”的情况。
import { Node } from '@antv/x6'; export const DeviceNode = Node.define({ name: 'device-node', width: 120, height: 80, markup: [ { tagName: 'rect', selector: 'body' }, { tagName: 'rect', selector: 'statusRect' }, { tagName: 'text', selector: 'title' }, { tagName: 'text', selector: 'value' }, ], attrs: { body: { fill: '#ffffff', stroke: '#d9d9d9', strokeWidth: 1, rx: 6, ry: 6, }, statusRect: { x: 8, y: 8, width: 12, height: 12, rx: 3, fill: '#52c41a', }, title: { x: 28, y: 18, fontSize: 14, fill: '#333333', text: '设备', }, value: { x: 10, y: 50, fontSize: 12, fill: '#666666', text: '--', }, }, ports: { groups: { left: { position: 'left', attrs: { circle: { r: 4, magnet: true, fill: '#fff', stroke: '#999' } } }, right: { position: 'right', attrs: { circle: { r: 4, magnet: true, fill: '#fff', stroke: '#999' } } }, top: { position: 'top', attrs: { circle: { r: 4, magnet: true, fill: '#fff', stroke: '#999' } } }, bottom: { position: 'bottom', attrs: { circle: { r: 4, magnet: true, fill: '#fff', stroke: '#999' } } }, }, items: [ { group: 'left', id: 'in' }, { group: 'right', id: 'out' }, { group: 'top', id: 'top' }, { group: 'bottom', id: 'bottom' }, ], }, });连接桩的设计也花了不少心思。每个设备节点上下左右都留了连接桩,但并不是所有场景都需要四个方向。阀门、传感器这种图元,我会按实际需求精简连接桩数量,不然画布上密密麻麻全是空心圆点,视觉噪音太重。连接桩的 magnet 属性决定它是否能被连线吸附,这个在自定义时要注意,不需要接线的桩就别设置成磁吸,否则用户拉线会莫名其妙被“粘住”。
2.3 拖拽生成图元:从图元库到画布
图元库拖拽是组态编辑器最直观的操作入口。X6 官方提供了 Dnd 插件,专门处理“从面板拖一个节点到画布上放下”的场景,省了我大量事件绑定工作。
用法很直接,给 Dnd 传入目标 graph,然后监听从图元库容器的 mousedown 事件,调用 dnd.start(node, e) 开启拖拽。拖拽过程中 X6 会在画布上生成一个半透明的预览节点,放到目标位置后触发 node:added 事件,我再把业务默认值填进去。
这里有一个关键参数 scaled: true。因为画布本身有缩放比例,如果拖拽时不把缩放因素算进去,从图元库拖进来的节点尺寸会被缩放干扰,视觉上要么偏大要么偏小。我在源码里专门测过,把 scaled 设为 true 之后,拖进来的节点尺寸与实际尺寸完全一致,这个问题就消失了。另一个细节是拖拽结束后,如果用户把节点拖到画布外释放,X6 不会留下任何残留节点,这个行为官方默认就是对的,不用额外处理。
import { Dnd } from '@antv/x6-plugin-dnd'; import { DeviceNode } from './nodes/device'; const dnd = new Dnd({ target: graph, scaled: true, }); nodePanelItems.forEach((item) => { item.element.addEventListener('mousedown', (e) => { const node = graph.createNode({ shape: item.shape, data: item.defaultData, }); dnd.start(node, e); }); });2.4 属性面板联动:选中什么,就编辑什么
属性面板是组态编辑器里最容易做得难用的地方。我见过不少实现,画布和属性面板各管各的,一边改了节点属性,另一边完全没反应。这个源码项目里,我坚持一条原则:画布是唯一数据源,属性面板只是视图。
具体实现是监听 graph 的 cell:click 和 blank:click 事件。点击节点时,取出节点数据,设置给属性面板的响应式对象;用户修改任意字段,立刻通过节点 updater 写回画布,同时更新 store 里的 JSON 快照。这样无论用户是通过拖拽、缩放,还是属性面板修改节点,最终的数据状态永远一致。
页面初始化的时候,还要处理“未选中任何节点”的状态。属性面板显示一个空状态提示,而不是留白,这个交互细节很影响使用体验。另外,多选节点时属性面板要锁定,避免用户改 A 节点属性结果同步改到了 B 节点上,这个逻辑我在源码里做了明确分支。
3. 关键模块实操:序列化、历史重做、大数据量渲染
3.1 画布 JSON 序列化与恢复:保存和回读的生命线
组态编辑器最重要的能力之一,就是把画布完整保存下来、再完整恢复。X6 原生提供了 toJSON 和 fromJSON 两个方法,我基于它们封装了快照的导出和导入。
序列化的时候有一个特别要小心的点:业务数据字段必须都放在 data 里。X6 序列化只保证结构字段,比如 position、size、ports 这些,自定义业务字段如果不放进 data,反序列化之后就丢了。我在封装节点时强制约定,所有业务数据必须挂载在 data 下,包括设备编号、名称、状态、连线配置等。这样 toJSON 之后,整个画布结构连同业务数据就是一个纯净的 JSON 对象,可以直接存数据库,也可以导出成文件。
反序列化的过程有个排序问题。fromJSON 时如果节点数量很大,我建议先加节点、再连边。否则边可能会在节点还没渲染完成时试图连接,虽然 X6 会自动等待,但视觉上会出现边先出现、节点后出现的闪烁。我从项目初期就按“先节点、后边”的顺序执行,实测稳定很多。
// 保存快照 const snapshot = graph.toJSON(); // 恢复快照 graph.fromJSON(snapshot);就这么两个方法,但“保存”和“恢复”中间隔了多少细节,只有自己做一遍才体会得到。比如保存时机,我用的是 debounce 防抖,用户连续拖拽过程中不保存,等停顿 500ms 再触发,避免频繁请求后端。恢复的时候则要注意清空画布,直接用 fromJSON 会叠加在现有画布上,必须先调用 graph.clearCells() 清掉旧节点。
3.2 撤销重做:History 插件与自定义快照的选择
X6 官方提供了 History 插件,能记录画布操作,支持 undo 和 redo。对组态编辑器来说,这个功能基本是标配,用户画错了要能回退,而且回退不能破坏已有的业务数据绑定。
我最初直接用官方 History 插件,后来发现一个问题:当程序内部批量更新节点属性时,这些操作也会被记进历史栈里,用户按一次撤销,可能不是回到“上一次用户操作”,而是回到“上一次程序内部更新”,体验非常割裂。解决办法是给内部更新包一层 batch,让这些操作在历史记录里合并成一步,或者在调用 mutate 时指定不记录历史。踩过这个坑之后我才意识到,History 插件不是开了就完事,必须结合业务操作精细化配置。
如果你对历史记录有更复杂的要求,比如要按“用户操作前后业务状态”做快照,而不是按“节点结构变化”做快照,那建议自己实现一个简单的快照栈,每次保存前推入当前快照,用状态更新的方式触发撤销重做。这个方案比插件更可控,代价是实现量稍大。我的源码项目里选了官方插件加自定义 batch 的方式,够用且维护成本低。
3.3 批量添加节点时的渲染性能优化
组态图有时候会很大,比如一个车间几十台设备、上百条连线。一次性加载全部数据时,一条条 addNode、addEdge 会导致每一节点都触发一次重绘,页面会明显卡顿。X6 提供了批量操作的机制,可以暂时挂起画布渲染,等所有节点加完一次性刷新。
我在源码里封装了一个 loadScene 方法,先用 model.startBatch 把画布更新挂起,批量添加节点和边,最后 stopBatch 恢复渲染。实测在 200 个节点、400 条边的场景下,整体加载耗时从原来的接近 3 秒降到了不到 1 秒,体感非常明显。
graph.model.startBatch('load-scene'); try { nodes.forEach((item) => graph.addNode(transformNode(item))); edges.forEach((item) => graph.addEdge(transformEdge(item))); } finally { graph.model.stopBatch('load-scene'); }批量操作里还有一个顺序细节:边引用的节点 id 必须先存在。如果后端返回的数据里边的 id 引用缺失,with fromJSON 会静默失败,排查起来非常痛苦。所以我在 loadScene 前面加了一层数据校验,先遍历 edges,把引用不到节点的边过滤掉,再进画布,宁可少一条边,也不能让整张画布加载失败。
3.4 自定义连线样式:让管路“会说话”
组态图里的连线不只是直线,不同物料、不同状态需要用不同颜色和线型区分。我在源码里封装了多种自定义连线,核心是 Edge.define。
连线样式我通过 data 字段驱动:data.status 是 normal 就画蓝色实线,是 alarm 就画红色虚线,并在边上显示一个脉冲动画效果。X6 支持在 Edge 的 markup 里塞任意 SVG 标签,所以我在边上多加了一条用于动画的 path,配合 CSS animation 实现流动效果。这个在实际监控大屏上非常加分,管线是否有流量一图就能看出来。
连线标签也是组态里的高频需求,比如管道编号、介质名称、温度。X6 的 Edge 原生支持 label 配置,我封装了一个 addEdgeLabel 方法,双击连线就能编辑标签文本,失焦后自动保存。这个交互客户非常喜欢,因为它符合直觉:想改哪里就点哪里。
import { Edge } from '@antv/x6'; export const ProcessEdge = Edge.define({ name: 'process-edge', markup: [ { tagName: 'path', selector: 'line' }, { tagName: 'path', selector: 'flowLine', attrs: { fill: 'none' } }, ], attrs: { line: { connection: true, stroke: '#4096ff', strokeWidth: 2, fill: 'none', strokeLinejoin: 'round', }, flowLine: { connection: true, stroke: '#fff', strokeWidth: 1, fill: 'none', strokeDasharray: '4 4', opacity: 0.6, }, }, });4. 踩坑记录与常见问题排查
4.1 画布内容不显示:容器尺寸和初始化时机
遇到过好几次这种情况:Graph 初始化没有报错,但页面上就是一片空白。排查到最后,十有八九是容器尺寸问题。X6 在初始化时会读取容器宽高,如果容器当时是隐藏状态或高度为 0,渲染结果就是空的。所以画布容器所在的 Tab 页,或者有懒加载逻辑的时候,一定要等容器可见且有尺寸后再初始化 Graph。如果是 flex 布局,还要确认子容器没有被压缩,可以在容器上强制设置一个 min-height 或者明确的 height。
另外,如果页面用的是路由懒加载,组件销毁重建时,Graph 实例销毁不干净也会导致内存泄漏、二次进入时白屏。需要在 onUnmounted 里调用 graph.dispose(),把事件监听和实例都释放掉。这个细节很容易被忽略,我在源码里做了统一处理。
4.2 拖拽出来的节点总是偏位或尺寸不对
Dnd 拖拽出来的图元如果偏位,大概率是 scaled 参数没设置。前面说了,画布缩放后,拖拽预览和落点坐标需要缩放适配,scaled: true 能解决。
还有另一种情况是图元库面板本身有滚动条,拖拽时坐标计算要减掉面板滚动偏移。Dnd 内部虽然处理了一部分,但如果图元库容器被包了一层带 transform 的组件,坐标就会算偏。这个情况在 Vue 的 transition 动画中特别容易出现。我的解决办法是图元库容器不用 transform 做位移动画,根因规避,不在事件回调里做各种 offset 修正,省了很多麻烦。
4.3 连线连不上或者连错连接桩
X6 的连接桩有 magnet 属性,只有 magnet: true 的桩才能被连线吸附。如果自定义节点时忘记给端口圆点设置 magnet 属性,用户就会发现怎么也连不上线。我排查过一次,问题就出在 ports.groups 的 attrs 里漏了 magnet。
另一种情况是连接桩定位不准。默认的 position 有 left、right、top、bottom 四种,但工业组态里经常需要特定角度的连接口,比如 45 度斜向的管道。X6 支持传入函数自定义连接桩坐标,我在源码里实现过一版根据相对位置计算坐标的函数,可以精确控制每个连接桩在节点边缘上的位置。如果只是常规上下左右,用内置 position 就够了。
4.4 大数据量场景下缩放和拖拽卡顿
组态图随着业务越做越大,几百个节点是常态,这时候性能问题就暴露了。我发现卡顿主要来自两个地方:一是节点里嵌入了复杂的 SVG 模板,每个节点几十个 DOM 元素,几百个节点就是上万元素,浏览器渲染压力很大;二是框选、拖拽过程中频繁触发的重绘。
针对第一个问题,我的思路是“按需渲染”:节点在默认缩放下显示简化版本,只保留标题和状态灯;当用户放大到一定比例,再切换到包含全部细节的版本。用 X6 的 scale 事件监听实现对节点数据详情的动态注入,这样平时操作不掉帧,需要看细节时放大也有内容。针对第二个问题,批量操作加节流是常规手段,拖拽过程中不实时保存样式变化,等拖拽结束再统一更新。
5. 从源码到生产:工程化落地与扩展建议
5.1 与后端接口的对接规范
组态编辑器的价值,一半在前端交互,一半在数据链路。保存画布时,前端提交一个 JSON;加载场景时,后端把这个 JSON 原样返回。这个接口看起来简单,但一定要提前定好版本号字段。因为图元定义随时会升级,如果新版本画布数据被老版本代码读取,可能出现结构不兼容。我在快照结构里加了一个 schemaVersion 字段,读取时检查版本,低于当前版本的走迁移函数,这样即使以后图元定义改了,历史场景也能正常打开。
还要注意 JSON 数据的体积。几百个节点的画布 JSON 动辄几百 KB,全量保存每次都会产生不小的网络开销。优化思路是只保存与默认值不同的字段,加载时用默认值填补缺省字段。这个压缩策略我在源码里实现了,实际体积能减少 60% 左右。
5.2 基于现有源码二次开发的技巧
如果直接拿这套源码去改业务,我建议优先改三个地方:节点定义、数据映射、属性面板表单。节点定义决定画布里能看到什么图元,数据映射决定设备数据如何驱动图元显示,属性面板决定业务人员能改哪些字段。这三块是组态编辑器的业务核心,也是最容易和具体行业结合的地方。
不要急着改 X6 的内核配置,比如网格类型、缩放范围、连线路由,这些其实用官方配置项就能覆盖。真遇到特殊交互需求,可以先看官方有没有出插件,比如测量工具、自定义工具按钮,如果没有,再考虑在插件层面自己扩展。能不改核心库就不改,否则后续升级 X6 版本时会非常痛苦。
5.3 编辑器后续还能扩展什么方向
这套架构的扩展空间其实很大。可以考虑接入数据源配置面板,让业务人员直接给图元绑定接口地址,这样同一个图元既能显示静态数据,也能动态轮询实时数据。架构上只需要在属性面板增加一个“数据绑定”Tab,存到 data 字段里,运行时由统一的数据管理模块按配置拉取和推送。这套做法在这个组态源码里已经预留了接口,只是默认没有开工。
也可以做组态模板市场,把常用场景比如办公区布局、机房拓扑、车间工艺流沉淀成模板,新项目从模板起步,效率会高很多。实现思路其实不复杂,模板本质就是一段 JSON 快照,配合缩略图,在图元库里增加一个“模板”分组就可以。
再往深了做,多人协作编辑也不是不可能。把 X6 的每次操作转成类似 CRDT 的增量指令,通过 WebSocket 广播,就能实现多人同时编辑一个场景。这个方向比起从零写协作框架要省力得多,因为图编辑的操作类型是有限的,可枚举的指令集天然适合做协同传输。
6. 个人实操体会与建议
最后聊一点不太能在官方文档里看到的东西。做组态编辑器,技术难度其实不是最耗时的部分,X6 把底层渲染和基础交互都处理得很好了,真正花时间的是“业务建模”。图元应该有哪些属性、连线应该表达什么语义、保存的 JSON 怎么设计才能兼容以后的升级,这些比调一个动画效果重要得多。我建议准备自研组态编辑器的团队,先把业务数据模型定义清楚,再动 UI 和交互,不要先画图再想数据怎么存。
另外,好的组态编辑器一定是从用户反馈里“磨”出来的。第一版做出来,让真正的车间操作员、调试工程师用两天,你会发现他们对“连接桩位置”“右键菜单”“键盘快捷键”这些细节的敏感度远超想象。X6 几乎都能覆盖这些交互,只是需要你在源码里一点一点配置和打磨。这个过程很琐碎,但做完了,项目的价值绝对不止一个“画图工具”那么简单。
本文还有配套的精品资源,点击获取