Vue3 + MQTT.js 实现 SCADA 实时数据通信:从不稳定到稳定的完整实践
1. 项目概述:从 HMI 到 Web SCADA 的通信架构演变
先说结论:这个项目解决的问题,是把传统 SCADA(数据采集与监控系统)中依赖组态软件和专用客户端的数据通道,用 Vue3 前端加上 MQTT.js 协议栈完整替换掉,实现浏览器里跑实时监控画面,最后从连接频繁断开、画面卡顿、数据延迟到稳定运行数周不掉线。
很多人会问,SCADA 和上位机到底是什么区别。简单讲,上位机是工控领域对监控层软件的统称,SCADA 是这个层面里具备数据采集、存储、报警、趋势分析能力的系统。传统 SCADA 是 C/S 架构,现场 PLC、传感器通过 Modbus、OPC UA 这类工业协议把数据交给 SCADA 服务器,工程师在组态软件里画画面、绑变量。在 2024 年做 Web SCADA,其实是在保留 SCADA 核心能力的前提下,把 HMI(人机界面)这一层搬到浏览器里,让工艺人员不装任何客户端就能看实时数据、操作设备。
整个技术栈的核心就两样:Vue3 负责界面和交互,MQTT.js 负责和消息代理之间的双向通信。为什么选 MQTT 而不是 WebSocket 直连或者 HTTP 轮询?先从通信模型说起。MQTT 是发布/订阅模型,设备端把数据发布到主题(Topic),前端订阅需要的主题。一个车间几千个点位,每个点位每秒刷一次,如果通过 HTTP 轮询,每秒钟就是几千个请求,代理服务器根本扛不住,而且 HTTP 是请求-响应模式,服务端有数据变化时无法主动推送。WebSocket 能做到服务端推送,但和具体业务协议绑定,每换一个设备厂商就要改协议解析。MQTT 的优势是协议规范统一、QoS 等级可控、心跳机制完善,而且 Broker(如 EMQX、Mosquitto)对海量连接的支持远比自研 WebSocket 网关成熟。
这个项目里我选 Vue3 的原因是组合式 API 在处理大量实时数据时太方便了。SCADA 画面里几十个设备、成百上千个点位,每个点位要维护当前值、状态颜色、报警标志、历史趋势,用 Options API 写会把数据逻辑散落在各个生命周期钩子里,而watch、computed、ref、shallowRef这一套组合函数可以让每个设备的数据管理逻辑自包含。前端工程化方面,Vite 做构建工具,Pinia 做全局状态管理,Element Plus 做基础组件库,ECharts 做趋势曲线——这是一套相当成熟的 Web SCADA 技术栈。
适合谁参考这篇实践?正在做工业互联网平台、智慧工厂大屏、设备监控系统,或者想要把老 SCADA 系统做 Web 化改造的人。如果你是纯前端工程师,可以从本文了解 MQTT 协议和工业通信基础;如果你是工控工程师,可以看看前端怎么消费实时数据流。我会按照从基础通信到稳定性优化再到问题排查这条线往下写,全程以实际踩坑记录为主,最后会给出完整的稳定运行方案。
2. 通信链路设计:MQTT 协议基础与 Vue3 项目集成
2.1 MQTT 关键参数选型:QoS、KeepAlive、CleanSession
设计通信链路前先把协议参数吃透,选错一个参数就会导致后续一系列稳定性问题。MQTT 协议里有几个关键参数一旦设置不当,后果非常隐蔽。
QoS(服务质量)是消息送达保证级别。QoS 0 是至多一次,消息发出去就不管了,可能丢;QoS 1 是至少一次,保证到达但可能重复;QoS 2 是恰好一次,代价是握手开销大。SCADA 场景下大部分实时数据点位我建议用 QoS 0,因为点位值每秒都在更新,丢一帧下一帧就补上了,最新值才是有效的。反而是控制指令这类反向操作消息,比如操作员点击启动电机,这种必须用 QoS 1,而且要在业务层面做去重和确认,因为 QoS 1 可能重复投递,一条“启动”指令如果被消费两次,电机就重复启动了。
KeepAlive 是客户端和 Broker 之间的保活心跳间隔。我在项目里设的是 30 秒。这个值不是随便定的,MQTT 协议规定客户端在一个 KeepAlive 周期内如果没发出任何 MQTT 控制报文,必须主动发一个 PINGREQ 报文。Broker 如果在一个半的周期内没收到任何报文就断开连接。所以 KeepAlive 设 30 秒意味着 Broker 最多等 45 秒判定连接死亡。Ws 断了客户端无论如何也要 30 秒才能感知,这直接影响断线重连的响应速度。根据实际延迟需求,如果你是秒级监控,建议设 10-15 秒。
CleanSession 决定会话是否被持久保存。如果设为 true,断线后 Broker 不保留任何订阅关系,重连后要重新订阅;设为 false 则会在 Broker 端保留会话和服务端未下发的 QoS 1/2 消息。Web SCADA 场景我建议设 true,因为订阅逻辑在前端代码里,重连后重新订阅很容易,而且 SCADA 的数据本质是“看最新快照”,不是“补历史消息”,持久会话容易攒积压消息,重连后瞬间刷出一堆旧数据反而会引发画面卡顿。不过有一个例外,就是控制指令下发场景需要离线补发,那要单独建一条持久会话通道。
2.2 Vue3/MQTT.js 的连接与订阅实现
在 Vue3 项目里集成 mqtt.js 没有官方插件,所以我用 Pinia 的 store 来管理连接生命周期。注意一个核心设计原则:整个应用只维持一个 MQTT 客户端实例,不要在组件里 new 多个连接。多连接会导致浏览器文件描述符耗尽,而且 Broker 端每个连接都要维持心跳,连接数多了服务端压力很大。
下面这段 store 代码是整个通信层的基础骨架:
// src/store/mqtt.ts import { defineStore } from 'pinia' import mqtt, { MqttClient, IClientOptions } from 'mqtt' import { ElMessage } from 'element-plus' interface DeviceData { [key: string]: number | string | boolean } export const useMqttStore = defineStore('mqtt', { state: () => ({ client: null as MqttClient | null, connected: false, deviceData: {} as DeviceData, }), actions: { connect() { const options: IClientOptions = { protocol: 'wss', // WebSocket 安全连接 host: '192.168.1.100', // MQTT Broker 地址 port: 8084, // WSS 默认端口 username: 'web_scada', password: 'web_scada_2024', clientId: `web_${Math.random().toString(16).substring(2, 10)}`, keepalive: 30, clean: true, connectTimeout: 10000, // 连接超时 10s reconnectPeriod: 5000, // 断线重连间隔 5s resubscribe: true, // 重连后自动恢复订阅 } this.client = mqtt.connect(options) this.client.on('connect', () => { this.connected = true this.client?.subscribe('factory/+/data', { qos: 0 }) this.client?.subscribe('factory/+/alarm', { qos: 1 }) }) this.client.on('message', (topic, payload) => { const msg = JSON.parse(payload.toString()) this.handleMessage(topic, msg) }) this.client.on('reconnect', () => { console.warn('MQTT 连接断开,正在重连...') }) this.client.on('error', (err) => { ElMessage.error(`MQTT 连接错误: ${err.message}`) }) }, handleMessage(topic: string, msg: any) { // 解析 topic: factory/{deviceId}/data const match = topic.match(/^factory\/(.+)\/(data|alarm)$/) if (!match) return const deviceId = match[1] // 浅放对象引用,不触发深层响应式 this.deviceData[deviceId] = msg }, disconnect() { this.client?.end(true) this.connected = false }, }, })这段代码有几个细节值得展开。clientId用随机数生成是因为同一个 clientId 同时连接会被 Broker 踢掉,在多标签页场景下这是最常见的互踢根因。resubscribe: true是 mqtt.js 的一个自动恢复订阅的开关,重连成功后会自动执行之前的 subscribe 调用。message回调里我只做了浅放对象引用,没有立刻触发 Vue 的响应式系统。如果 1000 个点位每秒各推一条消息,每条消息都触发响应式依赖更新,页面必卡无疑,这个优化细节后面单独讲过。
2.3 Broker 端的订阅发布设计
Broker 我用的 EMQX 5.x,因为工具链完整、WebSocket 支持好、开源版功能足。主题命名遵循“设备/数据类型”的层级结构,factory/{deviceId}/data承载实时点位数据,factory/{deviceId}/alarm承载报警信息。这套命名规则的好处在于可以使用通配符一次订阅所有设备,还能在 Broker 端做基于主题的访问控制。
发布端的工程注意事项:PLC 网关程序把点位数据批量上报到 MQTT,最优做法是聚合数据,比如同一设备的几十个点位合成一条 JSON 报文批量发布,而不是一个点位一条消息。我在项目里遇到过每秒钟 200 条小消息冲击的情况,前端处理起来每个消息都要走一遍解析-分发-更新流程,同时 Broker 端维持的会话状态也大,换成聚合消息后 Topic 数量不变但消息量降到每秒 20 条左右,整体稳定性提升很明显。
3. 不稳定问题排查与分析:从连接闪断到数据风暴
3.1 连接闪断:心跳冲突与浏览器兼容性
项目上线第一周最头疼的问题就是连接闪断。客户端连接大约稳定运行 3-5 分钟后被断开,然后自动重连,重连后再断开,循环往复。排查这个问题花了整整一个下午,最后发现是两个原因叠加的。
第一个原因是心跳冲突。老设备网关注册到 Broker 的心跳间隔是 60 秒,Web 前端设的 keepalive 是 30 秒,两者本身不冲突。问题是 Broker 端对心跳判定是“在一个半心跳周期内没收到任何报文就断开”,我这边前端的 30 秒心跳周期是发送 PINGREQ 的周期,不是业务数据保活周期。如果页面一直在收消息,TCP 层是活跃的,但 MQTT 协议层在 30 秒没有任何 PINGREQ 也会被视为心跳超时。后来我调整成 15 秒保活,同时把 Broker 端 EMQX 的keepalive_backoff调大到 1.5 倍,问题会缓解,但没有根治。
第二个原因才是真正的主因:浏览器对 WebSocket 长连接的空闲超时策略。Chrome 对处于空闲状态的 WebSocket 连接有一个空闲超时机制,大约是 10 分钟左右没有任何数据传输时强制断开。而我们的监控页面如果一直有数据流动是不受影响的,但有些画面切换到不常看的页面时,数据订阅照旧,界面不展示,浏览器底层可能就判定为空闲,掐掉 WebSocket。解决方式是在前端加了一个 25 秒一次的应用层心跳发布,写一条factory/{clientId}/ping主题的消息,不仅仅是接收数据,还要主动往外发,维持连接活跃。这样既满足了 MQTT 协议层的心跳要求,又避开了浏览器空闲超时的坑。
3.2 消息风暴:发布订阅频率与前端处理瓶颈
另一个高频问题是数据量大时页面卡死。有个车间的设备点位做了 200 个数据标签,网关每 500 毫秒发布一次更新报文,理论上是每秒 400 条消息。消息量看起来不大对不对?但注意 MQTT 的机制是每条消息独立一条发布报文,每条报文都要解析 JSON、匹配 Topic、更新 Vue 响应式状态。当页面上的表盘组件对每个点位都做了响应式绑定,每秒 400 次更新会导致整个组件树疯狂重渲染。
我一度认为问题是 ECharts 图表的 setOption 调用太频繁导致的,后来在 Vue DevTools 里看性能分析才发现,渲染开销的大头其实是无处不在的响应式依赖收集。每个点位值都放在reactive或ref里,意味着任何组件只要读取了这个值就建立了依赖,值一变化组件就重新渲染。SCADA 页面里一个设备卡片可能显示 20 个参数,400 条消息变化就意味着 8000 次响应式依赖触发,哪怕每次只是改一两个 DOM 文本节点,综合计算量也相当可观。
3.3 数据延迟与时间线错乱:毫秒级戳与本地时钟
还遇到过一个非常隐蔽的坑:前端展示的实时曲线和实际工况差了十几秒,而且时间越久误差越大。排查时先怀疑是 Broker 转发延迟,看了 EMQX 的监控面板确认转发延迟只有几十毫秒,数据链路没有问题。后来发现是数据自身的问题——网关发布数据的 JSON 里带的时间戳是 PLC 的本地时间,而前端图表显示的时间线用的是浏览器本地时钟,两个时钟没有做过同步校准,累计误差就造成了时间线错乱。
这个问题的根因是工业现场很多老设备的时钟没有配置 NTP 同步,关机重启后 PLC 时间就慢了。解决方式有两层:前端把时间轴改成显示“数据采样时间”,也就是数据自带的 timestamp 字段,而不是显示接收时间;同时推进现场设备接入 NTP 时间同步,至少保证网关和 PLC 的时间一致。对已经发生的时间偏移,我写了一个动态时移补偿的辅助函数,在展示层计算数据时间戳和当前时间的差值做平滑修正,让曲线的时间轴在同一尺度上对齐。
4. 稳定性优化实践:从前端到后端的完整加固
4.1 连接层优化:自动重连与退避策略
断线重连是 SCADA 高可用方案里最重要的一环。mqtt.js 自带的reconnectPeriod是固定间隔重连,默认 1000 毫秒意味着每 1 秒尝试重连一次。如果服务器连续不可用五分钟,客户端会产生 300 次 TCP 连接尝试,非常消耗资源。我的做法是实现一套指数退避的重连策略,控制重连频率。
// src/utils/reconnect.ts export class ReconnectManager { private baseDelay = 1000 private maxDelay = 30000 private attempts = 0 private timer: any = null constructor(private onReconnect: () => void) {} reset() { this.attempts = 0 this.clearTimer() } schedule() { this.clearTimer() // 指数退避 + 抖动:1s -> 2s -> 4s -> 8s ... 最大 30s const delay = Math.min(this.baseDelay * Math.pow(2, this.attempts), this.maxDelay) // 增加 0~1000ms 的随机抖动,避免多个客户端同时重连导致惊群 const jitter = Math.random() * 1000 this.timer = setTimeout(() => { this.attempts++ this.onReconnect() }, delay + jitter) } clearTimer() { if (this.timer) { clearTimeout(this.timer) this.timer = null } } }给重连时间加随机抖动也是个有价值的细节,避免服务重启时数百个客户端同时重连把 Broker 打垮。mqtt.js 底层其实已经有reconnectPeriod参数,但固定间隔无法退避,所以我在外部封装了这一层。更重要的是,重连成功后的数据补偿策略:如果断线期间产生了数据空洞,需要重新拉取一次全量快照,而不是等实时数据慢慢流过来。我的做法是 Broker 端用 EMQX 的数据桥接把点位最新值存到 Redis,前端重连成功后发一条快照请求主题,网关收到后发布一条完整快照。这个机制把恢复时间从最多几十秒压缩到 1-2 秒内。
4.2 数据层优化:Vue3 响应式系统与批量更新
这一步是整个项目的核心优化点,解决了之前提到的响应式风暴问题。核心思路是:频繁变更的实时数据不应该进入 Vue 深度响应式系统,只在需要展示的边界转换为响应式数据。
具体做法是维护一个非响应式的Map来存原始点位数据,然后在组件层通过一个tick计数器强制触发视图更新。每次网络消息到来,只更新 Map 的原始数据,等到批量更新时钟触发时,才把需要展示的数据快照一次性拷贝到响应式变量里。
// src/store/scadaData.ts import { ref, shallowRef } from 'vue' // rawData 是非响应式的普通对象,存最新点位值 const rawData: Record<string, any> = {} // 对外暴露的响应式版本,只存储渲染所需的最小视图模型 const viewModel = shallowRef<Record<string, any>>({}) // 批量更新节流:50ms 合并一次视图刷新 let updateTimer: number | null = null let pendingChange = false export function pushMessage(deviceId: string, payload: any) { // 1. 更新原始数据区,不走响应式系统,性能极高 rawData[deviceId] = payload // 2. 标记有变更,等待批量刷新 pendingChange = true if (!updateTimer) { updateTimer = window.setTimeout(() => { // 3. 只拷贝一份浅快照触发视图更新 viewModel.value = { ...rawData } pendingChange = false updateTimer = null }, 50) } }shallowRef在这里极其关键,它只对顶层值变化做响应式监听,不会递归处理对象内部字段的变化,所以每次viewModel.value = { ...rawData }是一次浅拷贝,触发一次组件重渲染,而不是 400 次。配合设备卡片组件的深比较 props,实际渲染次数可以降 60%-70%。
另一个配合优化是给高频更新的组件加v-memo指令。Vue3 的v-memo可以指定一个依赖数组,如果数组里的值没变,组件就不会重新渲染。我把它用在设备列表的每一项上:
<div v-for="device in deviceList" :key="device.id" v-memo="[device.id, device.name, device.status]"> <DeviceCard :device="device" /> </div>确保只有当status、name等实际变化时才重渲染卡片。从结果数据看,一个 200 个点位的车间画面,优化前页面卡顿明显,优化后 CPU 占用下降了 70% 以上,帧率稳定在 50fps 以上。
设备状态颜色闪烁的问题也从另一个角度印证了响应式优化的必要性。点位值变化太快,状态指示灯一直在红绿切换,很像报警。我后来把所有状态颜色都做了“稳定区域”过滤,只有持续 200ms 以上的状态变化才更新指示灯颜色,既减轻渲染压力,也避免闪烁给操作员造成误导。
4.3 渲染层优化:虚拟列表与图表防抖
SCADA 大屏上常见百级甚至千级设备点位的列表展示。如果直接把所有设备渲染成真实 DOM,即使数据更新频率不高,初始化时的 DOM 创建和布局计算也会让首次加载很慢。我引入虚拟滚动方案,仅渲染可视区域内的设备卡片,滚动时动态替换。这个方案对固定高度卡片很容易实现,配合上一步的批量更新机制,列表滚动和数据刷新互不干扰。
ECharts 趋势曲线的数据更新同样要做防抖。实时曲线要展示最近一分钟的采样点,每秒钟追加一个新点,通过setOption更新。如果直接每帧调用setOption,ECharts 内部的动画和渲染计算会成为性能黑洞。我的做法是每 500ms 合并一次数据更新,同时关闭趋势图的动画效果。具体配置如下:
// ECharts 实时曲线的优化配置 const chartOption = { animation: false, // 实时更新不需要动画过渡 progressive: 1000, // 上千个数据点分批渲染 large: true, // 开启大数据量优化模式 }要特别提醒一点,large: true是 ECharts 折线图在大数据量下必须开启的选项,它的底层用 Canvas 批量绘制而不是逐个创建 Path 图元,性能差距可以达到一个数量级。
4.4 消息可靠性与去重机制
之前提到的 QoS 1 消息可能重复投递、控制指令不能重复执行,我在业务层实现了幂等处理。入口是给每条控制指令加msgId唯一标识,前端发送时生成 UUID,Broker 消息到了设备端,设备侧维护一个最近 5 分钟内已执行的msgId缓存。这条消息即使是重复投递,只要msgId在缓存里就忽略。因为我不能完全控制设备端修改代码,所以这条机制在实际落地时是在边缘网关(也就是设备数据采集程序)实现的,用户侧 PLC 收到的指令天然不重复。
Mqtt.js 的messageId是协议层的消息编号,每个消息的 Packet ID 会递增,但 QoS 1 重传时 Packet ID 不变。我不知道有其他好办法在纯前端识别一条控制指令是否重复执行过,所以msgId字段从业务层加入,是最可靠的做法。数据点位到达设备端后的时间顺序错乱问题,我用了一个单调递增的序列号字段来排序:前端如果发现新消息的序列号小于当前已处理序列号,直接丢弃或者标记为过期。
5. 常见问题与排查技巧实录
整理一下这个项目里实际遇到且值得记录的问题,做成一个速查表,方便读者按图索骥。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 连接运行几分钟后自动断开 | 浏览器空闲超时 + MQTT 心跳超时 | 应用层 25 秒心跳发布 + 合理 keepalive |
| 重连后收不到数据 | 重连后未重新订阅 Topic | 开启resubscribe: true+ 手动调用 subscribe |
| 页面卡顿、CPU 占用高 | 消息频繁触发 Vue 响应式更新 | 非响应式数据层 + shallowRef + 批量刷新 |
| 设备状态灯闪烁严重 | 点位值频繁抖动 | 稳定区域:200ms 状态变化过滤 |
| 曲线时间轴错乱 | 设备时钟未 NTP 同步 | 使用数据自带时间戳 + 动态时延补偿 |
| 同一 clientId 反复被踢下线 | 多标签页/多客户端 clientId 冲突 | clientId 加随机后缀,或限制单会话 |
| 网关上报消息量过大 | 逐点位逐条发布 | 多条点位聚合为 JSON 数组批量发布 |
| tab 页签切换回来数据空白 | 后台 tab 被浏览器限流,WebSocket 断连 | 监听visibilitychange事件手动重连 |
每个问题都有对应的处理细节可以展开,这里挑两个影响面最大的补充说明。
第一个是多标签页互踢的问题。操作员经常同时打开多个监控页面,每个标签页都创建独立的 MQTT 连接,使用同样的 clientId 连接 Broker,新连接会强制把旧连接踢下线。踢了再连、连了再踢,表面上看起来就是连接一直闪断。我最后的方案是使用 Tab 间共享通信,只保留第一个标签页的连接,其他标签页通过BroadcastChannel接收数据。这样不仅解决了互踢问题,还把浏览器整体的连接数降到了一个,同时标页面的内存占用也降下来了。实现上要处理 Tab 关闭后的“接管”逻辑,核心代码是监听beforeunload事件通知其他 Tab 重新建立连接。
第二个是网关端 NTP 时间同步缺失导致的时间错乱。在纯前端排查时我用performance.now()对比本地时间戳,能推断出数据的时间戳是不是真实产生时间。后来我建议现场网络加了一台 NTP 时间服务器,网关和 PLC 统一时间源。实时监控系统的时间轴准确性和真实工况对齐非常关键,一旦时间错了,报警追溯、历史回放全都对不上,这个坑必须尽早填。
补充一个 EMQX 的调优细节:Broker 端 WebSocket 连接数上限默认是 1024,我们的现场可能会出现同时在线超过这个数字的情况,需要在emqx.conf里调整listener.ws.external.max_connections。同时要留意打开zone.mqtt.max_mqueue_len来限制离线消息积压长度,防止某个断线客户端重新连接后被积压的历史消息洪水冲垮。
6. 性能压测与稳定性验证
优化动作做完了,必须用数据说话。验证过程分成三步:连接稳定性压测、消息吞吐压测、前端页面性能压测。
连接稳定性压测我用的是mqtt-benchmark工具,模拟 1000 个客户端同时连接 EMQX,每 5 秒执行一次发布/订阅操作,持续 12 小时。结果连接全程无闪断,Broker CPU 峰值 65%,内存稳定在 1.2GB。期间我额外模拟了 20 次 Broker 重启,客户端重连成功时间最慢不超过 8 秒,恢复后数据能续传。
消息吞吐压测针对前端处理链路,用 Node 脚本以 500 条/秒的速度向 Broker 发布消息,前端页面保持打开。优化前这个量级下页面已经卡死,延迟超过 20 秒;优化后页面操作仍然流畅,数据展示延迟稳定在约 200ms 以内,CPU 占用大约 35%。再升高到 1000 条/秒,页面帧率开始下降但不会崩溃,说明瓶颈已经转移到浏览器渲染层而不是通信层。
前端页面性能我直接用 Chrome DevTools Performance 面板记录,具体关注三个指标:脚本执行时间、渲染时间、内存曲线。脚本执行时间从每条消息约 3ms 优化到约 0.5ms,渲染时间在 50ms 内稳定,内存曲线没有出现持续增长。关于内存泄漏专门强调一下:SCADA 系统是长期运行型业务,泄漏问题不会立刻暴露,但运行三五天后页面会越来越卡,最后浏览器崩溃。我在项目中检查出的泄漏点主要在两个地方,一是setInterval定时器或事件监听没有在onUnmounted清理,二是 ECharts 实例没有调用dispose。凡是创建了定时器或监听了window事件的组合函数,我都统一添加了onScopeDispose回调做清理。
最后上一组压测数据供参考对比:
| 节点 | 优化前 | 优化后 |
|---|---|---|
| 最大消息处理速率 | 约 120 条/秒 | > 1000 条/秒 |
| 稳定运行最大时长 | 3-5 分钟闪断 | 连续运行 14 天以上 |
| 重连恢复时间 | 依赖固定间隔,最长 30s | 指数退避最大 30s,配合快照恢复约 2s |
| 200 点位画面 CPU | 卡顿,CPU 经常 80% | 流畅,CPU 稳定 25-35% |
| 内存占用基线 | 波动大,疑似泄漏 | 全天内存曲线平稳,24 小时内无持续攀升 |
这里要额外提醒,压测必须使用生产环境的真实网络和真实 Broker 配置做验证。我在开发环境压测一切正常,上生产环境的公网链路后依然发现了 TLS 握手延迟问题和 NAT 超时断连的问题,只能在现场环境再用 production 配置复测一轮,这个经验希望读者少走弯路。
有个细节值得补充:前端本地的 SSL 证书在 WebSocket 长连接场景下会话恢复效率也影响稳定性。推荐为 Broker 配置完整证书链,不要使用自签名证书,避免浏览器 SSL 握手超时导致的连接不稳定。如果确实只能在局域网用自签证书,也要把证书正确导入系统信任库,不要用rejectUnauthorized: false跳过校验,因为那会让后续的匿名数据流暴露在危险中。
我记得最深的一次现场问题是在客户那里,厂家给了一个用 Java 写的模拟器,每 200ms 发布一次模拟数据。结果页面大概运行半小时后所有设备状态全部变成离线,但 MQTT 连接本身是正常的。排查了很久才发现,这个模拟器运行时 Topic 层级和真实网关的不完全一样,发布到了一个不存在的主题分支,而且发布频率远超正常设备。前端订阅的factory/+/data通配符没匹配上这个分支,所以页面上看不到任何数据,但连接本身没有断开,显示不出来就像是离线了。所以 SCADA 项目上线前一定要核对 Topic 命名规范,并且通过 Broker 的可视化监控面板观察消息流向,而不是只依赖前端页面反馈。EMQX 的 Dashboard 里可以看到实时消息速率、每个 Topic 的流量,排查这类问题非常方便。
关于visibilitychange事件还有一个值得注意的细节。浏览器切到后台一段时间后,定时器会被节流到最少 1 秒一次,WebSocket 消息也不再实时处理。操作员切换回来时,如果页面展示的是一个设备列表,数据源一直有更新但 DOM 没刷新,就会看到一个“活的假页面”。我的做法是监听visibilitychange,当页面重新可见时强制触发一次viewModel.value = { ...rawData }的刷新,同时调用一次client.pingreq()主动探测连接状态,如果发现连接异常立即触发重连流程。
关于心跳机制的设计再补充一个容易踩坑的地方:别用setInterval发送 PINGREQ,要用递归setTimeout。因为setInterval如果上一个回调还没执行完就会开始下一个,网络波动时可能堆积大量心跳请求。递归setTimeout可以确保每次心跳发送完成后再排下次,配合重连管理器统一管理。
7. 写在最后的几个经验教训
这个项目做下来,我最想分享的不是哪段代码、哪个库用得好,而是整体的架构思路。做 Web SCADA 这类实时性要求高、运行周期长的系统,稳定性的优先级远远高于功能的丰富度。功能少一个可能只是不方便,稳定性不行就是现场事故。
第一个教训:实时系统的所有状态要考虑断线恢复后的状态一致性,不能只盯着“在线时”的数据流。连接断开时发的控制指令、断线期间产生的报警、重连后的数据快照,这些边界场景才是稳定性方案的主要工作量所在。
第二个教训:性能优化要在设计阶段就考虑。如果我在项目初期就定好“实时数据走非响应式通道、展示层浅快照更新”的架构,后面就不会花那么多时间重构了。SCADA 这种高频率多数据点的场景和普通管理系统完全不同,普通后台管理系统处理的是“变化频率低的结果数据”,SCADA 处理的是“一直在变的流数据”,从一开始意识到这个差异,方案选型会议上面就会被好几天。
第三个教训:现场调试永远比模拟环境复杂。网络波动、PLC 重启、网关固件版本差异、浏览器版本差异都会带来意想不到的问题。别只在自己电脑上测试,尽量提前部署到目标网络环境跑一段时间。
后续扩展方向上,我已经在计划把 Web SCADA 的组件库抽成独立的 npm 包,让后续项目直接复用。通信层会试验 MQTT over QUIC 方案,进一步降低弱网环境下的连接延迟。如果你正在做类似项目,或者这个实践里的某些解决思路对你有启发,欢迎在评论区交流具体的实现细节和排坑经验。