Vue3 大屏实时数据总线:用 MQTT 构建可共享的设备状态流
面向读者:具备 Vue3 + TypeScript 基础的前端工程师
讨论范围:浏览器侧如何接入 MQTT、如何把持续涌入的设备报文收敛成可被多个页面共享的响应式状态
文中代码均为示意性伪代码,命名与结构可整体迁移到自己的项目
1. 架构定位与整体设计
1.1 什么时候该用 MQTT 而不是轮询
大屏类应用的核心诉求是「设备状态实时可见」:设备在线与否、位置与姿态、指令执行到第几步。
如果用 HTTP 轮询,1 秒一次 × N 台设备意味着大量空请求,而且延迟取决于轮询间隔、数据会抖动。MQTT (Message Queuing Telemetry Transport,一种基于发布/订阅模型的轻量消息协议)正好反过来:设备持续把状态发布 到某个主题,需要数据的一方订阅 该主题,中间的 Broker(消息服务器)负责按订阅关系转发。状态一变就推一次,天然适合「多设备、持续变化、只关心最新值」的场景。
浏览器无法直接使用 MQTT 的 TCP 传输,因此统一走 WebSocket 承载,即 ws:// / wss:// 地址。
1.2 三个关键设计决策
① 浏览器直连 Broker,中间不搭转发服务
判断依据很简单:如果前端需要的只是「订阅若干主题、读最新值」,再自建一层 Node 转发只会增加延迟与运维成本。直连的前提是 Broker 支持 WebSocket 并开放只读订阅权限(见 7.8)。
② 单例连接 + 模块级响应式数据总线
连接实例和数据容器都放在模块顶层,任何 import 该模块的组件拿到的是同一份引用:
ts
import { ref, shallowRef } from 'vue';
// 连接实例:shallowRef 只响应 .value 的整体替换
export const client = shallowRef(null);
// 数据容器:ref 深响应,组件 watch 即可拿到推送
export const osdStore = ref<Record<string, any>>({});
这里 shallowRef 与 ref 的分工必须说清楚:
- 连接实例内部持有 socket、stream、定时器等非普通对象,若被 Vue 递归代理成 Proxy,容易出现「方法丢失、事件不触发」这类极难定位的问题,所以必须用
shallowRef; - 数据对象需要「字段级」响应(某个
battery变了就刷新 UI),因此用ref,并在使用侧配合deep: true的watch。
③ 只订阅、不发布
控制类动作(启动任务、返航、云台转动)一律走 HTTP 接口,由服务端做鉴权、幂等与审计。前端只保留「看」的能力。这样既拿不到设备控制权限,也避免在浏览器产物里持有发布凭证。
1.3 整体结构
为什么要有这么多容器,而不是一个「大对象」?因为不同数据的消费方式完全不同:位置数据需要高频重绘地图,事件数据需要弹窗与播报,进度数据需要节流渲染进度条。分开存放后,每个组件可以只订阅自己关心的那一块,避免「一条报文触发全站组件重算」。
1.4 多页面共享同一条连接
连接与数据都在模块作用域,页面切换不会重建连接、不会丢数据。好处很直接:
- 从列表页跳到详情页时,设备状态是「现成的」,不需要重新拉一次;
- 不会因为每个组件各开一条 WebSocket 而撞上浏览器对同域并发连接数的上限。
代价是「谁都能改这份全局状态」,所以订阅范围的变化与连接的销毁时机必须有明确归属,这也是下一节的重点。
1.5 术语速查
| 术语 | 含义 |
|---|---|
| Topic | 消息路由路径,用 / 分层,如 iot/{tenantId}/osd/{deviceSn} |
| Broker | 消息服务器,负责接收发布并按订阅关系转发 |
| OSD | 设备状态快照,通常是增量报文:每条只带变化的部分 |
| 通配符 | + 匹配一层,# 匹配多层,只能用于订阅、不能用于发布 |
| keepalive | 心跳周期,超过约定倍数没心跳,Broker 判定客户端离线 |
| clientId | 会话标识,同一 Broker 上重复的 clientId 会互相挤下线 |
2. 连接建立与生命周期
2.1 配置从哪来
ts
// 建议把可变部分收敛成常量,方便一处排查
const MQTT_URL = import.meta.env.MQTT_URL; // 构建期注入
const MQTT_USER = import.meta.env.MQTT_USER;
const MQTT_PASS = import.meta.env.MQTT_PASS;
三个必须知道的约束:
- 构建期注入 :这类变量在
dev启动或build打包时被静态替换进产物,改完环境文件必须重启或重新构建,浏览器里改window上的值没用。 - 前缀有讲究 :以 Vite 为例,只有约定前缀(如
VITE_)的变量才会暴露给客户端,其余一律是undefined。示例里为了简洁省略了前缀,实际接入时请按构建工具约定补上。 - 模式要与配置对齐 :如果地址是通过「构建模式 → 地址」的映射表取出来的,那么每新增一种构建模式(如
staging、preview),都要同步新增映射,否则取到undefined(见 7.1)。
2.2 clientId 为什么要带随机串
ts
const clientId = `${randomId()}-WEB`;
Broker 对同一个 clientId 只保留一个会话:后连接者会把先连接者踢下线。
- 若写死
clientId,用户同时开两个标签页(大屏 + 预览页)就会互踢,现象是「数据一阵一阵地来」「控制台反复重连」; - 加随机串可保证每个标签页独立会话;
- 再拼一个
-WEB之类的后缀,便于运维在 Broker 侧一眼区分「网页端」和「设备端」。
2.3 三个时间参数怎么定
| 参数 | 建议值 | 含义与取舍 |
|---|---|---|
keepalive |
15s | 心跳周期。取值小 → 断线感知快,代价是心跳包更频繁;Broker 一般按 1.5 倍未收到心跳判离线 |
connectTimeout |
30s | 建连超时。跨公网 + WebSocket 握手,给足余量避免弱网误判 |
reconnectPeriod |
4s | 自动重连间隔。设为 0 会关闭自动重连;4s 是「恢复速度」与「失败风暴」之间的折中 |
这几项不是拍脑袋定的:大屏要求「断线后尽快自愈、但不能疯狂重连把 Broker 打挂」,所以心跳偏小、重连间隔偏大。
2.4 建立与关闭的执行序列
对应的伪代码:
ts
export const openChannel = (topics: string[]) => {
closeChannel(); // 关键:先关旧连接
client.value = mqtt.connect(MQTT_URL, {
clientId, username: MQTT_USER, password: MQTT_PASS,
keepalive: 15, connectTimeout: 30_000, reconnectPeriod: 4_000,
});
client.value.on('connect', () => client.value?.subscribe(topics));
client.value.on('message', handleMessage);
};
export const closeChannel = () => {
const c = client.value;
if (!c) return;
c.removeAllListeners(); // 先摘监听
c.end(true); // 再强制关闭
client.value = null; // 最后置空
};
为什么必须「先关闭旧连接」
- 单例只保存一个实例。直接覆盖赋值,旧实例的 WebSocket 仍然连着,浏览器会同时持有 2 条、3 条连接,达到并发上限后新连接直接失败;
- 旧实例上的
message监听器依然存活,会把旧订阅范围的报文继续写进同一份全局状态,出现「切了设备还能收到上一台设备的数据」; - 旧实例既没有
off也没有end,成为真正意义上泄漏的僵尸连接。
为什么 subscribe 写在 connect 回调里
mqtt.js 的重连成功后会重新派发 connect 事件。把订阅放进这个回调,等于免费获得了「重连后自动恢复订阅」的能力,不需要额外写重连逻辑。
为什么 removeAllListeners 要在 end 之前
end() 是异步优雅关闭,期间仍可能派发 close / error。若先把 client.value 置空再 end(),这些回调访问 client.value.xxx 时就是 null,控制台立刻刷 Cannot read properties of null。
2.5 生命周期归属
| 位置 | 何时连 | 订阅什么 | 何时断 |
|---|---|---|---|
| 应用布局层 | 登录后拿到设备列表 | 列表中每台设备的状态主题 | 布局卸载(登出、整页卸载) |
| 详情页 | 进入页面 / 切换设备 | 当前设备 + 其子设备 + 业务事件 + 进度 | 离开页面 |
| 地图组件 | 地图初始化 / 被 KeepAlive 激活 | 可见设备的状态主题 | 失活 / 卸载 |
| 列表卡片 | 可见卡片集合变化 | 可见设备的状态主题 | 由路由切换时的其他位置统一关闭 |
一句话原则:「订阅范围变了就重连,页面销毁就断开」 。除此以外不要随手调 openChannel------这是 7.2 那个坑的根源。
3. Topic 设计规范
3.1 用工厂函数代替手写字符串
主题是字符串拼接,手写极易漏层级、拼错租户。统一提供几个构造函数:
ts
type TopicArgs = { tenantId: string; deviceSn?: string };
// 单设备状态主题
export const buildTopic = ({ tenantId, deviceSn }: TopicArgs) =>
`iot/${tenantId}/osd/${deviceSn}`;
// 租户级事件主题
export const buildTenantTopic = ({ tenantId }: TopicArgs) =>
`iot/${tenantId}/event`;
// 通配符订阅:+ 匹配一层设备号
export const buildWildcardTopic = ({ tenantId }: TopicArgs) =>
`iot/${tenantId}/osd/+`;
| 函数 | 产物 | 层级语义 | 适用场景 |
|---|---|---|---|
buildTopic |
iot/{tenantId}/osd/{deviceSn} |
租户 / 设备 | 已知具体设备,精确订阅 |
buildTenantTopic |
iot/{tenantId}/event |
租户 | 业务事件(任务状态、告警通知) |
buildWildcardTopic |
iot/{tenantId}/osd/+ |
租户 / 任意设备 | 需要「全部设备」的态势页面 |
把主题拼接集中到三个函数上,还有一个隐性收益:将来若 Broker 升级、主题前缀调整,只需改这三行。
3.2 通配符到底该不该用
+ 匹配一层 ,# 匹配多层及其父层 。buildWildcardTopic 用 + 而不是 #,就是为了把范围严格限制在「状态主题的设备层」,不会误收租户事件、进度主题等兄弟分支。
| 方案 | 优点 | 代价 |
|---|---|---|
| 通配符一次订阅 | 设备增减无需重连,代码最简 | 消息量随设备总数线性增长,全部写入状态总线,watch 开销上升 |
| 按 SN 精确订阅 | 只收关心的设备,总线体量可控 | 设备集合变化要重连或增量订阅 |
实践建议:默认精确订阅,把通配符留给「全域态势」这类必须全量的页面。大屏通常只盯着少数几台设备,通配符带来的额外流量没有收益,只会让深比较更慢。
3.3 母设备与子设备在主题上的关系
同一套命名空间下,母设备(如停机坞)与子设备(如飞行器)主题结构完全同构 ------都是设备号占同一层,都由 buildTopic 拼出来。区别只在报文内容:
| 维度 | 母设备状态 | 子设备状态 |
|---|---|---|
| 主题 | iot/{tenantId}/osd/{dockSn} |
iot/{tenantId}/osd/{deviceSn} |
归属字段 hubSn |
通常为空,也可能等于自身 | 等于所属母设备 |
| 典型内容 | 舱门、温控、网络等自身状态 | 位置、姿态、电量等运动状态 |
| 写入容器 | dockStore[dockSn] |
osdStore[hubSn] |
| 消费方 | 设备详情、调试面板 | 地图、仪表 HUD |
注意子设备状态用的是 hubSn 作为 key ,而不是子设备自己的 SN。这是刻意的:详情页永远是「按当前母设备取数」,用 hubSn 做 key 才能一行代码拿到当前设备名下的全部子设备数据。
3.4 完整主题清单(示例)
| 主题(示意) | 生成方式 | 用途 |
|---|---|---|
iot/{tenantId}/osd/{deviceSn} |
buildTopic |
母/子设备状态快照 |
iot/{tenantId}/event |
buildTenantTopic |
租户级业务事件 |
cmd/progress/{dockSn} |
模板串 | 指令执行进度 |
notice/task/created/+ |
模板串(注意是否带前导 /) |
任务创建通知 |
track/realtime、track/conflict |
模板串 | 实时轨迹与冲突告警 |
iot/{tenantId}/osd/+ |
buildWildcardTopic |
全设备状态(慎用) |
这张表本身就是一个提醒:同一 Broker 上常常并存多套命名空间(设备域、通知域、轨迹域),它们的风格未必一致。接入前先向服务端确认真实主题字面量,别靠猜。
4. 消息分发与解析
4.1 一个入口,按特征分流
所有订阅最终汇入同一个 message 回调,用「主题特征 + 报文特征」两把尺子分发:
4.2 从主题反解设备号
ts
const seg = topic.split('/');
const last = seg.at(-1);
// 兼容两种结尾:`.../{sn}` 与 `.../{sn}/osd`
const topicSn = last === 'osd' ? seg.at(-2) : last;
const isOsd = topic.includes('/osd') && !!data.body && !!topicSn;
为什么不直接读报文里的 deviceSn?因为母设备状态报文往往不带该字段,主题是唯一可靠的来源;而子设备报文有时也缺,只能从主题末段推断。两处都要兜住,所以统一从主题解析。
isOsd 额外要求「主题含 /osd」且「有报文主体」,是为了排掉同一前缀下的兄弟主题(进度、事件),避免把非状态报文合并进状态容器。
4.3 母设备与子设备状态如何区分
ts
// 母设备描述自己:hubSn 缺失,或等于本消息的设备号
if (isOsd && (!data.hubSn || data.hubSn === topicSn)) {
merge(dockStore, topicSn, data.body);
}
// 子设备描述自己,并声明归属:hubSn 存在且不同于自身设备号
if (data.hubSn && data.hubSn !== topicSn) {
merge(osdStore, data.hubSn, data.body);
}
判定依据是「报文里的归属字段是否等于本消息的设备号」:
- 相等或缺失 → 设备在描述自己 (母设备才有这个资格),进
dockStore; - 存在且不等 → 子设备在描述自己,并声明「我属于某台母设备」,进
osdStore[hubSn]。
这里有个非常经典的事故 :早期只写了 if (data.hubSn && data.body) 就塞进 osdStore,而母设备报文也可能带 hubSn(值等于自己),于是母设备状态被「子设备分支」吞掉,只读 dockStore 的面板永远拿到空对象------表现是「子设备数据正常,母设备状态一直是占位符」。补上 hubSn === topicSn 判断并拆分容器后消失。
教训:分流条件要覆盖「字段存在但语义相反」的情况,不能只判断字段存在。
4.4 数据容器对照表
| 容器 | 写入条件 | key | value | 谁在用 |
|---|---|---|---|---|
osdStore |
hubSn 存在且 ≠ topicSn |
hubSn(母设备号) |
合并后的报文主体 | 地图、仪表、视频源切换 |
dockStore |
isOsd 且(无 hubSn 或 hubSn === topicSn) |
topicSn |
合并后的报文主体 | 设备详情、调试面板 |
deviceStore |
报文含 deviceSn |
设备号 | 合并后的报文主体 | 设备标记、链路信息 |
eventStore |
报文含 method |
方法名 | 合并后的报文主体 | 动作回执、状态提示 |
progressStore |
主题含 /progress/ 且有进度字段 |
母设备号 | { progressPercent, text, status } |
进度条 |
rawStore(可选) |
主题命中白名单 | 完整 topic | 原始报文 | 兜底调试、低频通知 |
三个容易被忽略的语义:
eventStore以方法名为 key ,而不是设备号。同一方法被多台设备触发时,后到者覆盖先到者。若业务需要区分设备,key 应改为${method}:${deviceSn}。rawStore是兜底通道,用完整 topic 作 key 天然不会互相覆盖,但数据量会翻倍。只建议对低频主题使用。- 容器粒度决定渲染粒度。把「高频位置」和「低频事件」拆开,才能让地图只在地图数据变化时重绘。
4.5 为什么是「合并」而不是「覆盖」
状态报文是增量快照 :连续两条可能只带变化的字段,第一条带 longitude/latitude,第二条只带 battery。若直接 store[key] = data.body,UI 会因字段瞬间消失而闪成 --。
所以统一写成:
ts
const merge = (store, key: string, patch: object) => {
store.value[key] = { ...store.value[key], ...patch };
};
代价是数据会「陈旧」:设备离线后字段仍保留最后一次的值。因此必须配套超时清理:记录每个 key 的最后更新时间,定时器定期扫描,超过阈值就移除对应实体(阈值可按业务配置,例如 60s 无更新即视为离线)。
5. 特殊主题处理
5.1 指令进度:cmd/progress/{dockSn}
典型报文:
json
{
"method": "task.execute",
"body": { "desc": "航段飞行中 60%", "status": "running", "progressPercent": 60 }
}
处理逻辑的关键是三个守卫:
ts
const writeProgress = (topic: string, data: any) => {
const dockSn = topic.split('/').pop() ?? '';
const percent = data?.body?.progressPercent;
// 守卫 1:没有进度字段就不写 ------ 否则会把正在展示的进度条打回 0
if (!dockSn || percent == null) return;
// 守卫 2:归一化,避免字符串 "60" 或非法值污染 UI
const value = Number(percent) || 0;
// 守卫 3:按母设备号分桶,多设备并发指令互不串扰
progressStore.value[dockSn] = {
progressPercent: value,
text: data.body.desc ?? '',
status: data.body.status ?? '',
};
};
UI 侧配合两点:progressPercent >= 100 后延时自动隐藏;切换设备时只隐藏、不清零(否则切回来会闪一下 0%)。
5.2 任务通知:notice/task/created/+
这类主题按「组织/部门」分层,+ 匹配一层 ID,订阅一次即可覆盖该层级下所有来源。
两个坑必须提前说明:
topic里不会保留+。Broker 投递时给出的是真实主题(如notice/task/created/10086),因此任何topic === 'notice/task/created/+'的相等判断都永远为假,只能用includes、startsWith或按段解析;- 前导
/极易写错 。订阅字面量是否带前导斜杠,取决于服务端的主题定义。写成notice/...却去匹配/notice/...,代码能编译、能跑,就是不生效。
ts
// 用「包含 + 解析」代替相等判断
if (topic.includes('task/created')) {
const taskId = topic.split('/').pop();
rawStore.value[topic] = { ...data, taskId };
}
更推荐的做法:低频强交互事件走独立轻量连接
状态流是高频、可丢帧的;通知是低频、不可丢的。把通知挂在主连接上,一旦某处调用 openChannel 换订阅范围,通知主题就被整体覆盖掉了。独立连接可以做到「随组件挂载而订阅、随卸载而退订」:
ts
const useTaskNotice = (orgId: string, onNotice: (msg: any) => void) => {
const topic = `notice/task/created/${orgId}`;
const c = mqtt.connect(MQTT_URL, { clientId: `${randomId()}-NOTICE` });
c.on('connect', () => c.subscribe(topic));
c.on('message', (_t, buf) => {
try { onNotice(JSON.parse(buf.toString())); } catch { /* 忽略坏帧 */ }
});
onUnmounted(() => { c.unsubscribe(topic); c.end(); }); // 生命周期跟随组件
};
5.3 轨迹:track/realtime 与 track/conflict
这一族的主题风格往往与设备域不同(可能不带租户前缀),且两条主题共用同一批设备号。分派必须靠主题后缀,且先判更长的那个:
ts
const t = String(topic);
if (t.endsWith('track/conflict')) handleConflict(data);
else if (t.endsWith('track/realtime')) handleTrack(data);
为什么强调顺序:'track/realtime'.endsWith('track/conflict') 为假,看似安全;但一旦出现 track/conflict/realtime 这类子主题,先判 realtime 就会误命中。先匹配更具体、更长的后缀是一条通用纪律。
另一个隐患:轨迹与冲突若都以 deviceSn 作 key 落进同一个容器,{...old, ...new} 会让两者互相覆盖字段。稳妥做法是按 ${deviceSn}:${type} 分桶,或干脆分成两个容器。
6. 典型使用场景
以下示例均为示意性伪代码,只体现调用关系与数据流向。
6.1 全局挂载:连接并订阅设备列表
ts
import { watch, onUnmounted } from 'vue';
import { openChannel, closeChannel, buildTopic } from '@/realtime/channel';
watch(
() => deviceList.value, // 列表来自接口,登录后才就绪
(list) => {
if (!list?.length) return;
openChannel(list.map((d) => buildTopic({ tenantId, deviceSn: d.dockSn })));
},
);
onUnmounted(closeChannel); // 布局卸载时必须断开
6.2 组件内监听单个数据桶并刷新 UI
vue
<script setup lang="ts">
import { ref, watch } from 'vue';
import { progressStore } from '@/realtime/channel';
const visible = ref(false);
const percent = ref(0);
// 只监听当前设备的 key:其他设备的进度不会触发本组件重算
watch(
() => progressStore.value[currentDockSn.value],
(p) => {
if (!p) return;
visible.value = true;
percent.value = p.progressPercent;
if (percent.value >= 100) setTimeout(() => (visible.value = false), 3000);
},
{ deep: true },
);
</script>
<template>
<div v-if="visible" class="progress">
<progress-bar :value="percent" />
</div>
</template>
6.3 组合订阅:母设备 + 子设备 + 事件 + 进度
ts
const subscribeCurrent = () => {
const topics = [buildTopic({ tenantId, deviceSn: dockSn.value })];
if (droneSn.value) {
// 子设备可能属于不同租户,建议用其自带的 tenantId
topics.push(buildTopic({ tenantId: droneTenantId.value, deviceSn: droneSn.value }));
}
topics.push(buildTenantTopic({ tenantId })); // 租户级事件
topics.push(`cmd/progress/${dockSn.value}`); // 指令进度
topics.push('notice/task/created/+'); // 任务通知
openChannel(topics); // 注意:会先断开旧连接,最后调用的订阅范围生效
};
6.4 合并两个容器读取设备全貌
ts
import { computed } from 'vue';
import { dockStore, osdStore } from '@/realtime/channel';
const deviceView = computed(() => {
const key = currentDockSn.value;
// 先铺母设备自身状态,再用子设备状态覆盖同名字段
const merged = { ...dockStore.value[key], ...osdStore.value[key] };
return {
online: merged.online,
temperature: merged.temperature,
networkRate: merged.networkRate,
batteryPercent: merged.batteryPercent,
};
});
6.5 运行期增量订阅(不断连)
需要临时关注额外设备时,不要 再调 openChannel(会断连并丢掉已有订阅),直接操作实例:
ts
import { client } from '@/realtime/channel';
const watchExtra = (extraDockSn: string) => {
if (!client.value) return;
const extra = [buildTopic({ tenantId, deviceSn: extraDockSn }), `cmd/progress/${extraDockSn}`];
client.value.subscribe(extra, (err) => {
if (err) console.error('追加订阅失败', err);
});
};
记得在不再需要时 unsubscribe,否则这些主题的报文会一直涌进状态容器。
7. 注意事项与最佳实践
7.1 地址取到 undefined:先查三件事
现象 :控制台只有一行 开始连接: { url: undefined },随后 error 事件报无效地址,所有容器恒空。
| 可能原因 | 排查方式 | 处理 |
|---|---|---|
| 构建模式不在地址映射表里 | 打印构建模式的实际取值 | 补全映射,或直接读单一地址变量 |
| 变量缺少构建工具要求的前缀 | 检查环境文件 | 补前缀 |
| 改了环境文件没重启 | 看产物里是否仍是旧值 | 重启 dev / 重新 build |
同时保留一行启动日志(打印 url + clientId + 当前模式),这是判断「变量到底有没有生效」最快的手段,不要嫌日志多就删掉。更稳妥的写法是启动时断言:
ts
if (!MQTT_URL) console.error('[realtime] 地址未注入,请检查环境变量与构建模式');
7.2 重复连接导致订阅被覆盖
这是这类架构最高频的线上问题 :openChannel 每次都先断开再连接,所以最后一次调用的主题列表就是全部订阅,先前订阅被整体丢弃。
典型链路:布局层订阅了设备状态 → 详情页订阅了状态 + 进度 + 通知 → 页面里的地图组件初始化时又只订阅了「设备状态」→ 结果进度条和通知全部收不到。
症状特征:某块数据「进页面时有、几秒后没了」「切一次页面又好了」,且最后一次订阅日志里没有你期望的主题。
规避手段(按侵入性排序):
- 约定单一订阅入口 :只在一个地方算全集,页面内不再调
openChannel; - 变更订阅一律用增量接口 (
client.subscribe),openChannel只用于「换租户 / 换设备集合」的整体切换; - 把
openChannel升级为订阅集合管理器:
ts
const active = new Set<string>();
export const ensureSubscribed = (topics: string[]) => {
if (!client.value) return openChannel(topics); // 首次连接
const add = topics.filter((t) => !active.has(t));
if (!add.length) return;
client.value.subscribe(add, (err) => {
if (!err) add.forEach((t) => active.add(t));
});
};
7.3 监听器与 watch 泄漏
| 泄漏点 | 成因 | 正确做法 |
|---|---|---|
| MQTT 事件 | end() 后旧实例仍派发 close/error |
removeAllListeners → end(true) → 置空 |
Vue watch |
在「激活/重新初始化」方法里创建 watch,每次调用都新增一个,旧的不销毁 | 用变量接住 stop 句柄,重复调用前先 stop() |
| 定时器 | 清理定时器、进度隐藏定时器未在卸载时 clear |
卸载钩子里统一清理 |
尤其注意第二种:写在 setup 顶层的 watch 会随组件卸载自动停止,但写在普通方法里的不会 。KeepAlive 场景下 onActivated 反复触发,watch 就会成倍增长,最终表现为「同一条报文触发 N 次渲染」。
ts
let stop: (() => void) | null = null;
const init = () => {
stop?.(); // 先停上一轮
stop = watch(osdStore, onOsd, { deep: true });
};
const dispose = () => { stop?.(); stop = null; closeChannel(); };
7.4 进度被误重置为 0
成因 :无条件写入 progressPercent: data?.percent ?? 0,或把「无进度字段的过程消息」也当成进度更新。
规避 :写入前必须有 percent == null → return 的守卫;业务侧不要在没有数据时主动清零,改用 visible = false 隐藏整个进度条;切换设备时只隐藏不清零。
7.5 全局容器被整体清空的副作用
切换设备时,常见写法是:
ts
osdStore.value = {}; // 清空
deviceStore.value = {};
这能防止上一台设备的数据串到新设备,但容器是模块级共享 的,同页其他组件在重新收到报文前会短暂看到空对象,出现「闪一下 --」。
建议按 key 删除而不是整体替换:
ts
const dropDevice = (sn: string) => {
delete osdStore.value[sn];
delete dockStore.value[sn];
// 其他容器同理
};
另外要审视「哪些容器该清」:如果只清了状态容器却漏了详情容器,切设备后详情面板会继续显示上一台设备的旧值。
7.6 深 watch 大对象的性能
watch(store, cb, { deep: true }) 每来一条报文都要深度遍历整棵对象树,回调里又常常再遍历所有设备。几十台设备尚可,上百台就会明显掉帧。
优化方向:
- selector watch:只监听当前设备的子树(见 6.2);
- 节流合并 :用
Map暂存最新报文,requestAnimationFrame或 100ms 定时批量刷新; - 按需订阅:只订可见设备,而不是租户全量。
7.7 clientId 与多标签页
固定 clientId 会让后连接者踢掉先连接者。用随机串保证每标签页独立,并加可读后缀便于运维排查。不要为了「方便定位问题」把 clientId 写死。
7.8 凭证一定会被打进产物
前端能读到的连接账号必然以明文出现在 JS 产物里,任何人打开开发者工具都能看到。可行的加固方向:
- Broker 侧按租户做 ACL,只允许订阅本租户前缀,禁止发布;
- 用短期票据代替长期密码(用户名里带 token,Broker 走鉴权回调校验);
- 不要把「控制类」主题的写权限下放给 Web 端(示例架构已通过「只订阅」彻底规避)。
7.9 JSON.parse 必须容错
坏帧(设备异常、Broker 扩展帧)会直接抛异常,污染控制台并中断当前帧的处理。统一包一层:
ts
let data: any;
try { data = JSON.parse(payload.toString()); }
catch { console.warn('[realtime] 非 JSON 报文,已忽略:', topic); return; }
7.10 控制指令不要走前端 MQTT
前端 publish 控制指令无法被服务端校验权限,也难做幂等与审计。控制走 HTTP(带鉴权与幂等键),MQTT 只承担「看」。这条边界一旦打破,7.8 的所有加固都会失效。
8. 小结
这套方案的本质是:用一条 WebSocket 长连接,把设备侧持续涌入的增量状态,收敛成若干个按业务语义划分的响应式容器。
关键决策回顾:
| 决策 | 原因 |
|---|---|
实例用 shallowRef、数据用 ref |
实例不能被深度代理;数据需要字段级响应 |
| 每次连接先断开旧连接 | 避免僵尸连接、事件重复触发、多份数据混杂 |
subscribe 写在 connect 回调内 |
免费获得「重连后自动恢复订阅」 |
| 母设备状态独立容器 | 母设备报文也可能带归属字段,与子设备共用 key 会互相吞数据 |
子设备状态以 hubSn 为 key |
详情页永远按「当前母设备」取数,取数逻辑最简 |
统一用 {...old, ...new} 合并 |
状态报文是增量快照,覆盖会让 UI 闪烁 |
| 进度只在有进度字段时写入 | 否则过程消息会把进度条打回 0 |
| 低频通知走独立连接 | 强交互事件不能被主连接的订阅覆盖影响 |
| 控制指令走 HTTP | 前端无法做鉴权、幂等与审计 |
接入顺序建议:
- 先跑通「一条连接 + 一个容器 + 一个
watch」,确认地址、clientId、主题字面量三者都对; - 再按语义拆分容器,同时把
watch从「深监听整棵」改成「监听单个 key」; - 最后补生命周期治理:订阅入口唯一化、
stop句柄、按 key 清理、坏帧容错。
做到第 3 步,这条连接才能从「能跑」走到「敢长期放着跑」。