WebSocket 实时推送 + HTTP 定时轮询:一块大屏两套取数怎么不打架
一块典型的工业监控大屏,数据来源天生就是两种节奏的:
设备遥测、实时告警,要求秒级甚至亚秒级到达,错过一帧就是错过一次故障------这必须是服务端主动推的;能耗汇总、班次统计、日累计值,一分钟更新一次都嫌快,用推送纯属浪费连接------这适合客户端定时拉。
于是问题来了:两条链路谁先谁后、断线了谁兜底、组件销毁时谁负责清理?处理不好,轻则数据闪烁,重则「断线一次、整屏数据全停」。

▲ 左边是把所有数据都压进一条长连接,右边是按节奏分工、出口统一
这篇讲我们怎么在这道题上落地。结构、字段名和配置片段来自对自家大屏运行器 datavis-render-vue3 的勘察记录(对应的两个核心文件是 src/packages/datavisRender/hooks/useApiRequest.ts 与 useApiWebsocket.ts),只讲机制,不涉及业务数据。
一、两套取数,在图纸里就是两个并列的配置项
先看它们在数据里长什么样。一份大屏图纸(designData)的 config 段里,interfaceApi 下面并列挂着 api 和 websocket 两个配置块(字段名照勘察文档的核心数据结构表):
json
{
"config": {
"width": 1920, "height": 1080,
"renderMode": "fit",
"defaultScreenId": "screen_001",
"interfaceApi": {
"api": {
"enable": true, "url": "...", "method": "GET",
"headerFormat": "...", "paramsFormat": "...",
"resFormat": "...", "timeInterval": 10
},
"websocket": {
"enable": false, "url": "...",
"paramsFormat": "...", "resFormat": "...", "sendScript": "..."
}
}
},
"backend": [{ "objects": [...] }],
"screen": [
{ "uid": "screen_001", "objects": [...] },
{ "uid": "screen_002", "objects": [...] }
],
"frontend": [{ "objects": [...] }]
}
这个结构传递了三个设计决策:
第一,两者对等、互不隶属。 api 和 websocket 是兄弟节点,各自有独立的 enable 开关。不存在「WebSocket 为主、HTTP 兜底」的主从关系------两套链路可以同时开,也可以只开一个,图纸层面不预设谁是主角。
第二,差异收敛在配置字段上。 HTTP 侧多了 method、headerFormat 和 timeInterval(轮询间隔);WebSocket 侧多了 sendScript(连接建立后要发的订阅消息)。除此之外,两边共用同一对脚本字段 paramsFormat / resFormat------请求怎么组装、响应怎么过滤,两套链路的语义是一致的。
第三,配置跟着图纸走。 这份 JSON 会随图纸一起序列化保存,「这块屏用不用推送、轮询几秒一次」是大屏的一部分,而不是运行环境的私事。
二、HTTP 轮询:定时器的单例与清理
HTTP 这条链路由 useApiRequest 承担。子屏挂载时调用 sendRequest,完整走一遍是六步:
text
① 组装 headerFormat / paramsFormat 脚本生成请求头与参数
② 发起 visApi.axios 发出请求
③ 过滤 响应交给 resFormat 脚本加工
④ 通知 editor.fire('apiRequest', result)
⑤ 派发 运行器监听到事件,exposeSetData(result) 分发数据
⑥ 循环 timeInterval > 0 时 setInterval 定时重跑 ①-④

▲ 定时器不是随手 new 的,它挂在固定位置、有明确的清理时机
这六步里真正值得展开的是两处。
脚本三件套是字符串,不是函数。 headerFormat / paramsFormat / resFormat 在配置里都是脚本字符串,运行时经 visApi.funRunScript 转成函数执行。这么做换来的表达能力是:脚本上下文里能拿到 editor 实例,还有注入的 lodash、dayjs、axios------「把当前屏的 uid 拼进查询参数」「把毫秒时间戳格式化成班次」这类逻辑,用户在配置界面里写几行脚本就能完成,不用改运行器代码。执行引擎还有个容错版本 tryScript,脚本出错不至于把整条取数链路带崩。
定时器挂在 editor.__apiRequestIntervalTimer 上保证单例。 这是个不起眼但很关键的细节:子屏会切换、组件会重复挂载,如果每次挂载都裸起一个 setInterval,切换十次屏就有十个定时器在同时请求,接口压力翻倍、数据还会互相覆盖。挂到 editor 的固定属性上,新一次请求前先清掉旧定时器,全局永远只有一个在跑。对应的清理时机也明确:组件销毁时 onBeforeUnmount 里 clearInterval。
还有一个容易被忽略的语义:接口数据是全局的 。响应结果不属于任何单个组件,所有组件共享这一份数据,每个组件通过自己身上的 signal 字段(如 "device.temperature")从全局数据里按路径取自己需要的那一片。这个设计后面还会讲到它的好处。
三、WebSocket 推送:持久连接的生命周期
WebSocket 这条链路由 useApiWebsocket 承担,sendWebsocket 负责建立连接,五步:
text
① 建连 创建 WebSocket 实例,挂到 editor.__websocketInstance 保证单例
② 组参数 paramsFormat 脚本生成连接参数,拼接到 URL(支持对象/字符串两种格式)
③ 订阅 onopen 时执行 sendScript 脚本(如发送订阅消息)
④ 收数 onmessage 时用 resFormat 脚本过滤,editor.fire('websocketRequest', result)
⑤ 清理 组件销毁时关闭 WebSocket 连接
和 HTTP 链路对着看,结构几乎是镜像的:同样的脚本三件套语义、同样的「过滤后 fire 事件」出口、同样的销毁清理。差异只有两点------连接是持久的,以及推送是服务端主导的 :HTTP 链路的节奏由客户端 timeInterval 决定,WebSocket 链路里客户端只在 onopen 时喊一嗓子(sendScript 发订阅),之后什么时候来数据、来多少,全由服务端说了算。
这个差异决定了它们适合的数据形态不同:轮询适合「我要的时候去拿」的汇总类数据,推送适合「你不知道它什么时候来」的事件类数据------设备告警、状态跳变就是典型。
四、不打架的关键:两个出口,汇成一个入口
前面一直在说 editor.fire(...)。两条链路各发各的事件,为什么组件不会乱?
看运行器的数据流转全貌(图来自勘察文档的模块关系章节):
text
┌────────┐ ┌──────────┐ ┌────────┐
│useApi │ │useApi │ │useApi │
│Request │ │Websocket │ │Script │
│(HTTP) │ │(WS) │ │(脚本) │
└───┬────┘ └────┬─────┘ └────┬───┘
│ fire('apiRequest'/'websocketRequest')
└───────────┬────────────┘
▼ exposeSetData(result)
┌───────────────────────────┐
│ 按 signal 路径派发数据 │
│ node.emitter.emit('setData', itemData) │
└───────────┬───────────────┘
▼
组件监听 'setData' 事件刷新

▲ 源头可以有很多条,出口只有一个,组件只认
setData
分工在源头,统一在出口 ------这就是两套取数不打架的答案。运行器主组件 index.vue 里的 handleEventlisteners 统一监听 apiRequest、websocketRequest 这些事件,无论数据是拉来的还是推来的,最后都走同一个 exposeSetData(result),按 signal 路径找到目标组件,经 node.emitter.emit('setData', itemData) 送过去。
对组件来说,世界被简化成了一件事:只认 setData 事件,不关心、也不需要关心数据是从哪条链路来的。同一个图表组件,上一秒吃轮询数据,下一秒吃推送数据,渲染逻辑一行不改。
顺带一提,其实还有第三条腿:useApiScript(脚本数据源),不走网络、本地脚本直接产数,常用于联调阶段的模拟数据。三条 hooks 全部遵循同一个「fire 事件 → 统一派发」的契约。新增一种数据源类型,只要它遵守这个契约,整套组件库一行不用改。
五、图纸里那些字段,在编辑器里长什么样
第一节贴了 config.interfaceApi 的 JSON,第二节说脚本三件套是字符串------这些字段不是凭空写的,它们是用户在编辑器里填出来的。两张真实编辑器截图对着看,字段与界面的对应关系一目了然。

▲ 新增数据变量:类型选「API 数据」,URL 与请求方法填进图纸的
interfaceApi.api,「定时执行」就是timeInterval,「数据处理」里写的脚本参数带着 editor
第一张图是数据源配置弹窗:类型选「API 数据」,URL 地址、请求方法(GET)对应图纸里的 url / method,「定时执行」一栏填的数字就是 timeInterval(0 表示不轮询)。下方「数据处理」tab 里是那段 executeScript 脚本,注释写得很直白:data 是接口返回的原始数据,editor 是编辑器核心对象 ------第二节说脚本上下文里能拿到 editor 实例,出处就在这里。

▲ 字段映射:脚本产出的 product / power / reduction 分别接到图表的 x1 / y1 / y2,三组数据驱动同一张柱状图
第二张图是选中一张柱状图后的数据面板:左侧「数据处理」编辑函数产出多组序列,映射表把它接到图表的取值 key 上------x1 对应 product(时间)、y1 对应 power、y2 对应 reduction,三组数据同时驱动同一张图;右侧的 JSON 树就是接口返回的原始结构,monthChart.list 里每一项的 createTime / power / reduction 与映射一一对应。组件身上的 signal 取值路径,在界面上对应「变量」这一栏。
调试这件事也在这层:运行器里有一个 debuggerToolDialog(数据调试器),API / Script / Websocket 三种数据源各有一个调试入口------接口通不通、脚本算出来是什么、推送帧长什么样,编辑阶段就能直接看到,不用等到大屏上线再猜。
六、四条踩坑经验(经验总结)
以下是我们真实项目里撞出来的,属于经验总结,不是勘察文档的结论:
第一,别把所有数据都塞进推送。 看起来「一条 WebSocket 全解决」很优雅,代价是这条连接成了单点:断线一次,连那些一分钟更新一次完全够用的汇总数据也一起停。按数据节奏分工,故障半径小一半。
第二,重连策略要在宿主层做。 运行器的契约是「销毁时关闭连接」,连接断开后的恢复(指数退避、重连后重新发 sendScript 订阅)不由运行器代劳。集成方要自己想清楚:大屏是 7×24 挂着的,网络抖动不是「会不会」而是「每天几次」。
第三,轮询驱动不了动画。 timeInterval 是数据刷新节奏,不是渲染节奏。拿轮询间隔当动画节拍用,接口一抖整个屏的动效跟着卡。数据归数据,渲染归渲染,renderMode 解决的是画面适配,不替你兜数据频率的底。
第四,两套数据的时间基准要提前统一。 轮询数据带的是接口返回时刻的时间戳,推送数据带的是事件发生时刻的时间戳,两者可能差好几秒。同一块屏上「当前值」和「累计值」各用各的时钟,跨系统对账时就会发现数字对不上。汇总展示前,先定一个统一的时间口径。
七、技术总结
回到标题那道题:两套取数怎么不打架?
答案分三层。图纸层 :config.interfaceApi 里 api 与 websocket 并列配置、各自 enable,差异收敛在 timeInterval 与 sendScript 两个字段上。运行器层 :useApiRequest 与 useApiWebsocket 两条 hooks 镜像对称,各自管好自己的定时器单例(__apiRequestIntervalTimer)与连接单例(__websocketInstance),销毁时各自清理。派发层 :两条链路都终结于 editor.fire → exposeSetData → 按 signal 路径派发,组件只认 node.emitter 上的 setData 事件。
架构上最值钱的决策是最后一个:把「数据从哪来」和「数据怎么用」彻底分开。 源头随便加、出口永不变,两套取数才能长期共处而不互相渗透------这比任何防冲突技巧都管用。