Vue3 大屏项目 MQTT 实战:机场与无人机 OSD 实时数据总线

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 整体结构

flowchart LR subgraph EDGE[设备侧] DOCK[母设备 Dock] NODE[子设备 Drone] end DOCK -- publish --> BROKER[(MQTT Broker<br/>ws://...)] NODE -- publish --> BROKER BROKER -- 单条 WebSocket --> CLIENT[client<br/>shallowRef 单例] CLIENT --> DISPATCH{消息分发器<br/>topic + 报文特征} DISPATCH --> S1[osdStore<br/>子设备状态] DISPATCH --> S2[dockStore<br/>母设备状态] DISPATCH --> S3[deviceStore<br/>设备直推数据] DISPATCH --> S4[eventStore<br/>动作与事件] DISPATCH --> S5[progressStore<br/>指令进度] S1 --> V1[地图机位 / 轨迹] S2 --> V2[设备详情面板] S3 --> V3[设备标记] S5 --> V4[进度条] S4 --> V5[通知与弹窗]

为什么要有这么多容器,而不是一个「大对象」?因为不同数据的消费方式完全不同:位置数据需要高频重绘地图,事件数据需要弹窗与播报,进度数据需要节流渲染进度条。分开存放后,每个组件可以只订阅自己关心的那一块,避免「一条报文触发全站组件重算」。

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;

三个必须知道的约束:

  1. 构建期注入 :这类变量在 dev 启动或 build 打包时被静态替换进产物,改完环境文件必须重启或重新构建,浏览器里改 window 上的值没用。
  2. 前缀有讲究 :以 Vite 为例,只有约定前缀(如 VITE_)的变量才会暴露给客户端,其余一律是 undefined。示例里为了简洁省略了前缀,实际接入时请按构建工具约定补上。
  3. 模式要与配置对齐 :如果地址是通过「构建模式 → 地址」的映射表取出来的,那么每新增一种构建模式(如 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 建立与关闭的执行序列

sequenceDiagram participant P as 调用方 participant M as 数据总线模块 participant OLD as 旧连接 participant B as Broker P->>M: openChannel(topics) M->>OLD: removeAllListeners() M->>OLD: end(true) M->>M: client = null M->>B: connect(url, options) B-->>M: 'connect' M->>B: subscribe(topics) B-->>M: 'message'(持续推送) Note over M: 解析 topic → 写入对应容器 B-->>M: 'error' / 'reconnect' / 'offline' / 'close'

对应的伪代码:

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;      // 最后置空
};

为什么必须「先关闭旧连接」

  1. 单例只保存一个实例。直接覆盖赋值,旧实例的 WebSocket 仍然连着,浏览器会同时持有 2 条、3 条连接,达到并发上限后新连接直接失败;
  2. 旧实例上的 message 监听器依然存活,会把旧订阅范围的报文继续写进同一份全局状态,出现「切了设备还能收到上一台设备的数据」;
  3. 旧实例既没有 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 回调,用「主题特征 + 报文特征」两把尺子分发:

flowchart TD A[&#34;on('message', topic, payload)&#34;] --> B[&#34;JSON.parse(失败则丢弃)&#34;] B --> C{&#34;命中轨迹类主题?&#34;} C -- 是 --> D[&#34;osdStore[deviceSn]<br/>合并 + 回填 topic&#34;] C -- 否 --> E[&#34;求 topicSn、判断是否状态报文&#34;] E --> F{&#34;是否母设备自身状态&#34;} F -- 是 --> G[&#34;dockStore[topicSn]&#34;] E --> H{&#34;hubSn 存在且 ≠ topicSn&#34;} H -- 是 --> I[&#34;osdStore[hubSn]&#34;] E --> J{&#34;报文带 deviceSn?&#34;} J -- 是 --> K[&#34;deviceStore[deviceSn]&#34;] E --> L{&#34;报文带 method?&#34;} L -- 是 --> M[&#34;eventStore[method]&#34;] E --> N{&#34;主题含 /progress/?&#34;} N -- 是 --> O{&#34;含 progressPercent?&#34;} O -- 是 --> P[&#34;progressStore[dockSn]&#34;] O -- 否 --> Q[&#34;跳过,不写入&#34;]

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 原始报文 兜底调试、低频通知

三个容易被忽略的语义:

  1. eventStore 以方法名为 key ,而不是设备号。同一方法被多台设备触发时,后到者覆盖先到者。若业务需要区分设备,key 应改为 ${method}:${deviceSn}。
  2. rawStore 是兜底通道,用完整 topic 作 key 天然不会互相覆盖,但数据量会翻倍。只建议对低频主题使用。
  3. 容器粒度决定渲染粒度。把「高频位置」和「低频事件」拆开,才能让地图只在地图数据变化时重绘。

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,订阅一次即可覆盖该层级下所有来源。

两个坑必须提前说明:

  1. topic 里不会保留 + 。Broker 投递时给出的是真实主题(如 notice/task/created/10086),因此任何 topic === 'notice/task/created/+' 的相等判断都永远为假,只能用 includes、startsWith 或按段解析;
  2. 前导 / 极易写错 。订阅字面量是否带前导斜杠,取决于服务端的主题定义。写成 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 每次都先断开再连接,所以最后一次调用的主题列表就是全部订阅,先前订阅被整体丢弃。

典型链路:布局层订阅了设备状态 → 详情页订阅了状态 + 进度 + 通知 → 页面里的地图组件初始化时又只订阅了「设备状态」→ 结果进度条和通知全部收不到。

症状特征:某块数据「进页面时有、几秒后没了」「切一次页面又好了」,且最后一次订阅日志里没有你期望的主题。

规避手段(按侵入性排序):

  1. 约定单一订阅入口 :只在一个地方算全集,页面内不再调 openChannel;
  2. 变更订阅一律用增量接口 (client.subscribe),openChannel 只用于「换租户 / 换设备集合」的整体切换;
  3. 把 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 }) 每来一条报文都要深度遍历整棵对象树,回调里又常常再遍历所有设备。几十台设备尚可,上百台就会明显掉帧。

优化方向:

  1. selector watch:只监听当前设备的子树(见 6.2);
  2. 节流合并 :用 Map 暂存最新报文,requestAnimationFrame 或 100ms 定时批量刷新;
  3. 按需订阅:只订可见设备,而不是租户全量。

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 前端无法做鉴权、幂等与审计

接入顺序建议:

  1. 先跑通「一条连接 + 一个容器 + 一个 watch」,确认地址、clientId、主题字面量三者都对;
  2. 再按语义拆分容器,同时把 watch 从「深监听整棵」改成「监听单个 key」;
  3. 最后补生命周期治理:订阅入口唯一化、stop 句柄、按 key 清理、坏帧容错。

做到第 3 步,这条连接才能从「能跑」走到「敢长期放着跑」。

相关推荐
2401_885885041 小时前
芬兰语音通知接口怎样对接?语音提醒接入流程
android·前端·python·php·语音识别
铁皮饭盒1 小时前
这6个小模型各有绝活, 且都可cpu/浏览器端运行
前端·后端
徐小夕1 小时前
JitKnow AI知识库 4.7 发布|多文档推理+拖拽 Agent + 技能库,打造更懂业务的 AI 知识中台
前端·vue.js·github
小poop2 小时前
从“雷峰塔”到“对象”:JavaScript基础语法入门指南
前端·javascript
ReStock活库2 小时前
Zephyr异步SPI调用返回后,为什么不能立即复用发送缓冲区?
java·前端·网络
计算机魔术师2 小时前
半年营收163亿,算力债却到7800亿,这账谁算得清
前端
chunyublog2 小时前
HDFS 上传文件名空格问题排障教程
前端·javascript·npm
?abc!2 小时前
(贪心)621. 任务调度器
java·前端·c#
摸鱼仙人~3 小时前
校招帮简历字段匹配架构分析报告
java·前端·架构