ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WebRTC+WebSocket 低延迟可视化大屏实时联动实战

WebRTC+WebSocket 低延迟可视化大屏实时联动实战 做可视化大屏最怕客户来一句“我要实时”。数据指标用定时器轮询还能凑合可一旦牵扯到视频画面整个技术选型都会跟着变。我最近做的园区监控大屏项目就是把 WebRTC 低延迟视频流和 WebSocket 实时状态通道接在一起最终把端到端延迟压到了 500ms 以内。这类需求其实非常典型森林防火、污水处理、园区安防、车间产线凡是既要看数据又要看画面的可视化大屏基本都逃不开这两个核心点——视频怎么低延迟上墙状态怎么实时驱动图表。今天把整个项目的封装思路和踩坑记录整理出来。内容包括为什么最终选了 WebRTC 而不是更常见的 HLS 或 HTTP-FLV、播放器如何封装才能在不同大屏项目中复用、WebSocket 状态通道的消息协议怎么设计、视频区和图表区如何联动以及上线前实测的延迟数据和几个绕不开的坑。适合正在做或者准备做可视化大屏的开发者参考尤其适合前端为主、需要跟后端和流媒体网关协作的场景。1. 大屏项目里的“实时”究竟卡在哪个环节1.1 先看清楚 HLS 和 RTSP 为什么上不了大屏大多数安防项目里摄像头信号是 RTSP 流。RTSP 本身是传输控制协议浏览器不会原生支持直接给video塞 RTSP 地址是不现实的。于是很多团队会把 RTSP 先转成 HLS用 m3u8 切片来播放。问题就出在 HLS 的切片机制上。HLS 默认把视频切成 2 到 10 秒的 TS/MP4 分片播放器要拿到切片索引、下载分片、再经过一小段缓冲才开始播放。实测下来端到端延迟最低也有 3 秒通常都在 5 到 10 秒之间。大屏上显示的人和真正到场的人不同步在门禁、出入口、生产线上这种场景基本不可用。有人会说 HTTP-FLV 延迟不是只有一两秒吗确实HTTP-FLV 走的是 HTTP 流式传输配合 flv.js 在浏览器里播放延迟可以压到 1 到 3 秒。但 flv.js 依赖 MSE 能力iOS Safari 对 MSE 的支持一直不理想大屏项目一旦遇到 iPad 或者 iPhone 做辅助显示就会出问题。1.2 多路视频并发下的选型对比我权衡过的几个方案放在一起看会更直观方案端到端延迟浏览器兼容落地成本适合场景HLS3~10s全兼容低直播、监控回看HTTP-FLV flv.js1~3siOS 不友好中低延迟直播RTSP 原生原始流延迟不支持高仅后端处理WebRTC0.3~0.7s现代浏览器原生支持中高实时监控、互动WebRTC 在这个对比里几乎是唯一兼顾“浏览器原生支持”和“500ms 内低延迟”的方案。它的底层走 UDP天然适合实时音视频传输另外还内置了 ICE/STUN/TURN 机制可以处理内外网穿透在园区这种复杂网络环境下优势很明显。选型的时候还有一条要注意WebRTC 网关必须支持把 RTSP 转成 WebRTC 流。目前常用的开源方案有 ZLMediaKit、SRS、Janus我在这个项目里用的是 ZLMediaKit因为它的 WebRTC 支持比较成熟直接拉取 RTSP 再推给前端对 H.264 编码的摄像头兼容性很好不需要自己维护转码服务。如果摄像头是 H.265 编码这里会有一个明显的大坑后面专门讲。2. WebRTC 播放器封装信令流程、ICE 候选积压和可复用组件2.1 信令流程设计WebRTC 本身只管音视频传输但建立连接之前需要交换 SDP 和 ICE candidate这个交换动作就叫信令。项目中我会把信令服务单独拆出来不要和后面要讲的 WebSocket 状态通道混在一起。原因很简单信令是“按需建立的短时连接”状态通道是“全程保持的长连接”混在一起会导致信令风暴影响业务数据推送。信令流程基本是这样的前端通过 WebSocket 连接信令服务发送{ type: play, streamId }服务端向流媒体网关请求拉流网关把 RTSP 拉起来之后服务端通过信令通道返回 SDP offer前端收到 offer调用setRemoteDescription设置远端描述前端调用createAnswer生成应答再调用setLocalDescription设置本地描述前端把 answer 回传给服务端两端持续交换 ICE candidate音视频轨道到达前端把stream绑定到video元素上有一个细节需要注意有些信令实现是前端自己创建 offer 发给服务端服务端返回 answer有些是服务端直接下发 offer。两种模式都存在封装时要兼容至少不要写死。2.2 播放器类的封装实现我把播放器封装成一个独立的类大屏上每一个摄像头画面都是这个类的一个实例。这样做的好处是视频流的创建和销毁逻辑统一管理断线重连策略可以集中控制后续如果要从 4 路视频扩展到 16 路只需要在 UI 层多渲染几个组件。type WebRTCState idle | connecting | live | reconnecting | error; type RTCSignalMessage | { type: offer; sdp: RTCSessionDescription } | { type: candidate; candidate: RTCIceCandidateInit } | { type: stream-ready; streamId: string } | { type: error; code: number; message: string }; export interface WebRTCPlayerOptions { video: HTMLVideoElement; signalUrl: string; streamId: string; onStatus?: (state: WebRTCState) void; } export class WebRTCPlayer { private pc: RTCPeerConnection | null null; private ws: WebSocket | null null; private candidates: RTCIceCandidateInit[] []; private state: WebRTCState idle; private reconnectAttempts 0; private readonly maxReconnect 5; private destroyed false; constructor(private options: WebRTCPlayerOptions) {} play() { this.destroyed false; this.connectSignal(); } destroy() { this.destroyed true; this.ws?.close(); this.closePeer(); this.options.video.srcObject null; } private connectSignal() { const ws new WebSocket(this.options.signalUrl); this.ws ws; ws.onopen () { this.setState(connecting); this.send({ type: play, streamId: this.options.streamId }); }; ws.onmessage (ev) { let msg: RTCSignalMessage; try { msg JSON.parse(ev.data); } catch { return; } this.handleSignal(msg).catch((err) { console.error([WebRTCPlayer] handle signal error, err); this.setState(error); }); }; ws.onclose () { this.ws null; if (!this.destroyed) this.handleDisconnect(); }; } private async handleSignal(msg: RTCSignalMessage) { switch (msg.type) { case offer: { if (!this.pc) this.createPeer(); await this.pc!.setRemoteDescription(msg.sdp); const answer await this.pc!.createAnswer(); await this.pc!.setLocalDescription(answer); this.send({ type: answer, sdp: this.pc!.localDescription }); for (const candidate of this.candidates) { await this.pc!.addIceCandidate(candidate); } this.candidates []; break; } case candidate: { if (!this.pc || !this.pc.remoteDescription) { this.candidates.push(msg.candidate); } else { await this.pc.addIceCandidate(msg.candidate); } break; } case stream-ready: { this.setState(live); break; } case error: { this.setState(error); break; } } } private createPeer() { const pc new RTCPeerConnection({ iceServers: [{ urls: stun:stun.l.google.com:19302 }] }); this.pc pc; pc.ontrack (ev) { const [stream] ev.streams; if (stream) { this.options.video.srcObject stream; this.options.video.play().catch(() {}); this.setState(live); } }; pc.onconnectionstatechange () { if (pc.connectionState failed) { this.handleDisconnect(); } }; } private closePeer() { if (!this.pc) return; this.pc.ontrack null; this.pc.onconnectionstatechange null; this.pc.close(); this.pc null; } private handleDisconnect() { if (this.destroyed) return; this.closePeer(); this.ws?.close(); if (this.reconnectAttempts this.maxReconnect) { this.setState(error); return; } const delay Math.min(1000 * 2 ** this.reconnectAttempts, 15000); this.reconnectAttempts 1; this.setState(reconnecting); setTimeout(() this.connectSignal(), delay); } private setState(state: WebRTCState) { if (this.state ! state) { this.state state; this.options.onStatus?.(state); } } private send(data: unknown) { if (this.ws?.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } } }这段代码里有一个很容易踩的坑candidates的积压处理。WebRTC 规范要求在setRemoteDescription之后才能调用addIceCandidate但实际信令服务可能会在 offer 之前或者 setRemoteDescription 尚未完成时就把 candidate 发过来。如果直接addIceCandidate前端控制台经常报InvalidStateError。我在代码里做了判断pc.remoteDescription不存在时先把 candidate 缓存起来等 offer 处理完再统一补发。2.3 Vue 组件封装和销毁时机类封装好之后Vue 组件就可以非常薄了。组件只负责渲染状态层和暴露video元素具体连接逻辑全部交给WebRTCPlayerscript setup langts import { onBeforeUnmount, onMounted, ref } from vue; import { WebRTCPlayer } from ../lib/web-rtc-player; const props defineProps{ signalUrl: string; streamId: string; }(); const videoRef refHTMLVideoElement(); let player: WebRTCPlayer | null null; const status refidle | connecting | live | reconnecting | error(idle); onMounted(() { player new WebRTCPlayer({ video: videoRef.value!, signalUrl: props.signalUrl, streamId: props.streamId, onStatus: (s) (status.value s) }); player.play(); }); onBeforeUnmount(() { player?.destroy(); player null; }); /script template div classrtsp-player :classstatus-${status} video refvideoRef autoplay muted playsinline/video div v-ifstatus ! live classmask span v-ifstatus connecting || status reconnecting正在连接.../span span v-else-ifstatus error画面异常/span /div /div /template style scoped .rtsp-player { position: relative; width: 100%; height: 100%; background: #000; } video { width: 100%; height: 100%; object-fit: contain; } .mask { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.5); color: #fff; } /style组件里一定要带上muted和playsinline。大屏场景通常不需要声音而且这两个属性可以绕开浏览器自动播放策略后面踩坑部分会细说。销毁时机也值得强调大屏项目经常有“切换板块”或“路由跳转”的场景如果组件销毁时没有调用player.destroy()RTCPeerConnection和WebSocket会一直挂在后台。看网络面板时全是 CLOSE_WAIT 或 ESTABLISHED 状态的连接数量多了之后网关会被拖垮。我在destroy里显式关闭了信令 WebSocket、关闭了 PeerConnection、清空了srcObject这套清理逻辑在 4 路视频上不明显但到 16 路的时候能很明显感觉出内存和连接数的差别。3. WebSocket 状态通道消息协议和推送会话管理3.1 为什么状态通道不能用轮询替代视频流本身解决的是“画面实时”但大屏上还有另一类数据设备状态、告警事件、能耗指标、环境监测数据。如果这些数据还靠前端定时器轮询接口会有两个问题一是轮询间隔太长突发告警可能几十秒后才显示二是轮询间隔太短服务端压力大尤其是多个大屏同时在线时会非常浪费。WebSocket 的优势在服务端主动推送。只要有一个长连接服务端可以在事件发生的第一时间把数据推到前端。这个能力对于大屏可视化来说是刚需尤其是告警联动、设备离线检测、视频切换指令这类需要“事件驱动”的场景。3.2 消息协议设计让每条消息都知道自己该驱动什么很多人做 WebSocket 推送会直接发一段裸数据比如{value: 32}。前端收到之后完全不知道这段数据是哪个设备的、要更新哪个图表、需不需要弹窗。等到页面模块多起来消息分发逻辑就会变成一团乱麻。我的做法是约定一个统一协议信封所有消息都按这个结构走{ type: PLANT_ALARM, payload: { alarmId: A-20240512-001, deviceId: D-001, level: critical, cameraId: CAM-03, value: 88.5 }, timestamp: 1715558400000, msgId: 2a86bc9e-a2dc-4a48-9358-89f6e1d5b9a1 }type决定前端行为PLANT_ALARM触发告警弹窗和视频联动ENERGY_DATA更新能耗图表DEVICE_STATUS更新设备列表状态VIDEO_PLAY下发视频切换指令payload携带业务数据timestamp是服务端时间戳前端可以据此判断数据是否过期以及做时间轴对齐msgId用于幂等去重防止消息重发导致重复处理前端收到消息后不直接操作业务模块而是做一个简单的分发层让每个模块注册自己关心的typetype StateHandler (payload: Recordstring, unknown, meta: { timestamp?: number; msgId?: string }) void; export class StateChannel { private ws: WebSocket | null null; private heartbeatTimer: number | null null; private reconnectAttempts 0; private readonly maxReconnect 8; private handlers new Mapstring, SetStateHandler(); constructor(private baseUrl: string) {} start() { this.connect(); } on(type: string, handler: StateHandler) { if (!this.handlers.has(type)) { this.handlers.set(type, new Set()); } this.handlers.get(type)!.add(handler); } private connect() { const ws new WebSocket(this.baseUrl, vis-screen-v1); this.ws ws; ws.onopen () { this.reconnectAttempts 0; this.startHeartbeat(); }; ws.onmessage (ev) { let msg: StateMessage; try { msg JSON.parse(ev.data); } catch { return; } this.dispatch(msg); }; ws.onclose () this.handleClose(); ws.onerror () {}; } private dispatch(msg: StateMessage) { const handlers this.handlers.get(msg.type); if (!handlers) return; handlers.forEach((handler) { handler(msg.payload, { timestamp: msg.timestamp, msgId: msg.msgId }); }); } private startHeartbeat() { this.stopHeartbeat(); this.heartbeatTimer window.setInterval(() { if (this.ws?.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: PING })); } }, 30000); } private stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer null; } } private handleClose() { this.stopHeartbeat(); if (this.reconnectAttempts this.maxReconnect) return; const delay Math.min(1000 * 2 ** this.reconnectAttempts, 20000); this.reconnectAttempts 1; setTimeout(() this.connect(), delay); } send(type: string, payload: Recordstring, unknown) { this.ws?.readyState WebSocket.OPEN this.ws.send(JSON.stringify({ type, payload })); } }注意这里new WebSocket(this.baseUrl, vis-screen-v1)的第二个参数是 subprotocol。它可以在握手阶段就声明“我这个连接用的是 v1 协议”服务端如果只支持 v1就能在握手时就拒绝不兼容的客户端。做多版本大屏迭代时这个机制很有用。3.3 Spring Boot 服务端会话管理和常见并发问题服务端我用的是 Spring Boot 内嵌的ServerEndpoint。它的用法比较简单但对并发处理有一些要求Slf4j Component ServerEndpoint(/ws/state) public class StateServerEndpoint { private static final ConcurrentMapString, Session SESSIONS new ConcurrentHashMap(); OnOpen public void onOpen(Session session) { SESSIONS.put(session.getId(), session); log.info(ws open, total{}, SESSIONS.size()); } OnClose public void onClose(Session session) { SESSIONS.remove(session.getId()); } OnError public void onError(Session session, Throwable t) { SESSIONS.remove(session.getId()); log.error(ws error, t); } OnMessage public void onMessage(String text, Session session) throws IOException { JSONObject json JSON.parseObject(text); if (PING.equals(json.getString(type))) { session.getBasicRemote().sendText({\type\:\PONG\}); } } public static void broadcast(String type, Object payload) { JSONObject msg new JSONObject(); msg.put(type, type); msg.put(payload, payload); msg.put(timestamp, System.currentTimeMillis()); msg.put(msgId, UUID.randomUUID().toString()); for (Session session : SESSIONS.values()) { try { if (session.isOpen()) { session.getBasicRemote().sendText(msg.toJSONString()); } } catch (IOException e) { log.warn(send failed, sessionId{}, session.getId()); SESSIONS.remove(session.getId()); } } } }这里有两个很关键的约束。第一ServerEndpoint类不是 Spring 管理的单例多个连接会创建多个实例如果直接把 Spring Bean 注入到字段里会拿不到。我这边是把会话列表放在static ConcurrentHashMap里并且broadcast方法也是静态的这样可以从任何地方调用比如告警服务检测到异常时直接广播。第二session.getBasicRemote().sendText()在多个线程同时向同一个 session 发送时会爆IllegalStateException。所以遍历发送时必须逐条 try-catch或者用带队列的AsyncRemote。我目前为了稳定性优先遍历 失败移除等消息量大了再考虑改异步发送。另外建议在开发阶段直接用 Postman 的 WebSocket Request 调试这个接口。新建一个 WS 请求地址填ws://localhost:8080/ws/statesubprotocol 填vis-screen-v1然后手动发一条{type:PING}能很快验证服务端是否正常回包不用每次启动整个前端项目。4. 大屏联动编排视频区与图表区如何共享同一套状态4.1 三栏式布局下视频墙的位置大屏的布局通常不用特别花哨最稳定的是三栏式左右两侧放指标数据和图表中间区域留给视频墙。这样既可以利用超宽屏的横向空间又不会让视频画面过度拉伸。以 1920x1080 的三分屏为例我会用 CSS Grid 来排.screen { display: grid; grid-template-columns: 360px 1fr 360px; grid-template-rows: 64px 1fr 96px; gap: 12px; padding: 12px; height: 100vh; background: #07111f; color: #d6e4ff; } .video-wall { grid-column: 2; grid-row: 2; display: grid; grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr); gap: 12px; }左侧放能耗、产量、设备状态等指标卡右侧放 ECharts 图表中间放视频墙。视频墙内部再根据摄像头数量做 2x2、3x3 的网格。如果摄像头只有 1 路且需要重点突出也可以让视频区跨越两行三行这个按实际场景调。4.2 从一条告警消息到联动切换的完整链路这套联动逻辑是“状态驱动”这个说法的核心。拿一次设备告警举例数据流是这样的后端采集服务检测到设备温度异常后端调用StateServerEndpoint.broadcast(PLANT_ALARM, payload)推送告警消息前端StateChannel收到消息分发给注册了PLANT_ALARM的处理器处理器更新左侧告警列表弹出告警高亮如果告警关联了摄像头cameraId前端更新activeCameraId对应视频墙组件销毁旧流、创建新流如果告警需要标记在时间轴上ECharts 在对应时间点打上 markPoint前端这里的联动代码很像一个“总控台”stateChannel.on(PLANT_ALARM, (payload, meta) { alarmList.value.unshift(payload); if (payload.cameraId payload.cameraId ! currentCameraId.value) { currentCameraId.value payload.cameraId; } chart.setOption({ series: [{ markPoint: { data: [{ coord: [meta.timestamp, payload.value], itemStyle: { color: #ff4d4f } }] } }] }, { lazyUpdate: true }); });这套逻辑的关键点在于前端不主动询问“该切哪个摄像头”而是完全由服务端的告警消息驱动。告警来了前端被动响应。这比前端轮询告警接口再决定切不切摄像头响应速度快了一个量级而且代码结构更干净。4.3 ECharts 增量更新的性能处理大屏图表最容易出现的问题是“消息一多就卡”。要知道 WebSocket 推一条数据前端就setOption一次如果一秒推几十条ECharts 会频繁重绘整个图表帧率直接掉下去。我的做法是做两级缓冲。第一级是数据层合并把同一类型的数据按时间戳缓存进数组用requestAnimationFrame统一更新第二级是 ECharts 本身的lazyUpdate参数它会把多次setOption合并到下一帧渲染避免同步重绘let pendingData: Array[number, number] []; let chartUpdating false; function pushChartData(point: [number, number]) { pendingData.push(point); if (!chartUpdating) { chartUpdating true; requestAnimationFrame(() { chart.setOption({ series: [{ data: pendingData }] }, { lazyUpdate: true }); pendingData []; chartUpdating false; }); } }这里最重要的认知是图表更新不需要“每条消息都立即上屏”人眼感知不到 30ms 内的差别但帧率从 60fps 掉到 20fps 一眼就能看出来。所以在高频率推送场景下牺牲一点点实时性换稳定的帧率是划算的。5. 延迟实测与工程落地绕不开的六个坑5.1 端到端延迟到底花在了哪里项目上线前我做了一轮针对性的延迟测试统计路径是摄像头采集 - 编码 - RTSP 传输 - ZLMediaKit 网关转 WebRTC - 浏览器解码渲染对照大屏画面和现场时钟。环节参考耗时说明摄像头采集编码40~120msH.264 硬编码延迟较低RTSP 拉流到网关20~80ms内网直连较稳定WebRTC 网关转发30~80ms受并发路数和 CPU 影响浏览器 jitter buffer80~250ms默认值偏保守可调解码 渲染10~30ms硬解延迟低合计下来整条链路实测在 300 到 700ms 之间。这个数据在园区监控大屏场景是可以接受的肉眼几乎感觉不到画面和实际的错位。如果觉得延迟还偏高优先排查两处一是有没有经过转码二是浏览器 jitter buffer 是不是默认值过大。WebRTC 内部播放缓冲有一些参数可以调但调太低会导致网络抖动时花屏需要权衡。5.2 坑一自动播放策略导致画面一直黑的真实原因上线第一天就遇到一个诡异问题所有视频画面加载出来但始终是黑屏控制台没有任何报错。排查到最后发现是浏览器自动播放策略拦截了video.play()。Chrome 要求网页必须先有用户交互之后才能播放有声媒体。但我们的 video 标签已经设置了muted理论上属于自动播放允许范围。问题出在srcObject赋值之后的play()调用时机有时候ontrack触发时页面还没有任何用户手势Chrome 会返回一个被拒绝的 Promise而我们代码里只是.catch(() {})吞掉了没有在 UI 上提示。解决方式有两个层面。第一video 标签固定加muted和playsinline第二大屏启动时设计一个“进入大屏”的交互按钮用户点击之后才创建视频播放器实例这一步点击就是用户手势之后的video.play()都能顺利执行。如果是领导直接站在大屏前要自动化演示可以在页面加载后主动播放一个完全静音的流来“激活”音频上下文但这个方案在 Safari 上不够稳我还是推荐保留一个启动交互。5.3 坑二ICE candidate 早于 answer 到达导致连接中断这个坑在信令联调阶段就碰到了。现象是偶尔能出画面偶尔一直卡在连接中看控制台报InvalidStateError定位后发现是 candidate 消息在setRemoteDescription之前就到达了。原因在于信令服务端的发送顺序并不保证严格串行尤其在网关繁忙时offer 处理和 candidate 上报之间会出现交错。我在前面播放器封装里已经处理了这个问题pc.remoteDescription不存在时把 candidate 放进数组缓存等 offer 处理完再补发。这个逻辑看着简单但如果没有提前设计故障出现时会非常迷惑。5.4 坑三connectionState 为 disconnected 不等于挂了WebRTC 的connectionState有多个状态容易误判的是disconnected。有段时间我把disconnected当成故障触发重连结果网络一抖动页面疯狂重建 PeerConnection画面反而一直出不来。后来调整了策略只有failed才立刻重连disconnected延迟 30 秒检查一次如果超过 30 秒还没恢复再重连。因为 WebRTC 底层有自己的 ICE restart 逻辑很多时候disconnected是网络抖动引起的几秒后会自动恢复。过度干预反而会打断 ICE 的自动恢复流程。5.5 坑四H.265 编码摄像头在 Chrome 上无法播放这个坑项目上线前差点没堵住。园区新增了一批 H.265 编码的摄像头接进 ZLMediaKit 之后播放器一直处于“正在连接”服务端日志显示流已经推上去了但前端始终没有 track 到达。原因很直接Chrome 不支持 H.265 硬解播放。WebRTC 协商时前端只声明了自己支持的编码格式服务端发现两者没有交集多轮协商失败连接就挂了。解决方向有三种。第一种是让网关把 H.265 转码成 H.264 再推 WebRTC最省事但实时转码非常吃 CPU16 路视频转码至少要上独立显卡或者专用转码服务器第二种是更换支持 H.265 的播放内核但这个方案在 WebRTC 场景下可选方案少需要大量改造第三种是采购摄像头时统一选 H.264 编码这是最经济的做法。我最后的建议是新项目从源头锁定 H.264存量设备通过网关转码同时监控 CPU 占用超过阈值只能减少并发路数。5.6 坑五多路并发视频流导致内存和带宽失控大屏同时开 16 路视频时内存和带宽会肉眼可见地增长。每路 WebRTC 播放器都有自己的 PeerConnection内部要维持 jitter buffer、解码器等资源。实测 16 路 720p H.264 在普通 i7 工作站上Chrome 内存稳定在 1.2GB 左右CPU 在 40% 上下如果再加图表动画机器风扇会一直高速运转。针对这个问题我做了三件事。第一非关键区域的视频降低分辨率网关支持在拉流时指定码率和分辨率不需要前端处理第二页面隐藏的播放器直接暂停或者销毁通过IntersectionObserver判断视频区是否在可视范围内第三视频墙切成“多画面轮巡”模式一次只播 4 路每隔 30 秒滚动切换一批这样既保住了监控的覆盖范围又不会让浏览器同时扛 16 路解码。监控大屏的设计思路应该是“该全时显示的才全时显示”而不是“所有摄像头都塞进一个页面”。5.7 坑六WebSocket 洪峰让大屏直接掉帧告警级联发生时服务端可能一秒推送二三十条消息。如果前端每条消息都立即操作 DOM 和图表必然掉帧。我前面提到的requestAnimationFrame合并更新就是为此准备的。另外告警弹窗也不能做到无上限堆叠我会在页面上只展示最近的 6 条告警更早的合并成“N 条历史告警”的折叠项。这样既保证关键信息不丢又不至于让整个页面被消息淹没。服务端这边也要考虑削峰。如果告警本身是批量产生的可以做一个简单的聚合推送把 10 秒内的同类告警合并成一条带上count字段前端拿到之后更新计数而不是逐条渲染。实时性和流畅度的平衡工程上通常靠“合并 限流”实现。最后再分享两个小技巧一个是视频流和状态通道的“动静分离”。视频流走 WebRTC状态数据走 WebSocket信令也走 WebSocket但信令服务一定要单独部署或者单独路径。三个通道混在一起延迟和稳定性都很难排查。另一个是字体和配色的经验大屏背景用深色渐变数据字体用工程感较强的无衬线体预警色集中在红、橙两档不要做太多颜色层次否则告警高亮就不明显了。我个人的体会是大屏可视化项目最难的地方从来不是某个单一技术有多深而是视频、状态、图表、交互这四条线能否在自己的节奏里协同。把 WebRTC 播放器封装成可复用组件、把 WebSocket 消息协议定义统一、把状态分发做成订阅式这三件事做好后面不管接多少路视频、加多少块面板都能保持结构不乱。项目做完之后再回头看最值得花时间的还是协议设计和边界条件处理这两块做扎实了上线后的半夜电话会少很多。
返回列表