Vue3+MQTT.js构建稳定SCADA大屏:连接、数据、渲染三层的优化实践
2026/9/18 17:05:18 网站建设 项目流程

如果你做过 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 上报给中控 SCADA,SCADA 网关再通过 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,但如果你没有监听closereconnect事件,连接断了之后,页面会进入“看似在线、实际已挂”的假死状态。等到重连成功时,中间断线期间的所有消息都丢了。

第二,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。

排查链路是这样的:

  1. 先看 Broker 端日志,发现 10:23:45 之后,该客户端的连接就断开了;
  2. 再看前端浏览器 Network,WebSocket 连接确实已经关闭;
  3. 然后看控制台,没有任何异常报错;
  4. 最后看 mqtt.js 源码文档,发现默认的reconnectPeriod虽然是 1000ms,但多次断线后可能因为连接异常抛出 error,而 error 事件没监听,会导致客户端静默停止重连

我当时只监听了connectmessage,没监听closeerrorreconnect。所以整个断线生命周期完全是黑盒。

这个坑的直接后果是:现场网络一抖动,前端就永久失联,只能靠人工刷新页面恢复。

2.3 消息风暴:订阅方式与渲染瓶颈的初次暴露

连接问题之外,渲染瓶颈也在第一版就暴露了。

SCADA 网关发布消息的频率比我预想的高很多。比如温度传感器,现场配置的是每秒上报一次,但 Broker 转发的主题还包括设备心跳、报警状态、累计量等,叠加起来每秒进入浏览器的消息大概 60~100 条。而第一版里,每次message都直接驱动 Vuex 的 commit 或 Vue 的响应式状态更新。

这就意味着:如果我在messagestore.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: false,Broker 侧会恢复会话,但为了应对 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 应用直接白屏。

后来解析逻辑我改成了下面这个“三道关卡”的 safeParse:

function 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 一些容易忽略、但很重要的细节

最后补几个容易踩的边角坑:

  1. clientId 不能重复。多个浏览器标签页打开同一个大屏页面,如果 clientId 一样,MQTT Broker 会互相踢下线。我每次生成随机后缀解决:scada_web_ ${Date.now()} _${Math.random().toString(16).slice(2)}

  2. 在线状态不要完全依赖 MQTT。MQTT 的will遗嘱消息能实现离线通知,但配置起来有讲究。前端大屏的“设备在线/离线”状态,我建议同时配合 HTTP 轮询或者后端主动推送的冗余通道,否则会出现“MQTT 连着呢但 PLC 已经停了”的尴尬。

  3. 页面卸载前要主动断开。Vue 组件销毁时记得client.end(true),否则 WebSocket 连接残留会导致内存泄漏和 Broker 连接数耗尽。我的useMqttonUnmounted里做了清理:

onUnmounted(() => { if (reconnectTimer) clearTimeout(reconnectTimer) if (client) client.end(true) })
  1. 不要忽略 Vue 的错误捕获。我在app.config.errorHandler里统一接了异常上报,至少保证前端出现异常时能第一时间看到错误日志,而不是等甲方截图反馈。

最后的体会

这套改造做完,再回头看“从不稳定到稳定”,其实核心不是某个炫技方案,而是把连接生命周期、数据质量、渲染性能这几件事分层拆开,每一层都做到可预期、可监控、可恢复。

我在这个项目里最大的体会是:SCADA 前端的稳定性,80% 在通信层,20% 在渲染层,但排错顺序一定是先通信后渲染。很多时候你以为是渲染卡顿,查了性能面板才发现是底层消息乱序导致组件反复重渲染;你以为是 MQTT 连接断了,查了日志才发现是 JSON 解析异常导致浏览器锁死了渲染线程。

如果这篇文章能帮你少熬几个大屏项目的夜,那这些坑就算没白踩。后面我还会单独写一篇useMqtt封装实现的文章,把 composable 的完整代码和调试技巧放出来,欢迎持续关注。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询