ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue3+MQTT.js构建稳定SCADA大屏:连接、数据、渲染三层的优化实践

Vue3+MQTT.js构建稳定SCADA大屏:连接、数据、渲染三层的优化实践 如果你做过 SCADA 大屏项目一定遇过这种尴尬看板上某个点位卡了十几秒不动你正准备截图给现场同事它突然跳变到最新值中间一大段过程数据直接蒸发。要是恰好被甲方领导看见“这系统不行”的印象就落下了。我最近一个项目就是典型场景Vue3 搭建的产线监控大屏通过 MQTT.js 对接中控 SCADA 的实时数据总线采集 PLC 点位、设备状态、产量计数这些信息。第一版从“能通”到“看起来能跑”只花了 3 天但随后被线上各种“薛定谔的稳定”折磨了快 3 周。这篇文章把整个从迷茫到稳定落地的过程拆开写清楚包括每一层坑的原因、排查链路、最终方案和压测方法希望能帮到正在做同类可视化监控、数据大屏的朋友。我默认你已经会 Vue3 和 MQTT 的基本写法所以重点不放在语法上而是放在“为什么你在别处看到的 demo 一切正常搬到 SCADA 场景就各种掉链子”这件事上。1. SCADA 场景下MQTT 通信“不稳定”到底指什么先说清楚一件事很多前端同学理解的“不稳定”和 SCADA 现场实际的“不稳定”根本不是一回事。你做聊天工具或者 IoT 智能家居 demo消息丢了重发一下无所谓甚至掉线几秒用户也感知不到。但 SCADA 场景下前端展示的是生产设备的实时状态和连续过程量数据一旦断裂或乱序会直接导致看板误判、报警误报严重的可能让操作员做出错误决策。1.1 一个典型场景产线可视化大屏项目背景是某工厂的一条装配线需要把线体上二十多台 PLC 的关键数据实时投到控制室大屏上。数据流大致是PLC 通过 OPC UA 或 Modbus 上报给中控 SCADASCADA 网关再通过 MQTT Broker 对外发布主题前端用 MQTT.js 通过 WebSocket 订阅。我负责的部分是 Vue3 前端。页面主要分三块顶部是产线综合指标总产量、OEE、节拍时间秒级刷新中间是工位状态看板每台设备的运行/停机/报警状态状态变化要求秒级响应底部是趋势曲线用 ECharts 画最近 1 小时的温度、转速、电流曲线每秒一个点。这种结构下前端订阅的主题有十几个每秒进入浏览器的消息在几十到上百条之间波动。初期我用最“标准”的连接写法结果遇到了形态各异的“不稳定”。1.2 不稳定现象的几种典型表现我归纳了一下实际踩到的坑大概分为四类表现表象根因方向数据冻死页面数值突然不动过一会又跳变MQTT 连接断开未重连或断线期间消息未处理数据重复/乱跳数值回退、上下抖动、曲线锯齿QoS 重发导致重复消息或点位顺序颠倒页面卡顿CPU 暴涨、滚动掉帧、大屏切换卡死高频消息直接驱动 Vue 响应式更新渲染层瓶颈偶发白屏/报错某个点位返回脏数据导致整个组件崩溃消息解析无容错一条脏数据拖垮全部这四类问题叠加在一起就会给人“系统不稳定”的整体印象。后面几章我会按照排查的递进顺序把每一类的根因和处理过程讲清楚。1.3 为什么是 Vue3 MQTT.js 这个组合选型的问题也简单说一下。Vue3 的 Composition API 很适合做通信逻辑的封装把连接、订阅、消息分发、断线重连这些逻辑收敛到 composable 里页面组件只管消费数据职责清晰。实际项目里我们基于useMqtt封装了一层页面代码里没有一行mqtt.connect。MQTT.js 是目前浏览器端最成熟的 MQTT 客户端库支持通过 WebSocket 连接 Broker体积不大API 稳定生态成熟。它能在浏览器里直接用这是它能在这个场景落地的核心原因——SCADA 侧的网关或 Broker 不需要额外开发 WebSocket 适配层前端直接订阅主题就行。组合没问题问题出在使用姿势。2. 第一版实现只用默认参数上线三天就翻车先交代一下我的第一版是怎么写的。当时完全是“demo 思维”照着文档把连接、订阅、监听消息三步写完感觉已经完事了。2.1 最朴素的连接写法以及它的问题第一版代码大概长这样import mqtt from mqtt const client mqtt.connect(ws://192.168.1.100:8083/mqtt) client.on(connect, () { client.subscribe(factory/line1/#) }) client.on(message, (topic, payload) { const data JSON.parse(payload.toString()) store.commit(updatePoint, { topic, data }) })看着没问题对吧问题大了。三个致命缺陷第一mqtt.connect()默认的reconnectPeriod是 1000ms但如果你没有监听close和reconnect事件连接断了之后页面会进入“看似在线、实际已挂”的假死状态。等到重连成功时中间断线期间的所有消息都丢了。第二payload.toString()之后直接JSON.parse只要现场设备任何一个点位返回了非 JSON 的字符串甚至是在 JSON 末尾多了一个空格导致解析异常整个message回调直接抛异常。Vue 的全局错误处理如果没接前端页面会静默白屏。第三也是最隐蔽的mqtt.connect()默认的keepalive是 60 秒也就是说客户端 60 秒内没和 Broker 通信Broker 才会认为它死了。但前端的 WebSocket 可能因为网络抖动、代理超时提前断开而 MQTT.js 并不知道。于是出现“页面还开着但数据已经断了几分钟”的诡异现象。2.2 断线后不重连单次连接的生命周期陷阱上线第三天甲方反馈“1 号工位的转速数据卡住了刷新一下才好”。我远程看了下确实是前端状态停在了 10:23:45而当前时间已经是 10:31。排查链路是这样的先看 Broker 端日志发现 10:23:45 之后该客户端的连接就断开了再看前端浏览器 NetworkWebSocket 连接确实已经关闭然后看控制台没有任何异常报错最后看 mqtt.js 源码文档发现默认的reconnectPeriod虽然是 1000ms但多次断线后可能因为连接异常抛出 error而 error 事件没监听会导致客户端静默停止重连。我当时只监听了connect和message没监听close、error、reconnect。所以整个断线生命周期完全是黑盒。这个坑的直接后果是现场网络一抖动前端就永久失联只能靠人工刷新页面恢复。2.3 消息风暴订阅方式与渲染瓶颈的初次暴露连接问题之外渲染瓶颈也在第一版就暴露了。SCADA 网关发布消息的频率比我预想的高很多。比如温度传感器现场配置的是每秒上报一次但 Broker 转发的主题还包括设备心跳、报警状态、累计量等叠加起来每秒进入浏览器的消息大概 60~100 条。而第一版里每次message都直接驱动 Vuex 的 commit 或 Vue 的响应式状态更新。这就意味着如果我在message里store.commit(updatePoint, data)那么这个数据对象每秒钟会被更新几十甚至上百次。Vue3 的响应式系统虽然性能不差但每一次 commit 都可能触发关联组件的重新渲染。当渲染的 DOM 节点数量达到几百上千时页面就会出现肉眼可见的卡顿。我截取过一段 Performance 面板数据FPS 掉到 20 以下长任务Long Task接近 800ms。这种性能表现在大屏这种要求流畅轮播、平滑动画的场景里完全不合格。3. 连接层改造把断线重连做成一个可靠的状态机第一版翻车之后我开始认真关注连接层的生命周期。这里要说一句MQTT 连接本质上是个状态机不是“连上就完事”。一个可靠的连接层至少要处理连接中、已连接、断线中、重连中四种状态并且每种状态下的行为都要有明确预期。3.1 心跳与连接参数keepalive、connectTimeout 怎么配先看连接参数的正确配置方式const client mqtt.connect(ws://192.168.1.100:8083/mqtt, { keepalive: 20, connectTimeout: 5000, reconnectPeriod: 0, clean: false, clientId: scada_web_ genRandomId(), })各参数含义和我的配置理由参数首版值稳定版值原因keepalive60默认20缩短 Broker 判断断线的时限网络异常能更快触发重连connectTimeout30s默认5000ms连接握手超时快速失败避免长时间阻塞reconnectPeriod1000默认0关闭自动重连改为自己控制重连节奏cleantrue默认false保证离线期间 Broker 保留会话和订阅重连后自动恢复特别解释下clean: false。MQTT 的 clean session 决定会话是否持久化。默认clean: true意味着客户端每次连接都是一个全新会话离线期间的消息全部丢弃。SCADA 场景下如果设备状态在断线期间发生变化重连后前端拿不到变化事件就会一直显示旧状态。clean: false可以让 Broker 保留客户端的订阅关系并在重连后继续发送离线期间的 QoS 1/2 消息。这个特性对你的“断线补偿”非常有帮助后面数据层改造会用到。3.2 重连策略指数退避比固定间隔更科学然后是我自己管理的重连状态机。核心思路是用指数退避算法控制重连间隔避免现场几十个客户端同时断线后在同一秒内全部冲击 Broker。let retryCount 0 let reconnectTimer null function scheduleReconnect() { // 指数退避1s、2s、4s、8s... 最大30s const delay Math.min(30000, 1000 * Math.pow(2, retryCount)) retryCount reconnectTimer setTimeout(connect, delay) } function connect() { status.value connecting client mqtt.connect(CONFIG.url, { keepalive: 20, connectTimeout: 5000, reconnectPeriod: 0, clean: false, clientId: CONFIG.clientId, }) client.on(connect, () { status.value connected retryCount 0 // 连接成功后重置重试计数 // 重新订阅主题 subscribeAll() }) client.on(close, () { status.value disconnected scheduleReconnect() }) client.on(error, (err) { console.error([MQTT] error:, err) client.end(true) }) }关键点有三个一是retryCount在连接成功后必须归零否则下次断线会直接从很大的退避间隔开始恢复时间变长。二是error事件里主动调用client.end(true)强制关闭连接并触发close事件这样重连逻辑才会继续走下去。否则部分异常场景下 MQTT.js 会处于“连接已经死了但事件没触发”的静默状态。三是重连成功后必须重新执行订阅。虽然设置了clean: falseBroker 侧会恢复会话但为了应对 broker 重启后会话丢失的情况前端主动重新订阅是最稳妥的做法。用这套状态机跑了一个月没有再出现过“永久失联”的情况。最严重的一次现场断电网关重启折腾了 5 分钟前端在 30 秒内自动恢复大屏数据继续滚动甲方甚至没注意发生过断线。3.3 断线补偿如何补上断线期间的数据缺口连接恢复后还会面临一个问题断线期间的数据缺口怎么办clean: false QoS 1 可以补回 Broker 转发过的消息但补不回来的情况也存在比如断线时间过长导致消息过期、Broker 重启导致持久化会话丢失、设备侧上报频率低而断线窗口恰好错过了状态变化。我的解决方案是快照 增量补偿。断线期间前端在本地记录“最后一条消息的时间戳”和“断线开始时间”。重连成功后先向后端网关发送一个 HTTP 请求拉取断线期间所有点位的最新快照然后等 MQTT 的离线消息补发完成后再拉一次增量数据和 MQTT 推上来的消息合并去重。这个方案不依赖单条通道的可靠性两条路径互相兜底。实测下来断线 10 分钟以内前端可以不丢一条数据恢复显示。4. 数据层改造QoS、去重与脏数据容错连接层稳定了数据还可能出现重复、乱序、脏数据的问题。这一层的问题比较隐性线上不一定会立刻暴露但它会在某些条件下突然变成“灵异事件”。4.1 QoS 0/1/2 在 SCADA 场景下到底怎么选MQTT 的 QoS 分为三档QoS语义特点适用场景0至多一次快可能丢心跳、日志、非关键状态1至少一次不丢但可能重复设备状态、报警、点位值2恰好一次不丢不重但握手开销大计费、订单等强一致场景SCADA 场景里我的建议是关键状态量用 QoS 1过程模拟量优先用 QoS 0 配合更短的上报周期不要动不动就上 QoS 2。理由很务实QoS 2 需要四步握手PUBLISH → PUBREC → PUBREL → PUBCOMP在设备点位多、上报频率高的情况下Broker 压力会成倍增加。而且前端大屏看的是趋势和状态单条数据偶发丢失并不会造成严重后果只要整体趋势和数据频率稳定即可。但 QoS 0 有个问题如果前端订阅的是 Broker 转发过来的retain消息保留消息那么断线重连后首次收到的消息可能是旧的保留值时间上比最后一条本地数据还要早。这就是典型的“数据回退”现象。所以我在订阅端做了一个约定前端展示点位值时以消息里的设备时间戳为准而不是以到达顺序为准。4.2 消息去重与乱序处理时间戳是唯一标准QoS 1 会带来重复消息。SCADA 网关重发消息的频率不高但一旦发生前端如果无脑更新画面上的数值就会“闪一下”曲线图上出现一个毛刺。我的去重方案是在消息解析层做一个轻量缓存const lastMsgMap new Map() // topic - { ts, value } function handleMessage(topic, payload) { const data safeParse(payload.toString()) if (!data) return const { ts, value } data // 只接受比本地更新的数据 const last lastMsgMap.get(topic) if (last ts last.ts) return lastMsgMap.set(topic, { ts, value }) updateStore(topic, data) }逻辑非常简单用 Map 按主题缓存上一条消息的时间戳新的消息如果时间戳不比上一条新直接丢弃。这样同时解决了重复消息和乱序消息两个问题。需要注意的是ts必须来自设备侧而不是前端收到的本地时间。因为前端本地时间在断线重连、系统休眠恢复后可能有偏差而设备时间是单调递增的。如果设备侧不提供时间戳可以考虑用“序号”或者“累计量”来判断新旧。总之要有一种能区分“这条数据到底是不是最新的”的判断标准。4.3 JSON 解析的容错设计脏数据不能拖垮页面这个问题首版就栽过。JSON.parse 直接放 message 回调里一条脏数据直接抛异常Vue 应用直接白屏。后来解析逻辑我改成了下面这个“三道关卡”的 safeParsefunction safeParse(raw) { // 第一关字符串可能为空或者额外空白 if (typeof raw ! string || raw.trim() ) return null // 第二关JSON 语法校验 let data try { data JSON.parse(raw) } catch (e) { console.warn([MQTT] JSON parse failed:, raw) return null } // 第三关字段类型校验 if (typeof data ! object || data null) return null if (typeof data.ts ! number) return null if (typeof data.value ! number typeof data.value ! string) return null return data }核心原则是解析失败只影响当前一条消息绝不能影响整个页面。丢弃脏数据后前端保持上一个有效值不变或者显示“数据异常”占位。这种做法牺牲了一点点精度换来了整体稳定性在工业场景里是值得的。另外提醒一点工业现场的设备数据小数点精度、负数、极大值这些都要在解析层做好约束。比如温度传感器偶尔返回65535-32767 的补码溢出如果前端不校验范围画趋势图时一个天大的毛刺能让整条曲线失去参考价值。5. 渲染层优化数据不丢了页面却卡死了连接和数据两层都稳了又出现新问题数据吞吐量上来了页面开始卡。特别是在大屏场景下CPU 占用率高动画不流畅严重时切换页面要等好几秒。5.1 高频消息直接驱动 Vue 响应式的后果Vue3 的响应式系统性能已经很好但架不住高频消息 大量 DOM。我的页面有二十多个工位卡片每个卡片上又有多个字段。如果一条消息更新一个字段一个周期内就可能触发二十多次组件更新如果这些更新还涉及 ECharts 图表的 setOption情况就更糟了。Performance 面板显示光是响应式触发和组件渲染就占了主线程 70% 的时间。图表重绘更是在每个消息帧里反复执行。5.2 数据节流与批量更新把 100 条合并成 1 次更新我的做法是引入批量更新机制消息进来先暂存到队列每隔 100ms 批量写入一次响应式状态。const pendingMap new Map() let batchTimer null function queueUpdate(topic, data) { pendingMap.set(topic, data) if (!batchTimer) { batchTimer setTimeout(flushBatch, 100) } } function flushBatch() { batchTimer null const points {} pendingMap.forEach((data, topic) { points[topic] data }) store.commit(batchUpdatePoints, points) pendingMap.clear() }这样即使一秒进来 100 条消息最终触发的更新次数也由“100 次”降为“10 次”。100ms 的延迟在 SCADA 大屏场景下完全可接受而主线程压力大幅下降。有些对实时性要求更高的数据比如报警弹窗可以不经过批量队列直接走单独的 event bus 处理。把“实时型数据”和“趋势型数据”分开处理是工业大屏常见的优化思路。5.3 ECharts 大屏图表的几个关键优化趋势图这块我用 ECharts 做了几项针对性优化// 初始化时关闭动画高频数据下动画反而是负担 chart.setOption({ animation: false, animationDurationUpdate: 0, }) // 更新数据时使用增量方式避免全量重绘 chart.setOption({ series: [{ data: newData, }], }) // ECharts 5 的 lazyUpdate 选项 chart.setOption(option, { lazyUpdate: true, })三个要点的分析animation: false很关键。ECharts 默认动画在低频场景下很加分但在每秒更新一个点、且历史数据有 3600 个点的情况下动画只会造成连续重绘CPU 直接爆炸。关掉动画曲线像工业组态软件那样“直给”反而更符合监控场景的气质。notMerge参数要看场景。全量更新一条曲线时可以用notMerge: true强制替换但如果页面还有其他图表组件尽量不要全量替换否则会重建整个图表实例开销很大。还有就是大数据量时优先用dataset管理数据。ECharts 5 对 dataset 模式有专门优化比直接在 series.data 里塞数组性能更好尤其是多个 series 共享同一个数据源的时候。6. 稳定性验收用这套压测方法我才有底气上线改造完成后我建立了一套相对固定的压测流程。每次改动上线前都会按这套流程过一遍。这里分享出来希望你能少走弯路。6.1 模拟弱网与断线的压测方法我常用的几种模拟手段场景操作预期表现网络断开DevTools 切 Offline保持 30s 再恢复前端在退避重连后自动恢复数据补齐Broker 重启重启 EMQX / VerneMQ 服务前端检测到连接关闭自动重连并重新订阅消息洪峰用脚本模拟 200 个点位、每秒 1 次上报页面稳定CPU 占用不持续增长脏数据注入人为发布非 JSON 或字段缺失的消息当前点位不动页面整体不受影响订阅恢复断线重连后查看订阅列表所有 STopic 恢复不丢主题特别说一下消息洪峰怎么测。我写了一个简单的 Node 脚本用 MQTT.js 模拟客户端往 Broker 批量发布消息const mqtt require(mqtt) const client mqtt.connect(mqtt://localhost:1883) client.on(connect, () { let i 0 setInterval(() { for (let j 0; j 50; j) { const topic factory/line1/device${j % 20}/data client.publish(topic, JSON.stringify({ ts: Date.now(), value: Math.random() * 100, device: j % 20, }), { qos: 1 }) } i }, 500) })注意压测时要盯着任务管理器或 Performance 面板看两个指标一是长任务是否持续增加二是内存是否会缓慢上涨。如果内存一直涨而数据量没变多半是某个集合没有及时清理比如前面提到的lastMsgMap或者批量更新的pendingMap内存泄漏了。我在项目里就吃过一次亏lastMsgMap在没有限制大小时长期运行后内存涨了几十 MB页面越来越卡。解决方案是给 Map 加个最大长度超过后删除最早的数据或者定期清理不再活跃的主题。6.2 上线后的关键观察指标压测通过只是第一步。上线后我还会持续观察这几个指标消息到达率前端实际收到的消息数除以 Broker 转发的消息数要求接近 100%重连平均耗时统计每次断线到恢复的间隔稳定在 30 秒内数据缺口时长断线期间哪些点位缺数据、缺了多久要和网关侧核对页面 FPS大屏运行 4 小时以上FPS 稳定在 30 以上内存曲线运行一整天内存没有异常上涨。这些指标我做了简单的打点上报前端每 5 分钟上报一次统计信息到后端方便复盘。6.3 一些容易忽略、但很重要的细节最后补几个容易踩的边角坑clientId 不能重复。多个浏览器标签页打开同一个大屏页面如果 clientId 一样MQTT Broker 会互相踢下线。我每次生成随机后缀解决scada_web_ ${Date.now()} _${Math.random().toString(16).slice(2)}。在线状态不要完全依赖 MQTT。MQTT 的will遗嘱消息能实现离线通知但配置起来有讲究。前端大屏的“设备在线/离线”状态我建议同时配合 HTTP 轮询或者后端主动推送的冗余通道否则会出现“MQTT 连着呢但 PLC 已经停了”的尴尬。页面卸载前要主动断开。Vue 组件销毁时记得client.end(true)否则 WebSocket 连接残留会导致内存泄漏和 Broker 连接数耗尽。我的useMqtt在onUnmounted里做了清理onUnmounted(() { if (reconnectTimer) clearTimeout(reconnectTimer) if (client) client.end(true) })不要忽略 Vue 的错误捕获。我在app.config.errorHandler里统一接了异常上报至少保证前端出现异常时能第一时间看到错误日志而不是等甲方截图反馈。最后的体会这套改造做完再回头看“从不稳定到稳定”其实核心不是某个炫技方案而是把连接生命周期、数据质量、渲染性能这几件事分层拆开每一层都做到可预期、可监控、可恢复。我在这个项目里最大的体会是SCADA 前端的稳定性80% 在通信层20% 在渲染层但排错顺序一定是先通信后渲染。很多时候你以为是渲染卡顿查了性能面板才发现是底层消息乱序导致组件反复重渲染你以为是 MQTT 连接断了查了日志才发现是 JSON 解析异常导致浏览器锁死了渲染线程。如果这篇文章能帮你少熬几个大屏项目的夜那这些坑就算没白踩。后面我还会单独写一篇useMqtt封装实现的文章把 composable 的完整代码和调试技巧放出来欢迎持续关注。
返回列表