这两年做工业可视化项目,一个很高频的坑是:前端一开始看起来只是画个组态页面,结果做着做着,浏览器里塞进了半个采集系统。
页面组件直接订 OPC UA 节点,图表自己管 WebSocket 重连,告警面板偷偷写一套点位转换逻辑,最后整个系统变成一锅“能跑但不敢动”的工业大杂烩。
如果你也经历过下面这些场景,那这篇大概率对你有用:
- 前端代码里到处是
ns=2;s=Channel1.Device1.TagA - 新接一个 Modbus 设备,前端要跟着改字段映射
- 同一份点位在大屏、趋势图、告警列表里被重复处理三次
- 协议层一波抖动,页面动画、图表和告警一起抽风
我的结论很直接:SCADA Web 化真正难的,不是把页面搬进浏览器,而是把“渲染层”和“采集层”切干净。
这篇就聊聊我现在更推荐的一种做法:让前端只认统一语义数据,不直接感知底层工业协议。
1. 先说结论:前端不要直接面向协议开发
传统工业项目里,OPC UA、Modbus TCP、BACnet、私有 TCP 协议各玩各的,桌面 SCADA 因为跟驱动、运行环境绑得更近,很多耦合还能忍。
但一旦到了 Web 架构,问题会被迅速放大:
- 浏览器天然不适合直接对接复杂工业协议
- 前端发布频繁、组件繁多,不应该承载协议适配职责
- 一个页面往往同时服务大屏、PC、平板甚至移动端,协议细节越早暴露,越难复用
所以比较稳的思路是分四层:
推荐的职责边界
- 设备层:PLC、传感器、仪表、网关
- 采集与网关层:负责协议驱动、点位采集、清洗、补时戳、质量码、边缘缓存
- 消息与数据层:负责统一事件模型、消息分发、时序存储、权限/API
- Web 前端层:负责渲染、交互、视图状态、页面编排
这里最关键的一刀是:
前端消费的是“TagUpdate / AlarmEvent / TrendPoint”这类统一业务对象,而不是 OPC UA NodeId、Modbus 寄存器地址或某个驱动 SDK 返回结构。
这件事听起来像废话,但真正落地时,很多项目都死在“先快点跑起来”这一步。
2. 为什么前端一旦碰协议细节,后面几乎必炸
2.1 设备接入变化会不断传染到 UI
假设一开始接的是 OPC UA,前端订阅的是:
subscribe('ns=3;s=Line1.Pump1.Speed')
后面现场改造,把数据接入改成 MQTT 网关转发,你原来的页面逻辑就会开始变形:
- 订阅地址要改
- 数据结构要改
- 质量码来源要改
- 重连逻辑要改
- 历史查询接口也可能要改
这就说明:你的前端没有服务于“业务对象”,而是服务于“接入方式”。
在工业项目里,接入方式恰恰是最容易变化的一层。
2.2 一个项目里,实时流和历史流通常不是一回事
实时画面看的是秒级甚至 100ms 级更新;趋势分析查的可能是时序库聚合结果;告警面板又来自规则引擎。
如果前端自己把这些数据源揉在一起,就会出现:
- 图表组件绑定实时流时很好用,但切换历史模式要重写一半
- 组态组件依赖实时值结构,结果无法复用在回放界面
- 告警面板和实时状态展示的“设备在线/离线”定义不一致
这时真正缺的不是更多组件,而是统一的数据语义层。
2.3 前端会被迫替后端补业务规则
工业数据不是“有值就行”,通常还带这些信息:
- 时间戳
ts - 数据质量
quality - 来源
source - 单位
unit - 工程量转换规则
- 告警阈值和滞回
如果这些规则散在前端组件里,最后一定会形成一个局面:
后端说自己只是转发,前端说自己只是展示,但业务规则已经神不知鬼不觉地住进了十几个页面。
3. 更稳的做法:建立统一 Tag 总线
我现在做这类项目,基本都会在“采集层”和“前端层”之间加一层统一 Tag 总线。它未必真的是某个独立产品,但在架构上必须存在。
它干的事情很朴素:
- 把 OPC UA、Modbus、MQTT、HTTP 等不同来源统一抽象成同一种更新事件
- 给每条数据补统一字段:
tagId / value / quality / ts / source - 屏蔽底层协议差异
- 提供统一的订阅、查询、回放、告警联动能力
一个最小可用的数据对象,大概长这样:
type TagUpdate = {
tagId: string;
value: number | string | boolean;
quality: 'good' | 'bad' | 'stale';
ts: number;
source: 'opcua' | 'mqtt' | 'simulator';
};
前端状态层只接这个,不接别的。
对应的代码组织也会清爽很多:
再给一个更贴近业务代码的例子:
class TagBus {
private listeners = new Map<string, Set<(u: TagUpdate) => void>>();
subscribe(tagId: string, handler: (u: TagUpdate) => void) {
if (!this.listeners.has(tagId)) {
this.listeners.set(tagId, new Set());
}
this.listeners.get(tagId)!.add(handler);
return () => this.listeners.get(tagId)?.delete(handler);
}
publish(update: TagUpdate) {
this.listeners.get(update.tagId)?.forEach(fn => fn(update));
}
}
这样页面组件只需要:
useTagValue('line1.pump1.speed')
而不是:
useOpcUaNode('ns=3;s=Line1.Pump1.Speed')
别小看这一层抽象。它决定了你的前端是在做“业务界面”,还是在做“浏览器里的轻量驱动中心”。
4. OPC UA、MQTT、Sparkplug B 应该怎么放位置
这次检索资料时,我专门看了几类公开信息:
- Web SCADA / HMI 开源项目 FUXA 的能力介绍,能看到它支持 Modbus、OPC UA、MQTT 等协议,并以 Web 方式完成工程化设计
- 多篇 OPC UA 与 MQTT Sparkplug B 的对比资料,核心共识基本一致:OPC UA 强在工业互操作语义和标准化,MQTT/Sparkplug 强在轻量发布订阅和跨网络分发
把它们放到架构里理解,会很清楚:
OPC UA 更适合靠近采集层
它的优势是:
- 对工业对象建模更完整
- 数据类型、层级、语义更规范
- 在设备接入和工业系统互联场景里更自然
但如果你让前端直接吃 OPC UA 结构,问题也很明显:
- NodeId 天然不友好
- 浏览器侧安全和连接控制复杂
- 对页面开发者不够“语义化”
所以 OPC UA 更适合作为采集层或边缘网关的输入协议。
MQTT / Sparkplug B 更适合作为分发层
它的优势是:
- 发布/订阅模型天然适合多终端消费
- 跨网络、跨服务广播实时状态更轻便
- 更适合做 WebSocket 之前的一层消息总线
但也别神化它。MQTT 本身不是银弹,如果没有统一 Topic 约定、设备生命周期管理和语义规范,最后也会演变成“字符串地狱”。
所以我的偏好是:
- 设备接入:优先 OPC UA / Modbus / 原生驱动
- 统一分发:消息总线用 MQTT 或内部事件流
- 浏览器消费:通常再包装成 WebSocket / SSE / API
也就是说,前端大多数时候更应该面对:
wss://.../realtime/api/trends/query/api/alarms
而不是直接面对 PLC 协议。
5. 前端真正该关心的,是状态组织而不是协议接入
一个靠谱的 SCADA Web 前端,我认为核心工作有四块:
5.1 统一状态仓库
实时值、历史值、告警态、设备在线态,都要落到统一状态模型里,而不是每个组件自己拉一份。
最少也得做到:
- 实时更新单点分发
- 同一 tag 多组件共享
- 脏数据和过期数据可识别
- 断线重连后支持增量恢复
5.2 视图层和数据层解耦
组态编辑器、趋势图、告警列表、设备卡片,应该通过统一 selector 取数。
比如:
const speed = selectTagValue(state, 'line1.pump1.speed');
const speedQuality = selectTagQuality(state, 'line1.pump1.speed');
而不是组件内部自己解析消息包。
5.3 让“质量码”成为一等公民
很多项目只展示 value,不展示 quality,现场一抖动页面还在一本正经地显示“正常运行”,这就很离谱。
工业前端一定要把下面这些能力内建进去:
good / bad / stale可视化- 最后更新时间显示
- 失联态样式降级
- 关键控制项禁止在坏质量下操作
5.4 让回放和实时走同一套渲染协议
这是我很推荐但经常被忽视的一点。
如果实时数据和历史回放使用同一种“组件输入协议”,你就会得到两个巨大好处:
- 趋势分析、事件回放、事故复盘能复用同一套视图
- 测试和演示不必总连真实设备
比如不管是真实流还是回放流,最后都向前端发 TagUpdate[],那画面层根本不需要知道自己是在“在线监控”还是“历史回放”。
6. 一个我更推荐的 SCADA Web 化落地模板
如果你现在正要搭一套 Web SCADA / Web 组态系统,我会建议按下面这个骨架来:
后端 / 边缘侧
- 协议适配器:OPC UA、Modbus TCP、MQTT、HTTP
- 点位中心:维护
tagId、单位、阈值、映射关系 - 实时分发:MQTT / 内部事件总线 / WebSocket Gateway
- 历史存储:时序库
- 告警引擎:规则、去抖、确认、恢复
- 权限系统:页面、设备、点位、操作权限分层
前端侧
- Runtime SDK:只暴露
subscribeTag / queryTrend / queryAlarm - 状态管理:统一 tag store / alarm store / session store
- 渲染引擎:组态画布、图表、列表、弹窗、联动
- 编辑器:只配置业务 tag,不配置协议细节
一个常见接口设计大概是:
interface ScadaRuntime {
subscribeTags(tagIds: string[], cb: (updates: TagUpdate[]) => void): () => void;
queryTrend(tagId: string, range: [number, number]): Promise<TrendPoint[]>;
queryAlarms(condition: AlarmQuery): Promise<AlarmRecord[]>;
}
注意这里暴露的是业务能力接口,不是协议能力接口。
7. 那低代码/组态平台为什么经常能做得更稳
这也是我最近越来越明确的一个判断:
组态平台的价值,不只是“拖拽更快”,而是它天然逼着你做中间层抽象。
因为一旦要支撑:
- 多页面复用
- 多设备接入
- 模板化组件
- 大屏、PC、移动端共用
- 实时 + 历史 + 回放共存
你就不可能让每个页面都直接写协议代码。
这也是为什么很多成熟可视化/组态产品,最后都会把“点位绑定”“事件模型”“运行时 SDK”“渲染引擎”拆得比较清楚。真正难的不是画布,而是抽象边界。
8. 最后一句人话总结
SCADA Web 化不是把传统监控画面塞进浏览器,而是重新定义一遍系统边界。
如果前端直接绑定 OPC UA NodeId、直接理解 Modbus 地址、直接处理设备驱动异常,那它看起来像是更灵活,实际上是在提前透支整个系统的可维护性。
更稳的方式是:
- 让采集层负责协议和设备差异
- 让消息层负责统一事件分发
- 让前端只关心业务语义和视图状态
一句话概括就是:
浏览器应该渲染工业系统,而不是兼职扮演工业驱动。
如果你正在做 Web 组态、SCADA Web 化或者工业物联网前端,这一刀越早切,后面越轻松。