1. 为什么你需要一个Web组态编辑器如果你做过工业控制、物联网监控或者流程设计相关的项目肯定遇到过这样的需求用户需要一个可视化的界面能像搭积木一样拖拽各种设备图标比如水泵、阀门、传感器然后用线条把它们连接起来形成一个完整的系统图。这个“画图”的工具就是组态编辑器。以前这类工具大多是桌面软件笨重且不易集成。现在随着Web技术的成熟在浏览器里实现一个功能强大、体验流畅的组态编辑器已经成为可能。它能无缝嵌入你的Web应用用户无需安装任何插件打开网页就能设计、配置和监控。我选择Vue3、TypeScript和AntV X6这个技术栈来构建是因为它们组合起来能产生奇妙的化学反应。Vue3的响应式和组合式API让状态管理变得清晰TypeScript提供了强大的类型安全在构建复杂交互逻辑时能极大减少低级错误而AntV X6则是一个专为图编辑而生的引擎它封装了画布、节点、连线、交互等底层细节让我们能专注于业务逻辑的实现而不是从零开始造轮子。接下来我会手把手带你从一个空白的Vue项目开始一步步搭建出一个功能完备的Web组态编辑器。我们会涵盖画布创建、自定义节点、拖拽交互、连线逻辑、对齐辅助、删除功能等核心模块并最终整合成一个工业控制面板的完整案例。即使你是前端新手跟着做下来也能掌握其精髓。2. 环境搭建与项目初始化万事开头难但把环境搭好后面就顺了。这里我们使用Vite来创建项目它比传统的Webpack更快、更轻量。2.1 创建Vue3 TypeScript项目打开你的终端执行以下命令npm create vuelatest my-web-configurator在创建过程中命令行会交互式地询问你一些配置。请务必确保选中以下选项TypeScript: YesJSX: No (我们不需要)Vue Router: 按需本例中可选NoPinia: 建议选Yes用于状态管理Vitest: 可选NoESLint: 建议选Yes保持代码规范Prettier: 建议选Yes自动格式化项目创建完成后进入目录并安装我们最核心的依赖——AntV X6cd my-web-configurator npm install antv/x6 --saveAntV X6是一个功能强大的图编辑引擎它提供了我们所需的所有底层能力。除此之外我们可能还需要一些辅助插件和UI库可以一并安装npm install antv/x6-plugin-snapline antv/x6-plugin-selection antv/x6-plugin-transform npm install ant-design-vuenext # 可选用于UI组件安装完成后你可以先运行npm run dev看看项目是否正常启动。一个干净的基础环境是成功的第一步。2.2 初始化画布你的第一块“数字绘图板”画布Graph是整个编辑器的舞台所有节点和连线都在上面表演。初始化画布看似简单但里面有很多配置项决定了编辑器的“手感”。首先我们在src/components目录下创建一个GraphCanvas.vue组件。在这个组件里我们将完成画布的初始化和核心配置。template div refcontainerRef classgraph-container/div /template script setup langts import { ref, onMounted, onUnmounted } from vue; import { Graph } from antv/x6; import { Snapline } from antv/x6-plugin-snapline; // 画布容器引用 const containerRef refHTMLElement(); // 画布实例用ref保存以便在其他地方使用 const graph refGraph(); onMounted(() { if (!containerRef.value) return; // 初始化画布实例 graph.value new Graph({ // 指定画布渲染的DOM容器 container: containerRef.value, // 画布尺寸这里设置为自适应容器。autoResize比固定宽高更灵活。 width: 100%, height: 100%, autoResize: true, // 背景色浅色背景更适合绘图 background: { color: #f8f9fa, }, // 网格辅助对齐让界面更专业 grid: { visible: true, type: doubleMesh, // 双层网格更有层次感 args: [ { color: #eee, thickness: 1 }, // 主网格 { color: #ddd, thickness: 1, factor: 4 }, // 次网格每4个主网格一个次网格 ], }, // 画布交互平移和缩放 panning: { enabled: true, eventTypes: [leftMouseDown], // 按住左键拖动画布 }, mousewheel: { enabled: true, zoomAtMousePosition: true, // 以鼠标位置为中心缩放体验更好 modifiers: [ctrl], // 按住Ctrl键滚动缩放避免和页面滚动冲突 }, // 连线交互配置后续会详细展开 connecting: { snap: { radius: 30 }, // 自动吸附半径 allowBlank: false, // 不允许连线到空白处 allowLoop: false, // 不允许自连接 highlight: true, // 高亮可连接的点 }, }); // 使用对齐线插件 graph.value.use(new Snapline({ enabled: true })); // 初始时将画布缩放至适合视图 graph.value.zoomToFit({ padding: 20, maxScale: 1 }); }); onUnmounted(() { // 组件销毁时清理画布释放内存 graph.value?.dispose(); }); /script style scoped .graph-container { width: 100%; height: 600px; /* 给一个初始高度 */ border: 1px solid #d9d9d9; border-radius: 4px; overflow: hidden; } /style代码细节解读autoResize: true 这是我强烈推荐的一个配置。设置为true后画布会自动充满其父容器。当你调整浏览器窗口大小时画布也会随之变化比固定宽高 (width: 1800, height: 1200) 灵活得多避免了出现滚动条或空白区域的问题。panning和mousewheel 这两个配置决定了画布的导航方式。我习惯设置为“左键拖拽平移”、“Ctrl滚轮缩放”。这样既符合常见设计软件的操作习惯又不会与浏览器的页面滚动冲突。grid 网格不仅仅是装饰。在拖拽节点时配合对齐线插件它能帮助用户快速将节点对齐到网格线上让排版更整齐。doubleMesh类型提供了主次网格视觉指引更清晰。插件系统 AntV X6 很多高级功能通过插件实现。这里我们引入了Snapline对齐线插件并使用graph.use()方法启用它。插件化设计让功能模块非常清晰。现在你可以在主页面引入这个组件一个具备平移、缩放、网格和对齐线辅助的空白画布就已经准备好了。这就像给你准备好了一块有标尺和辅助线的数字绘图板。3. 设计并创建你的第一个节点画布有了接下来就要往上面放东西了。在X6中万物皆“细胞”Cell节点Node和边Edge都是Cell的子类。创建一个节点本质上是定义它的外观长什么样和行为如何交互。3.1 理解节点的灵魂markup 与 attrs这是学习AntV X6节点定制时最关键的两个概念很多新手在这里绕晕。我用一个做手工的比喻来解释markup 就像你要做一个机器人模型的零件清单。清单上写着需要一个圆形脑袋(tagName: circle)一个方形身体(tagName: rect)一段文字标签(tagName: text)。markup定义了节点由哪些基本的SVG或HTML元素构成。attrs 就像这个零件的上色和装饰指南。指南里规定圆形脑袋要涂成红色(fill: red)方形身体的边框要2像素粗(stroke-width: 2)文字标签的内容是“水泵”(text: 水泵)。attrs通过选择器(selector)与markup中的元素关联为它们设置具体的样式和属性。一个常见的坑如果你自定义了markup就必须在attrs里为markup里定义的每个元素提供样式否则它不会显示。如果你不定义markupX6会使用对应shape的默认markup。3.2 实战创建一个带图标和标签的设备节点在工业场景中一个设备节点通常包含一个图标和一段说明文字。我们来创建一个这样的自定义节点。首先我们定义一个节点的配置对象。为了提高复用性我习惯把它放在一个单独的工具函数或Composable里。这里我们先在组件内定义// 定义设备节点的通用属性接口 interface DeviceNodeProps { id: string; x: number; y: number; imageUrl: string; label: string; width?: number; height?: number; } // 创建设备节点的函数 const createDeviceNode (config: DeviceNodeProps) { const { id, x, y, imageUrl, label, width 80, height 80 } config; return { id, // 节点唯一ID非常重要 shape: image, // 使用内置的‘image’形状它会帮我们处理图片加载 x, // 节点左上角x坐标 y, // 节点左上角y坐标 width, height, // 自定义 markup定义节点由一张图片和一段文字组成 markup: [ { tagName: image, selector: imageBody, // 选择器用于在attrs中定位这个元素 }, { tagName: rect, selector: labelBackground, // 文字背景框 }, { tagName: text, selector: labelText, // 文字 }, ], // 属性配置对应markup中的元素 attrs: { // 配置图片元素 imageBody: { xlink:href: imageUrl, // SVG中图片的引用方式 width: width, height: height, }, // 配置文字背景框 labelBackground: { refX: 50%, // 参考点相对于节点x方向的50%位置 refY: 100%, // 参考点相对于节点y方向的100%位置即底部 refWidth: width, // 背景宽度与节点同宽 height: 24, // 背景高度 fill: rgba(0, 0, 0, 0.7), // 半透明黑色背景 stroke: #1890ff, strokeWidth: 1, rx: 4, // 圆角 ry: 4, }, // 配置文字元素 labelText: { refX: 50%, refY: 100%, refY2: 12, // 在背景框参考点基础上再向下偏移12像素使其垂直居中 textAnchor: middle, // 文字水平居中 textVerticalAnchor: middle, // 文字垂直居中 fontSize: 12, fill: #fff, // 白色文字 text: label, // 文字内容 }, }, // 连接桩配置下一节详细讲 ports: { groups: { /* ... */ }, items: [ /* ... */ ], }, }; };定义好创建函数后我们就可以在画布初始化完成后或者响应用户操作时向画布添加节点了// 在onMounted或某个事件处理函数中 const addSampleNode () { if (!graph.value) return; const nodeConfig createDeviceNode({ id: pump_${Date.now()}, // 用时间戳确保ID唯一 x: 100, y: 100, imageUrl: /src/assets/pump.png, // 你的设备图标路径 label: 离心泵, }); graph.value.addNode(nodeConfig); };现在你的画布上应该出现了一个带图标和底部标签的设备节点。你可以尝试修改attrs里的颜色、大小、位置看看节点的变化这能帮你快速理解markup和attrs是如何协作的。4. 实现拖拽添加与连线交互一个只能代码添加节点的编辑器是不合格的。我们需要让用户能从侧边栏拖拽设备图标到画布上并且能在设备之间创建连接线。4.1 从侧边栏拖拽图片到画布这个功能的原理是利用HTML5的原生拖放APIDrag and Drop。第一步准备可拖拽的素材库我们在编辑器左侧或顶部创建一个素材库区域里面放置各种设备图标。template div classeditor-layout !-- 左侧素材库 -- div classmaterial-panel h4设备库/h4 div v-foritem in materialList :keyitem.id classmaterial-item draggabletrue !-- 关键属性使其可拖拽 -- dragstarthandleDragStart($event, item) !-- 拖拽开始 -- img :srcitem.imageUrl :altitem.label / span{{ item.label }}/span /div /div !-- 右侧画布区域 -- div classcanvas-panel GraphCanvas refgraphCanvasRef drophandleDrop !-- 监听画布上的放置事件 -- dragover.prevent !-- 必须阻止默认行为才能成为有效的放置目标 -- / /div /div /template script setup langts import { ref } from vue; import GraphCanvas from ./GraphCanvas.vue; interface MaterialItem { id: string; label: string; imageUrl: string; width: number; height: number; } const materialList refMaterialItem[]([ { id: pump, label: 水泵, imageUrl: /src/assets/pump.png, width: 80, height: 80 }, { id: valve, label: 阀门, imageUrl: /src/assets/valve.png, width: 60, height: 60 }, { id: sensor, label: 传感器, imageUrl: /src/assets/sensor.png, width: 50, height: 50 }, ]); const graphCanvasRef refInstanceTypetypeof GraphCanvas(); // 拖拽开始将设备数据存入DataTransfer对象 const handleDragStart (event: DragEvent, item: MaterialItem) { if (!event.dataTransfer) return; // 将设备数据以JSON格式存储方便在drop时获取 event.dataTransfer.setData(application/json, JSON.stringify(item)); // 设置一个自定义效果 event.dataTransfer.effectAllowed copy; }; // 在画布上放置获取数据计算位置创建节点 const handleDrop (event: DragEvent) { event.preventDefault(); if (!event.dataTransfer || !graphCanvasRef.value?.graph) return; try { const data event.dataTransfer.getData(application/json); const material: MaterialItem JSON.parse(data); // 关键将鼠标在页面上的坐标转换为画布内部的坐标 // graph.localToGraph() 或 graph.pageToLocal() 方法用于坐标转换 const graphInstance graphCanvasRef.value.graph; const point graphInstance.pageToLocal(event.pageX, event.pageY); // 调用之前定义的createDeviceNode函数 const nodeConfig createDeviceNode({ id: ${material.id}_${Date.now()}, x: point.x - material.width / 2, // 让鼠标位置在节点中心 y: point.y - material.height / 2, imageUrl: material.imageUrl, label: material.label, width: material.width, height: material.height, }); graphInstance.addNode(nodeConfig); } catch (error) { console.error(拖拽数据解析失败:, error); } }; /script关键点解析draggabletrue 这是启动HTML元素拖拽的开关。dragover.prevent 在画布容器上监听dragover事件并阻止默认行为 (prevent)这是必须的否则该区域不允许作为放置目标。dataTransfer.setData 在拖拽开始时将需要传递的数据这里是设备信息存入事件对象。我们用了application/json格式你也可以用text/plain。pageToLocal(x, y) 这是整个坐标转换的核心。event.pageX/Y获取的是鼠标相对于整个文档的坐标。而画布有自己的坐标系和可能存在的缩放、平移。pageToLocal方法能将页面坐标精确地转换到画布自身的坐标系中确保节点被放在你鼠标松开的位置。4.2 为节点添加连接桩Ports连接桩是节点上用于连线的锚点。没有连接桩节点之间就无法建立规范的连接。// 接上面的 createDeviceNode 函数补充 ports 配置 ports: { // 定义连接桩组可以定义多组如左侧一组右侧一组 groups: { top: { position: top, // 位置在节点顶部 attrs: { circle: { r: 4, // 连接桩圆圈半径 magnet: true, // 关键必须为true才能被连线 stroke: #1890ff, strokeWidth: 2, fill: #fff, }, }, }, right: { position: right, attrs: { circle: { r: 4, magnet: true, stroke: #1890ff, strokeWidth: 2, fill: #fff, }, }, }, bottom: { position: bottom, attrs: { circle: { r: 4, magnet: true, stroke: #1890ff, strokeWidth: 2, fill: #fff, }, }, }, left: { position: left, attrs: { circle: { r: 4, magnet: true, stroke: #1890ff, strokeWidth: 2, fill: #fff, }, }, }, }, // 在组的基础上定义具体的连接桩实例 items: [ { group: top }, { group: right }, { group: bottom }, { group: left }, ], },这样每个节点的上下左右四个方向都会有一个小小的蓝色圆圈这就是连接桩。当鼠标靠近时它会高亮因为我们之前在画布配置中设置了connecting: { highlight: true }提示用户可以从此处开始或结束连线。4.3 实现连线与交互优化有了连接桩用户就可以在节点之间拖拽创建连线了。但我们可以让体验更好。连线样式定制默认的连线是简单的黑线我们可以让它更美观比如变成带箭头的蓝色曲线。// 在创建画布 Graph 的配置中补充边的默认样式 new Graph({ // ... 其他配置 connecting: { // ... 之前的snap, allowBlank等配置 connector: { name: rounded }, // 连接器圆角 router: { name: manhattan }, // 路由器曼哈顿路由直角连线 connectionPoint: boundary, // 连接点在节点边界上 anchor: center, // 锚点中心 }, // 设置全局边的默认属性 edge: { attrs: { line: { stroke: #1890ff, // 线条颜色 strokeWidth: 2, targetMarker: { // 目标箭头 name: block, size: 8, fill: #1890ff, }, }, }, // 边的默认交互 interaction: { edgeMovable: true, // 允许拖动边 edgeLabelMovable: true, // 允许拖动边上的标签 }, }, });连线交互事件监听我们经常需要在连线创建、改变或删除时执行一些操作比如更新后端数据、验证连接是否合法。// 在画布初始化后监听边的事件 graph.value.on(edge:connected, ({ edge }) { console.log(连线已创建, edge); const sourceNode edge.getSourceNode(); const targetNode edge.getTargetNode(); // 可以在这里进行业务逻辑验证比如水泵不能直接连水泵 if (sourceNode targetNode) { // 获取连线数据可用于保存 const connectionData { source: sourceNode.id, target: targetNode.id, sourcePort: edge.getSourcePortId(), targetPort: edge.getTargetPortId(), }; console.log(连接数据:, connectionData); } }); graph.value.on(edge:removed, ({ edge }) { console.log(连线被删除, edge.id); // 同步更新你的数据模型 });5. 完善编辑器对齐、选择与删除一个专业的编辑器还需要一些提升效率的工具。5.1 使用对齐线(Snapline)插件我们在初始化时已经启用了对齐线插件。它的效果是当你拖拽一个节点靠近另一个节点或画布边缘时会自动出现一条对齐线通常是红色或绿色松开鼠标节点就会自动吸附对齐。这对绘制整齐的图纸至关重要。配置非常简单效果立竿见影。5.2 实现节点与连线的选择、删除用户需要能选中某个节点或连线并进行操作如删除、编辑属性。方案一使用选择插件(Selection)这是最快捷的方式。安装并启用antv/x6-plugin-selection插件。import { Selection } from antv/x6-plugin-selection; // 在画布初始化后 graph.value.use( new Selection({ enabled: true, multiple: true, // 允许多选 rubberband: true, // 启用框选 movable: true, // 选择框是否可移动 showNodeSelectionBox: true, // 显示节点选择框 }) ); // 现在用户可以用鼠标点击选择或者拖拽框选多个元素。 // 获取当前选中的单元格 const selectedCells graph.value.getSelectedCells(); // 删除选中项 graph.value.removeCells(selectedCells);方案二自定义交互更灵活如果你想完全控制选中样式和交互逻辑可以手动监听事件。我们在原始文章里看到了一种实现鼠标移入显示连接桩点击节点显示边框和删除按钮。这里我优化一下那个逻辑使其更健壮// 当前选中的节点 const currentSelectedNode refNode(); // 监听画布点击事件 graph.value.on(blank:click, () { // 点击画布空白处取消所有选中 graph.value?.clearCells(); currentSelectedNode.value undefined; }); graph.value.on(node:click, ({ node }) { // 清除上一个选中节点的样式 if (currentSelectedNode.value currentSelectedNode.value.id ! node.id) { currentSelectedNode.value.removeTools(); } // 为当前点击的节点添加工具边框和删除按钮 node.addTools([ { name: boundary, // 边界工具高亮边框 args: { attrs: { fill: transparent, stroke: #1890ff, strokeWidth: 2, strokeDasharray: 5,5, // 虚线边框 }, }, }, { name: button-remove, // 删除按钮工具 args: { x: 100%, y: 0, offset: { x: -20, y: 10 }, // 微调按钮位置 }, }, ]); currentSelectedNode.value node; }); // 监听由工具触发的删除事件按钮点击 graph.value.on(node:remove, ({ node }) { if (confirm(确定要删除这个节点吗)) { graph.value?.removeCell(node); } }); // 对于连线可以采用悬浮高亮显示删除按钮的方式 graph.value.on(edge:mouseenter, ({ edge }) { edge.addTools([ { name: button-remove, args: { distance: -40 }, // 在连线上距离起点-40px的位置显示按钮 }, ]); edge.setAttrs({ line: { stroke: #ff4d4f } }); // 高亮为红色 }); graph.value.on(edge:mouseleave, ({ edge }) { edge.removeTools(); edge.setAttrs({ line: { stroke: #1890ff } }); // 恢复蓝色 });这种自定义方式虽然代码量稍多但交互细节完全由你掌控可以做出更符合产品需求的效果。6. 完整案例构建一个简易的工业控制面板现在让我们把前面所有的知识点串联起来构建一个简易的“水泵-阀门-传感器”控制系统面板。场景描述用户可以从左侧物料区拖拽“水泵”、“阀门”、“传感器”到中间画布。水泵有出水口右侧连接桩阀门有进口和出口左、右连接桩传感器有输入口左侧连接桩。它们之间可以按逻辑连线如水泵出口 - 阀门进口 - 传感器输入。画布提供对齐辅助可以框选多个设备批量删除。实现步骤定义物料数据 如前面所示每个物料定义好图片、标签、默认尺寸和允许的连接桩位置例如水泵只有right组。创建画布与交互 使用我们之前写好的GraphCanvas组件配置好网格、对齐线、连线规则allowEdge: false确保必须连接到连接桩。实现拖拽入库 使用handleDragStart和handleDrop逻辑。定制节点创建 在createDeviceNode函数中根据物料类型 (id) 动态分配不同的ports.groups和items。例如水泵只生成right组的连接桩。添加上下文工具栏 在画布上方或右键菜单添加工具栏包含“撤销”、“重做”、“清空画布”、“导出图片”等功能。AntV X6 有对应的 History历史记录和 Export导出插件。数据序列化与保存 最重要的功能之一。当用户设计好面板后需要能保存这个“图”。// 获取整个图的JSON数据 const saveGraphData () { if (!graph.value) return; const data graph.value.toJSON(); console.log(图谱数据:, data); // 可以将 data 保存到后端数据库 localStorage.setItem(my-control-panel, JSON.stringify(data)); }; // 从JSON数据加载图 const loadGraphData () { const dataStr localStorage.getItem(my-control-panel); if (!dataStr || !graph.value) return; const data JSON.parse(dataStr); graph.value.fromJSON(data); };toJSON()和fromJSON()方法非常强大它们能完整地序列化和反序列化画布上所有节点、连线以及它们的样式、位置信息。这是实现“保存”和“打开”功能的关键。踩坑提醒节点ID唯一性 在拖拽创建或加载数据时务必确保每个节点的id是唯一的否则会发生覆盖。图片路径 如果图片是动态路径比如来自用户上传需要确保在序列化和反序列化后路径仍然有效。有时需要将图片转为Base64编码或上传到服务器使用URL。性能 当节点数量非常多比如超过500个时需要注意性能。可以开启画布的async渲染模式或对不在视图内的节点进行虚拟渲染。走到这一步你已经拥有了一个功能核心完备的Web组态编辑器。它可能没有商业软件那么华丽但骨架已经非常扎实。你可以在此基础上继续添加属性面板点击节点编辑其参数、图层管理、组合/解散节点、导入导出特定格式如SVG、PNG等功能。