Vue3+SVG+MQTT:实时组态监控可视化系统搭建指南

发布时间:2026/9/16 21:04:09
Vue3+SVG+MQTT:实时组态监控可视化系统搭建指南 简介这套基于Vue 3构建的SVG可视化Web组态编辑器源码面向Web前端与物联网平台开发人员可快速搭建MQTT实时系统图场景支持在不修改核心代码的前提下动态添加SVG组件兼具独立编辑器与组态引擎库双重属性。压缩包共172个文件、约916KB以66个SVG图元、51个Vue组件、32个TypeScript文件为主辅以JSON配置、CSS样式、PNG素材及HTML入口文件SVG用于定义可复用图元Vue组件封装拖拽、画布与节点交互TS与JSON分别提供类型约束和运行配置整体结构轻量且易于集成。除完整编辑器代码外还附有自定义动画样式、GIF操作演示与ESLint、TypeScript等基础工程配置可帮助读者理解组件动态注册、图元属性编辑以及MQTT实时数据绑定等核心实现思路便于二次开发时直接嵌入自有物联网管理平台。已有199人学习下载适合具备一定Vue基础、希望缩短组态系统开发周期的前端工程师查阅参考。1. Web 组态为什么押注 SVGMQTT 实时系统图的可视化解题思路物联网大屏、SCADA 系统图、能源监控看板这类场景往往要在同一个画布上展示几十个设备图元并且让状态值随现场数据实时刷新。组态编辑器选型时很多人第一反应是 Canvas 或者直接堆 DOM。但实际做下来会发现Canvas 的形状拾取和文字测量要自己实现DOM 的 div 嵌套又太重图元交互和动画写起来像在维护一套 CSS 私有体系。这个项目选择 Vue3 作为组态引擎把所有 SVG 图元封装成可动态注册的组件配合 MQTT 消息映射做到“新增图元不改代码、实时数据自动驱动”。适合正在做物联网平台前端、需要给设备组态页面提供可视化编辑能力的工程师也适合想研究 Vue3 动态组件和 SVG 结合的读者。2. Vue3 动态组件注册把 SVG 图元变成可扩展的组态引擎2.1 为什么要把 SVG 包装成 Vue 组件一个 SVG 图元本质上是一组形状和路径。直接在 template 里堆circle、path当然能画出来但一旦涉及状态切换比如电机 running 变绿色、stopped 变灰色就要在多个属性之间手动同步。把 SVG 包装成 Vue SFC让颜色、坐标、状态都成为组件的 props外部只负责改数据图形自然跟着变。这样做还有一个隐性收益Vue3 的响应式系统本身就是一套属性状态管理器省掉了自己写事件总线或全局监听器的麻烦。这里有一个关键选型不要直接用fetch拉取 svg 文件然后设置innerHTML。SVG 内部元素的跨文档样式隔离和事件绑定都很别扭。常见做法是把每个图元做成一个.vue文件放到src/nodes/目录构建时用import.meta.glob自动扫描注册。这样新增图元只需要复制一个 SFC 放进去主程序不用改动一行。2.2 用 import.meta.glob 自动发现图元组件import.meta.glob是 Vite 提供的构建时文件系统扫描能力。下面这段代码把nodes/*.vue文件映射成一个type - component的注册表// src/node-registry.ts export type NodeType valve | pump | sensor | tank; const nodeModules import.meta.glob(./nodes/*.vue, { eager: true }); export function resolveSvgComponent(type: NodeType) { const key ./nodes/${type}.vue; const mod nodeModules[key]; return mod?.default; }这段代码的核心是eager: true它让 Vite 在应用初始化时同步加载所有匹配的组件避免首次渲染图元时出现异步空白。resolveSvgComponent根据type字符串返回对应的 Vue 组件对象画布里的component :is拿到这个对象就能动态渲染。如果type拼写错误返回undefined模板会静默跳过因此这里建议在返回前加一条console.warn方便排查配置错误的图元类型。2.3 一个最小可运行的 SVG 图元组件以阀门为例在src/nodes/Valve.vue中定义一个标准 Vue3 组合式 API 组件通过defineProps声明可编辑属性并在 template 里用 SVG 标签绘制阀门形状!-- src/nodes/Valve.vue -- script setup langts import { computed } from vue; const props withDefaults(defineProps{ x?: number; y?: number; rotation?: number; fill?: string; stroke?: string; status?: running | stopped; }(), { x: 0, y: 0, rotation: 0, fill: #9ca3af, stroke: #1f2937, status: stopped, }); const bodyFill computed(() props.status running ? #22c55e : props.fill ); /script template g :transformtranslate(${x} ${y}) rotate(${rotation}) path dM0 10 L20 0 L20 20 Z :fillbodyFill :strokestroke stroke-width2 / /g /template这个组件把位置、旋转、颜色、运行状态全部提升为 propscomputed根据status动态计算填充色。注意transform中要“先位移再旋转”顺序颠倒会导致图元绕原点旋转。组件内部不要直接修改 props所有交互都应该由外层 store 驱动这样组件才能保持纯展示便于在编辑器里预览和回放状态。2.4 动态添加组件注册表与节点配置在组态编辑器中用户从图元库拖出一个“阀门”放到画布实际执行的操作是向节点列表推入一个{ id, type, x, y, props }对象。画布渲染时从节点的type字段调用resolveSvgComponent再通过v-bindnode.props传入属性和坐标。由于注册表是扫描nodes/目录得到的新增图元不需要修改任何调用方代码。下面是一份节点结构约定表资源包里的set-node-attr.gif演示的正是这套属性设置流程字段类型说明idstring节点唯一标识通常用crypto.randomUUID()生成typeNodeType图元类型与nodes/下的文件名对应x/ynumber图元在 SVG 画布中的坐标单位为 viewBox 逻辑像素rotationnumber旋转角度度可选propsRecordstring, unknown透传给组件的属性对象支持嵌套和任意值这里的x、y不放进props是因为位置信息需要在节点层级统一管理方便后续做连线锚点计算和整体结构调整。而颜色、文本、设备编号这些业务属性全部放props组件内部通过defineProps声明。除了节点结构资源包里还附带了css-vars.css和custom_ani.css分别用于定义 SVG 图元的主题变量和自定义动画。新增图元时可以直接引用这些全局样式保持整套系统图的视觉风格一致。3. 画布交互与节点状态拖拽、属性面板与连线拓扑3.1 屏幕坐标与 SVG 坐标转换组态画布通常放在一个固定宽高的 SVG 元素中内部使用viewBox建立逻辑坐标系。用户按住鼠标拖拽图元时拿到的pointerEvent.clientX是屏幕坐标不能直接赋值给节点的x/y需要先转换到 SVG 坐标系export function screenToSvg(svgEl: SVGSVGElement, evt: PointerEvent) { const pt svgEl.createSVGPoint(); pt.x evt.clientX; pt.y evt.clientY; const ctm svgEl.getScreenCTM(); if (!ctm) { return { x: 0, y: 0 }; } const svgPoint pt.matrixTransform(ctm.inverse()); return { x: svgPoint.x, y: svgPoint.y }; }这段代码的逻辑是getScreenCTM()返回 SVG 元素当前到屏幕的变换矩阵包含缩放、平移和旋转matrixTransform用矩阵的逆矩阵把屏幕坐标映射回 viewBox 坐标系。注意getScreenCTM()在 SVG 还未挂载或display:none状态下会返回null所以判空返回原点坐标。如果你的画布支持缩放拖拽过程中每次都要重新调用该函数因为矩阵可能随着缩放变化。3.2 节点拖拽与状态持久化拖拽逻辑建议放进组合式函数或 store不要在模板内联实现。下面代码记录按下时鼠标相对节点左上角的偏移量再在全局pointermove中更新节点坐标import { reactive } from vue; import { nodeStore } from /store/node-store; import type { GraphNode } from /store/node-store; export const dragState reactive({ dragging: false, nodeId: as string, offsetX: 0, offsetY: 0, }); export function startDrag(node: GraphNode, evt: PointerEvent) { dragState.dragging true; dragState.nodeId node.id; dragState.offsetX evt.clientX - node.x; dragState.offsetY evt.clientY - node.y; } export function onDragMove(evt: PointerEvent) { if (!dragState.dragging) return; const node nodeStore.nodes.find((n) n.id dragState.nodeId); if (!node) return; const sp screenToSvg(canvasRef.value!, evt); node.x sp.x - dragState.offsetX; node.y sp.y - dragState.offsetY; }startDrag中保存的offset是屏幕坐标下的偏移量。由于事件监听始终拿到的是clientX所以在onDragMove里要先把屏幕坐标转成 SVG 坐标再减去偏移才能保证图元跟随鼠标移动时保持相对位置不变。如果直接node.x evt.clientX图元会瞬间跳到鼠标点位置观感很差。拖拽完成后建议把节点列表整体交给history模块做一次快照便于实现后续的撤销重做。3.3 属性面板v-model 与节点 props 的双向绑定选中节点后属性面板要根据元信息动态渲染表单。这里定义一个NodeTypeMeta接口来描述可编辑字段export interface NodeTypeMeta { type: string; label: string; fields: Array{ key: string; label: string; control: input | color | select; options?: string[]; }; }属性面板模板则用v-model绑定selectedNode.props[key]template div v-ifselectedNode classattr-panel div v-forfield in meta.fields :keyfield.key classattr-row label{{ field.label }}/label input v-iffield.control input v-modelselectedNode.props[field.key] / input v-else-iffield.control color typecolor v-modelselectedNode.props[field.key] / select v-else-iffield.control select v-modelselectedNode.props[field.key] option v-foropt in field.options :keyopt :valueopt{{ opt }}/option /select /div /div /template注意selectedNode.props[field.key]这种动态键在 Vue3 响应式系统下可用前提是props对象本身是响应式的通常是通过reactive包裹的节点对象。如果节点不是响应式的修改不会触发视图更新。把元信息和组件分开维护的好处是图元库、属性面板、校验逻辑可以复用同一份数据定义新增一种图元时不需要再写一套表单。3.4 连线与拓扑关系系统图里图元之间需要管线连接。最简单的拓扑模型基于锚点port每个图元组件可以导出一份ports配置标出可连接接口连线数据记录sourceNodeId和targetNodeId渲染时根据两端端口坐标绘制路径。export interface Connection { id: string; from: { nodeId: string; port: string }; to: { nodeId: string; port: string }; path?: string; } export function computeConnectionPath(from: {x: number; y: number}, to: {x: number; y: number}) { const dx to.x - from.x; const dy to.y - from.y; const c1 { x: from.x dx * 0.5, y: from.y }; const c2 { x: to.x - dx * 0.5, y: to.y }; return M ${from.x} ${from.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${to.x} ${to.y}; }连线用三次贝塞尔曲线控制点沿水平方向偏移一半距离视觉上比直线自然。computeConnectionPath的参数是端口在 viewBox 坐标系下的绝对坐标这要求图元每次移动后都重新计算所有关联连线的 path。实现上可以给每条连线加一个 computed依赖源节点和目标节点的 x/y。节点数量多时全量重算所有连线也会带来性能压力下一章里的批量刷新思路同样适用于连线重绘。4. MQTT 数据绑定从主题订阅到 SVG 属性实时刷新4.1 把 MQTT 消息映射成节点属性路径组态编辑器不只是画图关键是让图上的设备状态跟着现场 MQTT 消息走。做法是维护一张“数据映射表”把 MQTT payload 中的字段绑定到某个节点的属性路径{ bindings: [ { topic: factory/line1/temperature, payloadKey: value, propertyPath: sensor-01.props.text }, { topic: factory/line1/pumpState, payloadKey: state, propertyPath: pump-02.props.status } ] }收到factory/line1/pumpState时从消息 JSON 中取出state字段把值赋给pump-02的props.status泵的颜色和文本随之变化。这种机制把业务规则从代码中剥离到配置里新增设备型号时不需要重新发版只需要在编辑器里调整映射关系。4.2 用 mqtt.js 建立浏览器客户端浏览器环境无法使用 mqtt 原生 TCP 协议必须用 WebSocket 连接 broker。这里使用mqtt.js它同时支持mqtt://和ws://前缀。下面是一个可复用的连接模块import mqtt, { MqttClient } from mqtt; import { useBindingStore } from /store/bindings; export function createMqttConnection(config: { url: string; clientId?: string; username?: string; password?: string; }) { const client: MqttClient mqtt.connect(config.url, { clientId: config.clientId || web-config-${Date.now()}, username: config.username, password: config.password, cleanSession: true, reconnectPeriod: 2000, }); client.on(connect, () { console.log([mqtt] connected to, config.url); for (const binding of useBindingStore().bindings) { client.subscribe(binding.topic, { qos: 1 }, (err) { if (err) console.warn(订阅失败, binding.topic, err.message); }); } }); client.on(message, (topic, payload) { const msg JSON.parse(payload.toString()); applyBindings(topic, msg); }); client.on(error, (err) { console.error([mqtt] error, err.message); }); return client; }url需要写成ws://broker-host:8083或wss://...8083 是 mosquitto 等 broker 常见的 WebSocket 端口。clientId如果不传随机生成一个带时间戳的 id避免多个浏览器标签页互相踢下线。reconnectPeriod表示断线重连间隔毫秒这里固定 2 秒生产环境可以做成指数退避。qos: 1表示至少一次投递消息可能重复所以后续处理要保持幂等。4.3 用 requestAnimationFrame 合并高频消息MQTT 设备状态通常以秒级甚至毫秒级频率上报。如果每条消息都立刻更新响应式属性Vue 可能在一个渲染帧内收到多次更新造成无谓的 DOM 操作。常见做法是把消息缓冲进一个 Map在下一帧统一刷新const pendingUpdates new Mapstring, unknown(); let rafId: number | null null; function applyBindings(topic: string, msg: Recordstring, unknown) { for (const binding of bindingStore.bindings) { if (binding.topic ! topic) continue; const value getNestedValue(msg, binding.payloadKey); if (value ! undefined) { pendingUpdates.set(binding.propertyPath, value); } } scheduleFlush(); } function scheduleFlush() { if (rafId ! null) return; rafId requestAnimationFrame(() { pendingUpdates.forEach((value, path) { setPropertyByPath(path, value); }); pendingUpdates.clear(); rafId null; }); }pendingUpdates用 Map 结构同一帧内对同一个属性路径的多次更新只保留最后一个值。rafId防止在同一帧中重复创建多个 flush 任务。setPropertyByPath负责把sensor-01.props.text这类路径解析成实际对象并赋值。这样即使 MQTT 消息每秒 20 条实际 DOM 更新频率最多是屏幕刷新率。4.4 数值范围与颜色联动温度、压力、液位这些数值往往需要映射成某种颜色或动画。常见做法是定义阈值映射函数把数值区间映射到图元 propsexport function numericThreshold(value: number, ranges: Recordstring, [number, number]) { for (const [state, [low, high]] of Object.entries(ranges)) { if (value low value high) return state; } return abnormal; }在绑定配置里增加一个preset字段applyBindings收到消息时先检查是否配置了preset如果是则执行阈值映射再赋值。这样“温度超过 80 度变红”这类规则完全配置化不用写死在代码里。但要克制不要把映射逻辑做得太抽象绑定关系复杂时先维护一个清晰的 map保持可读性。5. 组态渲染优化与踩坑节点 key、shallowRef 与批量更新5.1 给每个节点绑定稳定 key避免组件错乱画布中用v-for渲染component :is时必须给外层g绑定:keynode.id。如果依赖数组下标删除中间节点后Vue 会复用旧组件实例导致选中的节点、拖拽状态错乱。下面这种写法把事件绑定放在g上component只负责渲染 SVG 内容职责更清晰g v-fornode in nodeStore.nodes :keynode.id pointerdown.stopstartDrag(node, $event) clickselect(node.id) component :isresolveSvgComponent(node.type) v-bindnode.props :xnode.x :ynode.y / /g5.2 用 shallowRef 减小响应式追踪开销节点列表中的每个图元有大量静态形状如果整个 nodeStore 用reactive包裹Vue 会深度代理所有嵌套对象。在高频刷新场景下可以用shallowRef控制触发时机const nodes shallowRefGraphNode[]([]); export function updateNodeProp(nodeId: string, key: string, value: unknown) { const list nodes.value; const idx list.findIndex((n) n.id nodeId); if (idx 0) return; const newList [...list]; const nextNode { ...newList[idx], props: { ...newList[idx].props, [key]: value } }; newList[idx] nextNode; nodes.value newList; }每次更新用浅拷贝生成新的节点对象并替换数组主动触发视图更新。节点数超过 300 个时全量展开的拷贝成本不可忽略可以折中节点列表用shallowRef单个节点的 props 仍用reactive。5.3 合并连续位置变化防止布局抖动拖拽图元时pointermove事件的频率远高于屏幕刷新率如果每次都更新节点坐标会引发不必要的重排。这里同样用 rAF 合并let pendingPos: { id: string; x: number; y: number } | null null; let dragRaf 0; export function onDragMove(evt: PointerEvent) { if (!dragState.dragging) return; const sp screenToSvg(canvasRef.value!, evt); pendingPos { id: dragState.nodeId, x: sp.x - dragState.offsetX, y: sp.y - dragState.offsetY }; if (!dragRaf) { dragRaf requestAnimationFrame(() { if (pendingPos) { const node nodeStore.nodes.find((n) n.id pendingPos!.id); if (node) { node.x pendingPos.x; node.y pendingPos.y; } pendingPos null; dragRaf 0; } }); } }只要拖拽状态持续rAF 回调始终在下一次绘制前把最新坐标写入节点中间的重复事件被舍掉。缩放拖拽时这个优化尤其明显。5.4 内存泄漏检查与清理组态页面最容易泄漏的两个位置一是 MQTT 客户端在组件卸载时没有调用client.end()导致连接残留二是事件监听器挂在了window上但移除时机不对。可以在onBeforeUnmount中统一清理onBeforeUnmount(() { if (clientRef.value) { clientRef.value.end(true); clientRef.value null; } window.removeEventListener(pointermove, globalDragMove); window.removeEventListener(pointerup, globalDragUp); });最后留一个实用技巧对于高频刷新节点文本的场景可以直接给text元素绑定v-memo[value]把频繁变化限制在单个节点内部。这样 200 个设备同时刷新也能稳定保持在 60fps 附近。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询