前阵子接到一个充电站运营方的需求,要做一个能挂在运营中心墙上的大屏,实时展示整个站网几百根充电桩的运行状态。刚看到需求时觉得不复杂,ECharts 画几个图而已。真正动手才发现,难点根本不在图表上,而是在“实时”这两个字上——充电桩的状态、电流、电压、SOC 这些数据,怎么从设备端稳定地推到前端,推上来之后又怎么在大屏上渲染得又快又不卡,才是这个项目的核心。
我最初按惯性思维用了轮询方案,结果数据延迟、服务器压力、页面闪烁各种问题一起冒出来。换成 WebSocket 做主动推送之后,整个链路才算理顺。这篇文章把我从需求梳理到选型、从后端推送到前端渲染、再到上线后踩坑复盘的完整过程记录下来,给同样要做实时监控大屏的人一个参考路径。
1. 项目背景:从轮询到 WebSocket 的转变
1.1 充电桩数据监控到底需要看什么
运营方要的大屏,不是简单的“一根桩亮一个灯”。他们关注几个维度的数据:桩的当前状态(空闲、充电中、故障、离线)、每一路的实时电压和电流、车辆的 SOC、充电功率曲线、整站的当天充电量、订单数、故障率。还要能按城市、按场站维度下钻。
你可以把这想象成一个 7x24 小时的“驾驶舱仪表盘”,运营人员盯着它判断哪座站负载高了、哪些桩故障了、某个区域的充电高峰什么时候来。这意味着一个核心约束:数据必须准、必须快、必须连续。准还好说,桩端上报的数据只要做好清洗就行;快和连续,就要求前端不能干等,而要让数据主动“跑”到页面上来。
这个项目的实时数据链路是这样的:
code复制充电桩设备 → 桩端网关 → 后端服务 → WebSocket 推送 → 前端大屏 → ECharts 渲染
后端服务先以 MQTT 或 Modbus 协议从设备网关采集数据,做协议解析和清洗后,把结构化的实时数据通过 WebSocket 推给浏览器端,再由 ECharts 把数据变成图表。这条链路里,WebSocket 是承上启下的关键节点,也是我最开始没选对的地方。
1.2 轮询方案为什么撑不住
第一版我图省事,前端每 5 秒用 HTTP 轮询一次后端接口,拉全量桩状态。演示的时候 30 根桩没什么问题,但一上真实环境,几百根桩的数据全部拉下来,问题立刻暴露:
- 数据延迟不可控:轮询只能做到“准实时”,中间至少隔着一个轮询周期。充电过程中 SOC 跳变很快,运营人员盯着大屏却看到 5 秒前的数据,体验很不好。
- 无谓的请求浪费:即使数据没有任何变化,前端也会雷打不动地发请求。高峰期同时开着多块大屏,后端接口 QPS 直接翻倍。
- 首页加载闪烁:每次轮询返回全量 JSON,前端 setState 之后整个图表重新 setOption,即使数据只有一点变化,页面也会肉眼可见地闪一下。
轮询不是不能用,但它更适合“低频、少量、容忍延迟”的场景。像充电桩这种高频实时更新的大屏,需要的是服务端主动推送的机制,让数据变更立刻到达前端。
1.3 技术选型对比:WebSocket、SSE 与轮询
我认真对比了三种常见方案,见下表:
| 方案 | 方向 | 实时性 | 连接方式 | 适用场景 |
|---|---|---|---|---|
| 轮询 | 前端请求、后端响应 | 取决于轮询间隔 | 短连接 | 低频查询、接口天然 HTTP |
| SSE | 服务端单向推送 | 准实时 | 长连接 | 单向通知、文本流 |
| WebSocket | 双向通信 | 实时 | 长连接 | 高频双向交互、实时监控 |
SSE 也是长连接,但它是服务端单向推送给客户端,天然适合“通知消息”。可这里还有个问题:大屏页面偶尔需要主动给后端发指令,比如远程重启某根故障桩、下发限流指令。这是双向交互,SSE 做起来别扭。WebSocket 是真正的全双工通道,一条连接既收数据也能发指令,从架构演进角度更合适。
选型结论很简单:大屏实时监控用 WebSocket,后端推送、前端订阅,做真正的全双工长连接。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. WebSocket 服务端推送的核心实现
2.1 连接管理与心跳保活机制
后端我选的是 Node.js,配合 ws 库实现 WebSocket 服务。第一步不是写业务逻辑,而是把连接生命周期管好。大屏场景下,一个页面可能会开很长时间(有的运营中心是 7x24 小时挂着),如果连接断了没人发现,页面就成了一块“死屏”。
连接管理需要一个在线连接池,记录每个连接的 sessionId、接入时间、最后活跃时间、订阅的场站范围。客户端连上来后,服务端要做的第一件事是注册连接,然后立即推送一份“全量快照”,让大屏先有初始数据,再逐步接收增量更新。
心跳保活的逻辑非常重要。长连接看起来稳定,但中间隔着路由器、负载均衡器、防火墙,它们可能默默掐断长时间空闲的连接。我用的是双向心跳机制:
- 服务端每 30 秒向客户端发送一个
{"type":"ping"}协议帧; - 客户端收到后回复
{"type":"pong"}; - 服务端在连接池里记录上次收到 pong 的时间;
- 如果连续 3 个心跳周期没有收到 pong,服务端判死该连接,主动关闭并触发清理。
代码核心大致是这个样子:
javascript复制const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
const clients = new Map();
wss.on('connection', (ws, req) => {
const sessionId = generateSessionId(req);
clients.set(sessionId, {
ws,
alive: true,
lastPong: Date.now(),
subscriptions: parseSubscription(req)
});
ws.on('pong', () => {
const record = clients.get(sessionId);
if (record) {
record.alive = true;
record.lastPong = Date.now();
}
});
ws.on('close', () => clients.delete(sessionId));
ws.on('error', () => clients.delete(sessionId));
});
// 定时心跳检测
setInterval(() => {
const now = Date.now();
clients.forEach((record, sessionId) => {
if (now - record.lastPong > 90000) {
record.ws.terminate();
clients.delete(sessionId);
console.log(`session ${sessionId} 心跳超时,已断开`);
}
});
}, 30000);
注意:线上环境不要用单机内存 Map 存连接池。如果后端需要横向扩容,连接状态会分裂在多台机器上。实际项目里我接了一层 Redis 做连接元数据管理,WebSocket 连接本身的迁移则依赖网关层做会话保持。
2.2 数据协议的字段设计
协议设计是容易忽略的环节。刚开始我只是把桩端数据原样转发给前端,结果前端代码越写越乱,因为各个数据源格式不统一,有的字段叫 charging_state,有的叫 state,还有的用字符串 "1" 表示状态。代付了很多代价后,我干脆在服务端做了一层标准化的“推送协议”,统一所有字段口径。
我设计的推送消息结构是:
json复制{
"type": "status",
"timestamp": 1728888990000,
"data": {
"deviceId": "PILE_001",
"stationId": "STATION_A",
"state": "charging",
"voltage": 386.4,
"current": 142.6,
"soc": 68.5,
"temp": 42.1,
"power": 55.08
}
}
type用来区分消息类型:status(状态更新)、snapshot(初始快照)、heartbeat(心跳)、command_result(指令下发回执);timestamp统一用服务端时间戳,保证不同设备上报的时间基准一致;data里是标准化后的业务字段,单位一律统一。
前端收到消息后,只要判断 type 就决定走哪条处理分支。这个设计看似简单,但它极大降低了前后端联调和排错的成本。
2.3 多桩并发推送与消息合并策略
几百根桩同时上报数据,如果每根桩的状态变化都立刻推一条消息给大屏,前端会被消息淹没。这里我加了一个“缓冲合并”策略:高频数据定时聚合,低频事件实时推送。
充电桩的实时电压电流是高频数据,每秒钟都可能有值变化。但大屏上的曲线图其实不需要每一毫秒都更新,人眼也感知不到。我把这类数据放进一个缓冲队列,每 1 秒合并一次当前所有桩的最新状态,打包成一批推送给前端。而故障告警、订单完成这类低频事件,则单独走实时推送通道,确保运营人员能立即看到异常。
推送网关的核心代码思路:
javascript复制function pushStatusBatch() {
const batch = [];
batteryStatusBuffer.forEach((record, deviceId) => {
if (record.dirty) {
batch.push(record.getLatestSnapshot());
record.dirty = false;
}
});
if (batch.length > 0) {
broadcast({
type: 'status_batch',
timestamp: Date.now(),
data: batch
});
}
}
// 每 1 秒聚合推送
setInterval(pushStatusBatch, 1000);
// 告警事件实时推送
function pushAlert(alert) {
broadcast({
type: 'alert',
timestamp: Date.now(),
data: alert
});
}
这样做的另一个好处是减少浏览器的渲染压力。前端不必每秒处理几千条消息,而是每秒拿到一批紧凑的批次数据,在渲染层做统一处理,性能会稳定很多。
3. 前端 WebSocket 客户端封装与数据流设计
3.1 客户端连接管理、自动重连与心跳检测
后端有了推送服务,前端就得有一套能稳定消费消息的客户端。我单独封装了一个 RealtimeClient 类,核心职责是:建立连接、维持心跳、异常重连、消息分发。
接管重连逻辑是最值得花功夫的地方。最初我的重连策略是“断线后每秒重试一次”,结果某次后端发布重启,几十块大屏同时断线,恢复后所有客户端一起疯狂重连,服务端瞬间被打爆。这就是典型的重连风暴。
正确做法是指数退避加抖动:第一次重连等 1 秒,第二次等 2 秒,第三次等 4 秒,指数增长到上限 30 秒,再添加一个随机 0-5 秒的扰动,避免所有客户端同时发起连接。实现要点:
javascript复制class RealtimeClient {
constructor(url) {
this.url = url;
this.ws = null;
this.retryDelay = 1000;
this.maxDelay = 30000;
this.shouldReconnect = true;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.retryDelay = 1000; // 连接成功,重置重连延迟
this.lastConnectedAt = Date.now();
};
this.ws.onclose = () => {
if (this.shouldReconnect) {
const delay = Math.min(this.maxDelay, this.retryDelay) + Math.random() * 5000;
setTimeout(() => this.connect(), delay);
this.retryDelay *= 2;
}
};
this.ws.onmessage = (event) => this.handleMessage(event.data);
}
}
浏览器原生 WebSocket 断线后会停留在 CLOSED 状态,不会自动重连,必须手动创建新实例。封装这个类之后,业务组件就不用关心连接细节了。
3.2 数据解析层与状态管理
多条消息进来后,不能直接丢给图表组件。我加了一层解析和存储层,把消息按设备维度归并到一个全局状态树里。这个状态树是大屏所有组件的唯一数据源。
javascript复制const state = {
stations: {
STATION_A: {
piles: {
PILE_001: { state: 'charging', voltage: 386.4, soc: 68.5 },
PILE_002: { state: 'idle', voltage: 0, soc: 0 }
},
updateTime: 1728888990000
}
},
alerts: [],
summary: {
totalPiles: 240,
onlinePiles: 235,
chargingPiles: 128
}
};
状态树的好处在于,图表组件只需要关心自己订阅的那一段数据,比如桩的状态列表变了就刷新地图颜色,某根桩的功率变了就更新对应的折线图点。这样就做了解耦,不会因为一次推送就把整个大屏全部重绘。
3.3 大屏布局规划与视觉层级
大屏不是把一堆图表堆上去就行,它是有信息层级的。我们的设计顺序是这样的:
顶部一行是汇总指标:总桩数、在线率、今日充电量、今日订单数、故障总数。
中间主体是一张场站分布热力图,用地图标记各站点负载情况,颜色从绿到红渐变表示负载由低到高。
底部两行是趋势类图表:整体充电功率随时间的变化曲线、各场站 SOC 分布箱线图、充电量排名 TOP10、设备故障排行榜。
视觉上我选用了暗色背景加渐变亮色的科技风格,ECharts 的 dark 主题打底,再自定义几个色板。暗色不仅更符合监控大屏的视觉定位,更重要的是能让 ECharts 的高亮元素真正突出,而不是在一片白色背景下显得廉价。
4. ECharts 可视化核心图表的实时刷新
4.1 实时刷新机制:setOption 的正确姿势
ECharts 实时刷新的核心 API 是 setOption,但很多人用错方式。如果每次都传入完整的 option,图表会重建整个配置,导致闪烁和卡顿。正确做法是只 setOption 变化的部分,并开启 notMerge 参数来控制合并行为。
我的高频刷新写法:
javascript复制function updatePowerChart(points) {
const chart = powerChartInstance;
// 只更新 series 里的数据,不动 xAxis 和 tooltip
chart.setOption({
series: [{
data: points
}]
}, { notMerge: false });
}
第二个参数传 { notMerge: false }(或省略)时,ECharts 会把新旧配置合并,只更新变化的字段。这样图表在动画过渡上非常平滑,不会整图重绘。
对于动态时间轴,我维护一个固定长度的滑动窗口:
javascript复制const maxPoints = 1200;
function pushPoint(chartData, point) {
chartData.push(point);
if (chartData.length > maxPoints) {
chartData.shift(); // 删除最早的旧点
}
}
通过限制窗口内数据点数量,既保证了折线图能看到最近一段时间的变化趋势,又避免了内存中数据无限增长。
4.2 大数据量性能优化:防抖与节流
ECharts 处理几百个数据点没有压力,但几千点加高频刷新时,渲染性能就会成为瓶颈。这里我用了两层优化。
第一层是数据节流:前端每秒从 WebSocket 收到批量数据,但如果当前页面标签页处于后台,或者当前帧还没有渲染完,新数据就不能立刻触发重绘。我用了一个简单的时间戳判断:
javascript复制let lastRenderTime = 0;
const THROTTLE_MS = 1000;
function onDataReceived(payload) {
const now = Date.now();
// 合并到待绘制的缓存
pendingData = payload;
if (now - lastRenderTime < THROTTLE_MS) return;
lastRenderTime = now;
renderCharts(pendingData);
}
第二层是关闭不必要的动画。动画在首次加载时很酷,但实时刷新时每次 setOption 都带 300ms 的缓动动画,会非常卡。实时数据刷新时我把 animation 设为 false,只在图表初始化时保留入场动画。
4.3 视觉主题定制与暗色大屏风格
ECharts 支持注册自定义主题,我强烈建议大屏项目都用自定义主题,而不是直接用默认主题或 dark 主题。默认主题过于通用,dark 主题的配色也偏死板。自定义主题可以统一标题、轴线、背景、色板的风格。
javascript复制echarts.registerTheme('monitor', {
color: ['#00d8ff', '#19be6b', '#f5a623', '#f5222d', '#b37feb'],
backgroundColor: 'transparent',
textStyle: {
color: '#c0d4e8'
},
title: {
textStyle: {
color: '#e8f0fe',
fontSize: 16
}
},
legend: {
textStyle: {
color: '#a5b8cf'
}
},
xAxis: {
axisLine: { lineStyle: { color: '#2a4b6a' } },
axisLabel: { color: '#7a93aa' },
splitLine: { show: false }
},
yAxis: {
axisLine: { show: false },
axisLabel: { color: '#7a93aa' },
splitLine: { lineStyle: { color: '#1c3149' } }
},
tooltip: {
backgroundColor: 'rgba(20, 35, 55, 0.92)',
textStyle: { color: '#e8f0fe' }
}
});
这套配色里,主色用了青色和电光蓝,代表了“电力”“能量”的感觉;告警用高亮的橙红色。视觉上暗色背景和亮色图形形成对比,大屏远距离观看时信息可读性很强。
5. 上线后踩坑记录与排查复盘
5.1 内存泄漏等问题
项目上线约一周后,我发现运行三天的大屏页面浏览器内存持续增长,从 300MB 涨到 1.2GB。后来开始排查内存泄漏。
罪魁祸首有两个。
第一个是定时器未清理。我在多个图表组件里都用了 setInterval 来发心跳或做定时刷新,但组件在销毁时没有清除定时器,导致多个组件反复创建定时器,页面生命周期一长,定时器全部积压。
处理方式是全局统一管理定时器:
javascript复制const timers = [];
function schedule(fn, ms) {
const timer = setInterval(fn, ms);
timers.push(timer);
return timer;
}
function clearAllTimers() {
timers.forEach(timer => clearInterval(timer));
timers.length = 0;
}
第二个是 ECharts 实例未释放。大屏做场站下钻时,每次切换站会重建多个图表实例,旧实例没有调用 chart.dispose(),导致 Canvas 资源和事件监听器一直挂在内存里。我加了一层实例池,创建新实例前先检查同 DOM 节点是否已有实例,有就先 dispose 再重建。
另外,WebSocket 客户端在页面进入后台时,消息处理逻辑还在跑,但渲染逻辑已经停了。我把渲染放在了 requestAnimationFrame 里调度,避免积压大量未处理的数据。
5.2 断线重连风暴带来的服务端压力
上线初期发生过一次典型事故。后端服务发布重启后,所有大屏客户端几乎在同一时间断线,然后同时触发重连逻辑。由于最初版本的重连延迟固定为 1 秒,几十个客户端同时打过来,服务端瞬间出现大量半开连接,CPU 和内存飙升到警戒线。
后来修复了重连策略,加了指数退避和随机抖动。另外服务端也加了一层限流保护:如果单位时间内新连接数超过阈值,超出的连接直接收到一个 retry-after 提示,让它们稍后再连。
这个事故给我一个很深的教训:客户端退避策略和服务端过载保护必须同时做,缺一个都堵不住漏洞。
5.3 实时图表时间轴的滚动与数据堆积问题
折线图长时间运行后,数据点越来越多,哪怕限制了 1200 个点,也会因为每秒刷新一次而在 20 分钟后把整个页面拖慢。这个问题的根源是:虽然窗口限制了数据总数,但每次 push 和 shift 操作后 ECharts 都会重新计算整个 series 的数据范围,图表不断重排。
后来我把时间轴分成了两种场景。对于大屏上的功率趋势图,采用固定时间窗口滚动:只显示最近 15 分钟,窗口左边的点自动退出。这符合运营人员看趋势的直觉,也把内存里的数据控制在极小的范围内。
对于需要长期留痕的图表,比如充电量日报,则不做实时推送,改为每 5 分钟从后端拉一次聚合数据,写入静态 series。实时刷新和定时聚合分开,两套逻辑互不干扰。
6. 一些经验总结与后续可扩展的方向
做完这个项目后,我对“实时监控大屏”这个品类有了更实在的体感:它不只是一堆图表堆砌,而是一条完整的数据管道。任何一个环节没做好,最后都会直观地反映在页面上——不是卡顿,就是延迟,再不然就是崩溃。
复盘来看,收益最大的几个决策是:选型阶段放弃轮询改用 WebSocket;服务端做消息合并避免高频小包轰炸;前端封装统一数据总线和明确的渲染节流策略;上线前把重连、内存释放、实例销毁这些工程化问题提前想透。这些都不是“炫技”的东西,但决定了项目能不能稳定放在运营中心墙面上跑一周不重启。
后续可以扩展的方向有几个。一是多设备类型的接入,把数据链路抽象成标准事件模型,充电桩、储能柜、配电柜统一建模。二是在服务端把消息持久化到时序数据库,比如按小时回放历史趋势,让大屏具备“回溯”能力。三是告警联动,当某根桩连续故障时,自动在多块屏幕上同步高亮,并推送消息到运维人员的移动端。
如果说还有什么个人体会,那就是实时大屏这类项目,架构设计和工程质量的重要性一定高于视觉效果。图表做得再漂亮,数据链路一断,整块屏就是一块“花瓶”。先把链路做稳,再谈好看,这个顺序千万别搞反。
