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: Recordstring, any {} // 对外暴露的响应式版本只存储渲染所需的最小视图模型 const viewModel shallowRefRecordstring, 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-fordevice in deviceList :keydevice.id v-memo[device.id, device.name, device.status] DeviceCard :devicedevice / /div确保只有当status、name等实际变化时才重渲染卡片。从结果数据看一个 200 个点位的车间画面优化前页面卡顿明显优化后 CPU 占用下降了 70% 以上帧率稳定在 50fps 以上。设备状态颜色闪烁的问题也从另一个角度印证了响应式优化的必要性。点位值变化太快状态指示灯一直在红绿切换很像报警。我后来把所有状态颜色都做了“稳定区域”过滤只有持续 200ms 以上的状态变化才更新指示灯颜色既减轻渲染压力也避免闪烁给操作员造成误导。4.3 渲染层优化虚拟列表与图表防抖SCADA 大屏上常见百级甚至千级设备点位的列表展示。如果直接把所有设备渲染成真实 DOM即使数据更新频率不高初始化时的 DOM 创建和布局计算也会让首次加载很慢。我引入虚拟滚动方案仅渲染可视区域内的设备卡片滚动时动态替换。这个方案对固定高度卡片很容易实现配合上一步的批量更新机制列表滚动和数据刷新互不干扰。ECharts 趋势曲线的数据更新同样要做防抖。实时曲线要展示最近一分钟的采样点每秒钟追加一个新点通过setOption更新。如果直接每帧调用setOptionECharts 内部的动画和渲染计算会成为性能黑洞。我的做法是每 500ms 合并一次数据更新同时关闭趋势图的动画效果。具体配置如下// ECharts 实时曲线的优化配置 const chartOption { animation: false, // 实时更新不需要动画过渡 progressive: 1000, // 上千个数据点分批渲染 large: true, // 开启大数据量优化模式 }要特别提醒一点large: true是 ECharts 折线图在大数据量下必须开启的选项它的底层用 Canvas 批量绘制而不是逐个创建 Path 图元性能差距可以达到一个数量级。4.4 消息可靠性与去重机制之前提到的 QoS 1 消息可能重复投递、控制指令不能重复执行我在业务层实现了幂等处理。入口是给每条控制指令加msgId唯一标识前端发送时生成 UUIDBroker 消息到了设备端设备侧维护一个最近 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配合快照恢复约 2s200 点位画面 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 方案进一步降低弱网环境下的连接延迟。如果你正在做类似项目或者这个实践里的某些解决思路对你有启发欢迎在评论区交流具体的实现细节和排坑经验。