做充电桩实时监控大屏这个项目,前前后后折腾了两周多。中间踩了不少坑,也积累了一些比较实用的经验。这个项目看起来是前端可视化,实际上是把 WebSocket 长连接通信、数据推送稳定性、图表高频更新、大屏性能调优全串起来了。我把我整个实现过程复盘了一遍,从需求拆解到架构设计,再到 WebSocket 消息推送、Echarts 动态更新的各种细节,以及那些容易踩坑的排查实录,一次性整理出来,希望能给正在做类似物联网监控大屏的朋友一些参考。
1. 项目背景与需求拆解
1.1 充电桩监控的痛点
做这个项目之前,某运营方手里有几十个充电桩分布在不同区域,充电桩本身有自己的后台管理系统,但运维人员看的体验很差。数据刷新不及时、故障上报延迟、充电状态要靠人肉刷页面才能看到,运维效率非常低。
核心痛点其实是三个:一是充电桩状态数据是高频变化的,比如充电电流、电压、SOC(电池剩余电量)、实时功率这些数据,每秒都在变,传统 HTTP 轮询要么延迟高,要么请求量大,根本扛不住。二是故障发现靠人盯、靠电话通知,链路太长,往往是用户发现充电桩坏了才报修,而不是系统提前预警。三是充电桩分布分散,需要一个统一、直观的监控入口,让运维人员一屏看到全局状态。
所以这个项目本质上要解决的是:用最短的延迟、最小的资源开销,把充电桩实时状态推送到监控大屏上,并且把这些动态数据转换成人类友好的可视化界面。
1.2 技术选型背后的逻辑
技术选型其实没什么悬念。实时数据推送无非两个大方向:轮询和长连接。轮询实现简单,但实时性差、服务器压力大,对于几十个充电桩每秒一次的状态上报场景,HTTP 轮询会产生大量无效请求。长连接方案里 WebSocket 是浏览器原生支持的,全双工通信、头部开销小,是最适合大屏实时监控的方案。
可视化部分选了 Echarts,原因也很直接:它对动态数据接入——也就是 setOption 增量更新——支持得非常成熟,图表类型丰富,社区活跃,遇到问题查资料也方便。相比自研 Canvas 渲染或者用 D3 手动画,Echarts 的封装程度和开箱即用的体验在这个场景下效率最高。
我再补一句选型时容易忽略的点:WebSocket 不仅仅是前端拿数据,还要考虑后端怎么把充电桩的上报数据广播给所有前端客户端。也就是说,这不是一个简单的"浏览器到服务器"双向通道,而是"充电桩 → 接入服务 → WebSocket 服务 → 大屏客户端"的整条链路。后面的架构设计,其实就是围绕这条链路展开的。
2. 整体架构设计:从小车到调度室的实时链路
2.1 数据链路总览
我先画一下整个数据流向,帮助大家理解各个组件的位置和作用。
充电桩终端通过桩端上报协议(在我们这个项目里走的是 MQTT,但实际上到了 WebSocket 这一层,底层是啥已经不重要了)把状态数据发到接入服务。接入服务把原始数据做解析、格式化、清洗——比如把电表读到的小数点后多位的数值标准化、把桩端故障码翻译成人类可读的文案——然后交给 WebSocket 推送服务。
WebSocket 服务在这里的角色是一个"实时管道":所有已连接的大屏客户端都维护在一个在线集合里,每当有新的充电桩状态数据进来,服务端就遍历这个集合,把数据广播出去。这里有个很重要的点:不是每个充电桩数据只发给关心它的那个客户端,而是统一广播。因为大屏要展示所有桩的全局状态,这种一对多的广播模式最合适。
前端大屏客户端拿到 WebSocket 消息后做两件事:一是更新"全局状态缓存"(比如当前在线桩数、正在充电的桩数、故障桩数),二是把这些数据喂给 Echarts 图表做增量渲染。
2.2 前端模块划分
前端这一层按职责拆成四个核心模块:
- WebSocketClient 模块:负责连接建立、心跳保活、断线重连、消息收发。它不关心消息内容是啥,只保证上行和下行通道的稳定。
- 数据解析模块:负责把服务端发过来的 JSON 消息转换成前端组件能直接使用的结构化数据。注意,这一步不是简单的 JSON.parse,还要做数据合法性和完整性的校验。
- 状态管理模块:大屏是一个多组件协作的场景,顶部统计卡片、地图散点、趋势折线、桩列表各自都要访问实时数据。我用了全局的 Store(类似 EventEmitter + 状态缓存的模式)来统一管理,避免组件之间互相直接调用造成混乱。
- Echarts 渲染模块:每个图表是一个独立实例,订阅自己需要的数据子集,调用 setOption 更新。这里最关键的一个设计是:图表实例不直接感知 WebSocket 的存在,只感知 Store 数据的变化。数据驱动视图,是我在这个项目里贯彻得最彻底的一条原则。
这样拆分带来的直接好处是:WebSocket 断了重连的时候,图表层不需要做任何额外处理,Store 里的数据还在,重连后新数据一进来图表自动继续更新,整个过程无缝衔接。这比"图表自己管 socket,断了就丢数据"的写法稳定太多。
3. WebSocket 数据推送的全部实现细节
3.1 连接建立:从握手到身份校验
WebSocket 连接的第一步是握手。前端用一个服务端下发的访问令牌拼接连接地址,类似这样:
const wsUrl = `wss://monitor.example.com/ws?token=${accessToken}`; const ws = new WebSocket(wsUrl);这里有个安全细节要说明一下:在 URL 上挂 token 不太安全,因为 URL 会被浏览器日志、代理日志记录。如果条件允许,更推荐的做法是先通过 HTTPS 请求换取一个短期票据,再在 WebSocket 握手的子协议(Sec-WebSocket-Protocol)里携带。不过在我们的内部环境里,token 走 URL 参数的方式在可控范围内,所以先用这个方案。
连接建立后,服务端会主动下发一条welcome消息,确认连接成功,同时把当前所有桩的"初始快照"数据一次性推过来。这个设计非常关键——大屏第一次打开的时候,不能等每个充电桩都上报一遍才有数据,而是连接一建立就要先拿到全量状态打底。
{ "type": "welcome", "data": { "timestamp": 1700000000000, "snapshot": [ { "pid": "P001", "status": "charging", "power": 60.5, "soc": 72 }, { "pid": "P002", "status": "idle", "power": 0, "soc": 100 } ] } }3.2 消息协议设计
消息协议这块我倾向于"轻量 + 可扩展"。每条消息至少包含type和data两个字段,type表示消息类型,data里是具体内容。
实际用到的消息类型主要是这几种:
| type | 方向 | 用途 |
|---|---|---|
| welcome | 服务端 → 客户端 | 连接成功 + 全量快照 |
| heartbeat | 双向 | 心跳保活 |
| ping_ack | 服务端 → 客户端 | 心跳回执 |
| pong_ack | 客户端 → 服务端 | 心跳确认 |
| station_update | 服务端 → 客户端 | 单个充电桩状态更新 |
| alarm_event | 服务端 → 客户端 | 故障报警事件 |
| reconnect_ack | 服务端 → 客户端 | 重连后的补偿数据 |
station_update是最主要的业务消息,单条数据大概长这样:
{ "type": "station_update", "data": { "pid": "P001", "status": "charging", "voltage": 376.2, "current": 32.5, "power": 60.5, "soc": 72, "temperature": 38.6, "timestamp": 1700000006000 } }字段名全部用驼峰,且保持扁平结构。我把嵌套层级砍掉,是因为这种高频更新的消息每秒钟几十条,JSON 解析的耗时虽然不多,但能降一点是一点。扁平结构也方便前端直接映射到图表数据,不用做深层次的属性遍历。
3.3 心跳保活与断线重连
WebSocket 连接有一个天生的毛病:连接可能处于假死状态。也就是说,TCP 层面连接看起来还在,但实际已经收不到任何数据了。这是网络设备静默超时导致的,比如路由器或者防火墙把空闲连接悄悄丢弃,两端的 WebSocket 都不知道。
解决这个问题最通用的办法就是心跳机制。我设计的是:客户端每隔 30 秒发送一个心跳帧,服务端必须在 10 秒内回一个心跳确认帧,如果客户端连续 3 次没收到确认,就判定连接已经不可用,主动重连。
// 心跳检测核心逻辑 let missedHeartbeats = 0; const HEARTBEAT_INTERVAL = 30 * 1000; function startHeartbeat() { setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "heartbeat" })); missedHeartbeats++; if (missedHeartbeats >= 3) { console.warn("心跳连续丢失,触发重连"); ws.close(); } } }, HEARTBEAT_INTERVAL); } ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === "ping_ack" || msg.type === "pong_ack") { missedHeartbeats = 0; } };断线重连这块,最忌讳的是无脑立刻重连。如果服务端正在重启或者网络链路抖动,客户端疯狂重连只会加剧服务端压力,形成重连风暴。我用的是"指数退避 + 随机抖动"策略:第一次重连延迟 1 秒,第二次 2 秒,第三次 4 秒,以此类推,最大延迟不超过 60 秒,再加上 0~3 秒的随机抖动,避免所有客户端同时发起重连。
let retryCount = 0; function reconnect() { const delay = Math.min(Math.pow(2, retryCount), 60) * 1000; const jitter = Math.random() * 3000; setTimeout(() => { retryCount++; initWebSocket(); }, delay + jitter); } ws.onclose = () => reconnect();重连成功后,服务端会发送reconnect_ack消息,把客户端断线期间错过的关键数据做一个补偿推送。具体实现是服务端把每条变更消息带上自增序号,客户端记录最后收到的序号,重连后带上这个序号,服务端从序号之后开始补发。这个机制在实测中帮我省去了很多"重连后数据不一致"的麻烦。
4. Echarts 可视化落地与调优
4.1 大屏图表的选型与布局
可视化布局的出发点,是让运维人员一眼能看出"整体状态怎么样"。我当时的布局是:顶部一排统计卡片显示总桩数、在线数、充电中、故障数,中间左侧是充电桩地理位置分布图,中间右侧是实时功率排行和充电状态占比,底部是一条滚动的时间序列趋势图。中间位置给地图,因为运维人员最关心的就是"哪个区域的桩出问题了"。
图表选型上:
- 地图用 Echarts 的
scatter散点图加visualMap,把充电中的桩标成绿色、故障标成红色,点击散点可以弹窗看详情。 - 功率排行用
bar横向柱状图,按照实时充电功率从高到低排序。 - 充电状态占比用
pie环形图,展示 charging / idle / fault / offline 四种状态的占比。 - 趋势图用
line,展示全站总功率在最近半小时的变化曲线。
4.2 动态数据接入的正确姿势
Echarts 动态更新最核心的一个 API 就是setOption。我见过不少人在这里踩坑,setOption 的第二个参数是notMerge,控制新旧配置是合并还是覆盖。默认是 false,也就是合并。如果你没有理解这个参数,很容易出现"配置项残留"的问题,比如你之前设置了 tooltip 的某个字段,后续 setOption 没带上这个字段,信不信由你,有些配置会帮你"保留"下来。
我自己的规范是:固定的配置,比如color、legend、tooltip,在初始化时一次性传进去,后续数据更新只传series和xAxis/yAxis相关的动态部分。而且传数据时,attention 点不要放在"重设整个 option",而是只更新series[0].data。
function updateLineChart(latestPoints) { chart.setOption({ xAxis: { data: latestPoints.map(p => p.time) }, series: [ { data: latestPoints.map(p => p.totalPower) } ] }); }另一个关键点是:Echarts 对setOption是有 diff 算法的,它能自动识别数据变化,做增量更新。但是如果你每次 setOption 时都把整个 series 数组替换掉,diff 的优势就没有了,图表性能会明显下降。正确的做法是尽量保持 series 的结构稳定,只更新 data 部分。
4.3 性能优化:时间窗口移动与数据降采样
充电桩数据是高频的,每秒都可能来好几条。如果把这些数据无脑塞进趋势图,半小时之后图表的点位数就有上千个,浏览器渲染压力非常大。
我的处理方式是用"固定时间窗口 + 降采样"。
固定时间窗口:趋势图只展示最近 30 分钟的数据,每次新数据进来,就把窗口外的旧数据丢掉。理论上窗口内的数据量是恒定的,不会无限增长。
const WINDOW_SIZE = 30 * 60 * 1000; // 30 分钟 const maxPoints = 500; // 最多保留 500 个点 function appendPoint(points, newPoint) { const now = newPoint.timestamp; const filtered = points.filter(p => now - p.timestamp <= WINDOW_SIZE); filtered.push(newPoint); // 如果点位过多,则每 N 个点取一个 if (filtered.length > maxPoints) { const step = Math.ceil(filtered.length / maxPoints); return filtered.filter((_, index) => index % step === 0); } return filtered; }数据降采样:当点位超过上限时,不是简单粗暴地丢尾部数据,而是按照固定步长均匀采样,保住曲线的整体趋势。这里用最简单的"每 N 个点抽一个",对监控大屏来说足够用了,不需要上 LTTB(Largest Triangle Three Buckets)这样的复杂算法——因为我们的数据密度还没到那一步,杀鸡不用牛刀。
4.4 大屏主题与自适应
Echarts 的主题我重新配过,深色背景更符合监控大屏的视觉习惯。关键配置是背景色、字体颜色、网格线颜色,还有最重要的视觉反馈:故障桩用高亮的红色,正常桩用绿色,这样异常状态在余光里也能注意到。
// 注册自定义主题 echarts.registerTheme("monitor", { backgroundColor: "#0f1423", textStyle: { color: "#c8d0e0" }, categoryAxis: { axisLine: { lineStyle: { color: "#2b3350" } }, axisLabel: { color: "#8899bb" } }, valueAxis: { splitLine: { lineStyle: { color: "#1c2440" } } } });自适应这块,大屏终端可能是 4K 电视,也可能是普通的 1080P 显示器,所以不能写死图表尺寸。我的做法是监听resize事件,调用chart.resize()让图表跟随容器大小自动缩放。这里有一个细节:大屏用的是硬刷新,不搞响应式。因为硬件的分辨率是固定的,做响应式反而引入不必要的复杂度。如果你在普通电脑浏览器里打开,用devicePixelRatio相关的 Echarts 配置把文字和图表体积调一下就够了。
5. 工程化实践与协作记录
5.1 前端状态管理:用 Store 还是用 EventBus
做实时大屏的时候,一个很容易想到的方案是"组件各自连接 WebSocket,各自处理数据"——每个图表组件都手动创建一个 WebSocket 连接。这样做在开发初期看着舒服,团队的开发成本也低,但一旦上线,问题就来了:浏览器对单个域名的 WebSocket 连接数是有限制的,多个图表分享同一个 socket 节省资源,更重要的是避免"每个组件各自维护一套数据缓存,互相不通信,数据不一致"的混乱。
我在这个项目里用的是 Store 模式:一个全局的MonitorStore,内部维护一个数据缓存对象,并提供subscribe(selector, callback)方法。组件在 mounted 时订阅自己关心的数据切片,在数据变化时收到通知,再更新视图。
class MonitorStore { constructor() { this.state = { stations: {}, healthStats: { total: 0, online: 0, charging: 0, fault: 0 }, powerHistory: [] }; this.listeners = new Map(); } setStationData(payload) { const { pid } = payload; this.state.stations[pid] = { ...this.state.stations[pid], ...payload }; this.notify("station:" + pid); this.recalcStats(); } subscribe(key, callback) { if (!this.listeners.has(key)) this.listeners.set(key, new Set()); this.listeners.get(key).add(callback); } notify(key) { this.listeners.get(key)?.forEach(cb => cb(this.state)); } }这样做最大的好处是数据流完全单向:WebSocket 只管改 Store,组件只管读 Store。排查 bug 的时候,只需要看数据是怎么进 Store 的,以及 Store 数据怎么影响视图的,不会出现"一个状态绕了三个组件才传过来"的噩梦。
5.2 与服务端联调的约定
前后端在 WebSocket 联调阶段最大的坑是:两端对"消息格式"的认知不一致。前端写完代码,后端说"我把字段名改了",或者"我加的字段你前端没解析",这种琐碎的反复占用了我大量时间。
所以我在开始联调前会先做一件很基础但是很重要的事:定义好一份message-contract.md文档,把每种消息类型的字段、类型、示例值全部列清楚,然后让后端照着文档改服务端。前端代码也严格按照文档编写解析逻辑,加了字段就同步更新文档和解析器。这样把沟通成本压得很低。
联调的时候我还习惯做一件事:在客户端开发工具里把 WebSocket 的收发消息打点输出到控制台。每收到一条消息就打一行日志,包含消息类型和关键字段。这样调试的时候不是靠瞪眼看页面,而是看这条链路的数据到底流到哪一步了。
6. 常见问题与排查实录
6.1 WebSocket 断连重连风暴
有一次在测试环境压测,几十个充电桩每 5 秒上报一次,运行了两个小时后,服务端突然出现大量的 WebSocket 连接异常,随后连着的好几个客户端同时重连,服务端直接被压垮。
排查出来的根因有两个。第一个是服务端把连接空闲超时时间设置得太短,加上心跳机制配置不合理,导致服务端主动断开连接;第二个是客户端没有做重连延迟,断开后立刻重连,几十个客户端一起涌进来,服务端握手请求骤增,导致大量握手超时。
修复方案就是我们前面提到的:指数退避 + 随机抖动。另一块是在服务端加了一个"连接限流"的兜底逻辑,同一 IP 短时间内的握手请求超过阈值就拒绝,防止异常情况下的连锁效应。
6.2 Echarts 数据抖动与重复渲染
还有一个比较隐蔽的问题:Echarts 图表看起来在频繁抖动,尤其折线图,每个新数据点进来,整条曲线都会轻微跳动一下。起初我以为是数据本身波动大,后来仔细查代码,发现问题出在每次 setOption 的时候,把整个 series 重新赋值了,Echarts 在处理新旧 series 的 diff 时发生了"先移除再添加"的渲染逻辑,视觉上就出现了抖动。
解决办法很简单:稳定 series 结构,只更新 data,不替换 series 对象本身。这个坑用一句话总结就是:"你以为你在做增量更新,其实你每次都在做全量替换。"
6.3 大屏长时间运行的性能衰退
大屏是 7x24 小时挂着的,跑了一天后明显感觉图表操作卡顿。用浏览器性能面板一抓,发现是内存持续增长。根因是:WebSocket 的 onmessage 回调里,每次消息都创建一个新的 JSON 解析对象,这些对象如果没被正确释放,就会造成内存泄漏。具体到代码里,有一个很隐蔽的 bug:我在订阅 Store 数据时,回调是箭头函数写的,但退订的时候却传了另一个函数引用,订阅和退订的函数根本对不上,导致订阅回调越积越多。
排查方法也分享给大家:在 Chrome 的 Memory 面板里做堆快照对比,连续记录三五个快照,看哪些对象数量只增不减。我那次就是靠这个办法定位到了监听的异常增长。修复之后,内存曲线终于平稳了。
这个项目的核心收获就一句话:实时监控大屏,拼的不是单个图表的炫技,而是数据链路的稳定性。WebSocket 管好连接,Echarts 管好渲染,数据流管好一致性,整个系统跑起来自然就顺了。如果再扩展一下,后续可以加告警消息推送(比如充电桩故障时,大屏自动弹窗并语音提醒),甚至接入运维工单系统,让异常数据直接生成工单派单处理——这些都是下一阶段的迭代方向了。