实时数据推送技术
制造业大屏实时更新全攻略 — 从轮询到 WebSocket 全链路
为什么需要实时推送
在制造业大屏中,数据不是"刷新一次看一次"那么简单。OEE(设备综合效率)每秒都在变化, 机器状态(运行/停机/报警)必须即时反映,产线产量要实时滚动。如果数据延迟 10 秒才更新, 操作员看到的就是"过去的故事",不是"现在的情况"。
HTTP 轮询(定时拉取)
前端每隔 5 秒发一次 HTTP 请求:"有新数据吗?" 服务器说"没有"。5 秒后再问。
大部分请求都是浪费的 — 数据根本没变化!而且最多 5 秒延迟,不可能"毫秒级"感知。
WebSocket(双向长连接)
前端和服务器建立一条持久连接,不断开。服务器有新数据的那一刻,直接推给前端。
你零延迟收到消息,中间没有任何无用请求。
Server-Sent Events(服务器单向推送)
服务器主动给前端推送数据流。但这个推送是单向的
— 服务器推什么前端收什么,前端不能通过 SSE 向服务器发消息。
实时曲线 (Real-time Line Chart)
- OEE 数值每秒刷新
- 温度/湿度传感器数据流
- 产量累计曲线实时增长
- 设备功率波动监测
动态排序 (Dynamic Ranking)
- 各产线产量排行榜实时变动
- 设备 OEE 排名动态升降
- 不良品率 Top 5 实时更新
- 能耗排名柱状图动画
钻取与交互分析
- 点击产线钻取到设备级
- 点击设备查看历史趋势
- 报警信息实时弹出详情
- 联动过滤 — 选择车间联动所有图表
实时告警推送
- 设备异常停机即时告警
- 温度超标红色弹窗
- 质量异常消息推送
- 巡检提醒定时弹出
HTTP 轮询
最简单粗暴的方式 — 前端设一个定时器,每隔 N 秒发一次 HTTP 请求问后端要数据。
// 最简单的轮询:每 2 秒请求一次 OEE 数据
let pollingTimer = null;
function startPolling() {
pollingTimer = setInterval(async () => {
try {
const res = await fetch('/api/oee/realtime');
const data = await res.json();
updateDashboard(data); // 更新大屏图表
} catch (err) {
console.error('轮询失败:', err);
}
}, 2000); // 每 2 秒请求一次
}
function stopPolling() {
clearInterval(pollingTimer);
}
// 页面加载时开始轮询
startPolling();
// 页面卸载时停止轮询(很重要!)
window.addEventListener('beforeunload', stopPolling);
const express = require('express');
const app = express();
app.get('/api/oee/realtime', (req, res) => {
// 每次请求都从数据库/缓存读取最新数据
const oeeData = {
timestamp: Date.now(),
line1: { oee: 87.3, status: 'running' },
line2: { oee: 92.1, status: 'running' },
line3: { oee: 65.8, status: 'alarm' }
};
res.json(oeeData);
});
app.listen(3000);
100 × 1 req/sec = 100 请求/秒
其中 90% 的请求返回的数据和上次一模一样 — 纯属浪费!
这就好比 100 个大屏每秒各发一次请求问"有新数据吗?",服务器每秒处理 100 次查询,其中 90% 返回的是相同的数据 — 纯属浪费服务器资源!
| 问题 | 说明 | 严重程度 |
|---|---|---|
| 无谓请求 | 数据没变也要发请求,浪费带宽和服务器资源 | 严重 |
| 延迟不可控 | 2 秒轮询意味着最坏情况下数据延迟 2 秒 | 中等 |
| 服务器压力大 | 100 客户端 × 1 req/s = 100 QPS,且每次都要建立新 TCP 连接 | 严重 |
| HTTP 开销 | 每次请求都带完整 Header(Cookie、User-Agent 等),头部可能比数据还大 | 中等 |
Server-Sent Events (SSE)
SSE 基于 HTTP 协议,服务端可以持续向客户端发送数据。客户端用 EventSource 接收。
和轮询不同,SSE 只建立一次连接,服务端有数据就推,没有就安静等着。
SSE = 服务器单向推送数据流
服务器有新数据时主动推给前端,比如设备状态变化:"正常" → "预警" → "故障" → "已修复"。
前端只需要监听这个流,数据会自动到达。但注意:前端不能通过 SSE 向服务器发数据,
数据流是单向的(服务端 → 客户端)。
对比 WebSocket = 双向通信:前端和服务器可以互相发消息。
const express = require('express');
const app = express();
app.get('/api/sse/oee', (req, res) => {
// 1. 设置 SSE 必需的响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders(); // 立即发送响应头
// 2. 每 2 秒推送一次 OEE 数据
const interval = setInterval(() => {
const data = {
timestamp: Date.now(),
line1: { oee: (80 + Math.random() * 15).toFixed(1) },
line2: { oee: (85 + Math.random() * 10).toFixed(1) },
line3: { oee: (60 + Math.random() * 20).toFixed(1) }
};
// SSE 格式: "data: ...\n\n"
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 2000);
// 3. 客户端断开时清理
req.on('close', () => {
clearInterval(interval);
res.end();
});
});
app.listen(3000);
// 1. 创建 EventSource 连接(浏览器原生 API!)
const eventSource = new EventSource('/api/sse/oee');
// 2. 监听消息事件
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('收到OEE数据:', data);
updateDashboard(data); // 更新大屏
};
// 3. 监听连接打开
eventSource.onopen = () => {
console.log('SSE 连接已建立');
};
// 4. 监听错误(自动重连是浏览器内置的!)
eventSource.onerror = (event) => {
if (event.target.readyState === EventSource.CLOSED) {
console.log('SSE 连接已关闭');
} else {
console.log('SSE 连接中断,浏览器将自动重连...');
}
};
// 5. 手动关闭连接
// eventSource.close();
EventSource,无需引入任何库 ② 断线后浏览器自动重连
③ 比 WebSocket 更简单,适合只需服务端推送的场景(大屏展示就是典型!)
WebSocket — 全双工实时通信
WebSocket 是一个独立的网络协议(ws://),通过 HTTP 握手升级建立连接后,
客户端和服务端可以随时互发消息,不再受 HTTP 请求-响应模式限制。
HTTP(对讲机模式)
你按住按钮说一句"给我今天的 OEE",松开后等对方回复。说完一句,对话就结束了。
想再说?重新按按钮,重新问。
WebSocket(电话会议模式)
你拨通电话后,双方都不挂断。
任何一方随时可以说(发送数据),另一方立刻听到(接收数据)。
不需要"你说完了我才能说",可以同时双向通话。
在制造业大屏中:服务端随时推"OEE 变了!设备 3 报警了!",
客户端也可以随时发"我要订阅产线 A 的数据"。
const { WebSocketServer } = require('ws');
// 创建 WebSocket 服务器,监听 8080 端口
const wss = new WebSocketServer({ port: 8080 });
// 保存所有已连接的客户端
const clients = new Set();
wss.on('connection', (ws) => {
console.log('新客户端连接!当前连接数:', wss.clients.size);
clients.add(ws);
// 收到客户端消息
ws.on('message', (data) => {
const msg = JSON.parse(data);
console.log('收到消息:', msg);
// 示例:客户端请求订阅特定产线
if (msg.type === 'subscribe') {
ws.lineId = msg.lineId; // 标记该客户端关注的产线
ws.send(JSON.stringify({
type: 'subscribed',
lineId: msg.lineId
}));
}
});
// 客户端断开
ws.on('close', () => {
clients.delete(ws);
console.log('客户端断开,剩余:', clients.size);
});
});
// 模拟 OEE 数据 — 每 2 秒向所有客户端广播
setInterval(() => {
const oeeData = {
type: 'oee_update',
timestamp: Date.now(),
lines: {
line1: { oee: (80 + Math.random() * 15).toFixed(1), status: 'running' },
line2: { oee: (85 + Math.random() * 10).toFixed(1), status: 'running' },
line3: { oee: (60 + Math.random() * 25).toFixed(1), status: 'alarm' }
}
};
// 向所有已连接的客户端广播
for (const client of clients) {
if (client.readyState === 1) { // WebSocket.OPEN
client.send(JSON.stringify(oeeData));
}
}
}, 2000);
console.log('WebSocket 服务器运行在 ws://localhost:8080');
npm init -y
npm install ws express
// 1. 创建 WebSocket 连接
const ws = new WebSocket('ws://localhost:8080');
// 2. 连接成功
ws.onopen = () => {
console.log('✅ WebSocket 连接成功');
// 可以立即向服务端发送消息!
ws.send(JSON.stringify({
type: 'subscribe',
lineId: 'line1'
}));
};
// 3. 收到服务端消息
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'oee_update') {
updateOeeChart(data.lines); // 更新 OEE 实时曲线
updateStatusCards(data.lines); // 更新设备状态卡片
}
};
// 4. 连接关闭
ws.onclose = (event) => {
console.log('连接关闭:', event.code, event.reason);
// 注意:WebSocket 不会自动重连!需要自己实现
setTimeout(() => {
console.log('尝试重连...');
connectWebSocket(); // 重新创建连接
}, 3000);
};
// 5. 连接错误
ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
};
onclose 中手动实现重连逻辑。
这也是为什么生产环境推荐用 Socket.IO 的原因之一。
Socket.IO — 生产级 WebSocket 方案
原生 WebSocket = 手动挡汽车:能开,但离合、换挡、熄火重启都要你自己来。
断线了你要自己写重连逻辑,消息丢了你要自己处理。
Socket.IO = 自动挡 + 自动驾驶:所有麻烦事都帮你搞定了 —
自动重连(断线自动恢复)、
降级兜底(WebSocket 不可用时自动降级到轮询)、
房间机制(只给特定组的人广播)。
| 特性 | 原生 WebSocket | Socket.IO |
|---|---|---|
| 自动重连 | 需手动实现 | 内置 |
| 降级兜底 | 不支持 | 自动降级到轮询 |
| 房间(Rooms) | 需手动实现 | 内置 |
| 消息广播 | 遍历所有连接 | 一行代码 |
| 命名空间 | 不支持 | 支持 |
| 二进制数据 | 原生支持 | 支持 |
const { Server } = require('socket.io');
const express = require('express');
const app = express();
const httpServer = app.listen(3000);
// 创建 Socket.IO 服务器
const io = new Server(httpServer, {
cors: { origin: '*' } // 开发环境允许所有来源
});
io.on('connection', (socket) => {
console.log('客户端连接:', socket.id);
// 客户端加入 "dashboard" 房间
socket.on('join-dashboard', () => {
socket.join('dashboard');
console.log(socket.id, '加入 dashboard 房间');
});
// 客户端请求钻取某个设备详情
socket.on('drill-down', (deviceId) => {
// 只给请求者返回数据(不是广播)
const history = getDeviceHistory(deviceId);
socket.emit('device-detail', history);
});
socket.on('disconnect', () => {
console.log('客户端断开:', socket.id);
});
});
// 每 2 秒向 "dashboard" 房间广播 OEE 数据
setInterval(() => {
const oeeData = {
timestamp: Date.now(),
lines: {
line1: { oee: (80 + Math.random() * 15).toFixed(1) },
line2: { oee: (85 + Math.random() * 10).toFixed(1) },
line3: { oee: (60 + Math.random() * 20).toFixed(1) }
}
};
// 🔥 关键:只推送给 dashboard 房间的成员
io.to('dashboard').emit('oee-update', oeeData);
}, 2000);
// 引入 Socket.IO 客户端(CDN 或 npm)
// <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
const socket = io('http://localhost:3000', {
// 自动重连配置(Socket.IO 内置!)
reconnection: true,
reconnectionAttempts: Infinity, // 无限重试
reconnectionDelay: 1000, // 首次重连延迟 1 秒
reconnectionDelayMax: 5000, // 最大延迟 5 秒
});
// 连接成功
socket.on('connect', () => {
console.log('连接成功,ID:', socket.id);
socket.emit('join-dashboard'); // 加入房间
});
// 监听 OEE 更新
socket.on('oee-update', (data) => {
updateOeeChart(data.lines);
updateStatusCards(data.lines);
});
// 监听断线(Socket.IO 会自动重连)
socket.on('disconnect', (reason) => {
console.log('断线原因:', reason);
// 不需要手动重连!Socket.IO 自动处理
});
// 监听重连成功
socket.on('reconnect', (attempt) => {
console.log('重连成功,尝试次数:', attempt);
});
// 钻取设备详情(双向通信!)
function drillDownDevice(deviceId) {
socket.emit('drill-down', deviceId);
socket.on('device-detail', (data) => {
showDeviceModal(data); // 弹出设备详情弹窗
});
}
[大屏A, 大屏B, 管理看板]
前端实时图表更新
实时曲线的核心逻辑:收到新数据 → 追加到数组尾部 → 超过 N 条则移除头部 → 调用 setOption 更新。
不需要重新创建图表,只更新数据即可,ECharts 会自动做过渡动画。
实时曲线 = 走纸式心电监护仪
医院里的心电图仪,纸带上不停画着波浪线 — 新数据从右边出来,旧数据从左边"卷走"。
我们的前端曲线也是一样:新数据追加到右边,超过 50 个点就把最左边的移除,
屏幕上始终只显示最近 50 个点的"窗口"。
// 假设已经创建好了 ECharts 实例
const MAX_POINTS = 50; // 保留最近 50 个数据点
// X 轴时间数据
let timeData = [];
// Y 轴各产线 OEE 数据
let line1Data = [];
let line2Data = [];
let line3Data = [];
// 收到新数据时调用
function appendData(newData) {
const now = new Date(newData.timestamp);
const timeStr = now.toLocaleTimeString();
// 1. 追加新数据
timeData.push(timeStr);
line1Data.push(parseFloat(newData.lines.line1.oee));
line2Data.push(parseFloat(newData.lines.line2.oee));
line3Data.push(parseFloat(newData.lines.line3.oee));
// 2. 超过最大点数,移除最旧的数据(队列滑动窗口)
if (timeData.length > MAX_POINTS) {
timeData.shift(); // 移除头部最旧的
line1Data.shift();
line2Data.shift();
line3Data.shift();
}
// 3. 更新图表(只更新 series 数据,不需要重建图表)
chart.setOption({
xAxis: { data: timeData },
series: [
{ data: line1Data },
{ data: line2Data },
{ data: line3Data }
]
});
}
shift() 移除旧数据时,MAX_POINTS 不宜超过 200。
如果数据量更大,建议用环形缓冲区(Circular Buffer)代替数组。
// Gauge(仪表盘)配置
const gaugeOption = {
series: [{
type: 'gauge',
detail: { formatter: '{value}%' },
data: [{ value: 87.3, name: 'OEE' }],
// 动画配置
animationDurationUpdate: 800, // 过渡动画时长 800ms
animationEasingUpdate: 'cubicInOut' // 缓动函数
}]
};
// 收到新 OEE 值时更新
function updateGauge(newOee) {
gaugeChart.setOption({
series: [{
data: [{ value: newOee, name: 'OEE' }]
}]
});
// ECharts 会自动从旧值 → 新值做 800ms 的平滑过渡动画
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>实时OEE看板</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<style>
body { background: #0a0e27; color: #e0e6ff; font-family: sans-serif; display: flex; gap: 20px; padding: 20px; }
.chart-box { background: #131a3a; border: 1px solid #7ec8f0; border-radius: 12px; padding: 16px; }
#lineChart { width: 600px; height: 350px; }
#gaugeChart { width: 300px; height: 350px; }
#status { position: fixed; top: 10px; right: 20px; padding: 6px 14px; border-radius: 6px; font-size: 13px; }
.connected { background: rgba(34,197,94,0.2); color: #22c55e; }
.disconnected { background: rgba(255,107,107,0.2); color: #ff6b6b; }
</style>
</head>
<body>
<div id="status" class="disconnected">未连接</div>
<div class="chart-box">
<h3>OEE 实时曲线</h3>
<div id="lineChart"></div>
</div>
<div class="chart-box">
<h3>产线1 OEE</h3>
<div id="gaugeChart"></div>
</div>
<script>
// ===== 1. 初始化图表 =====
const lineChart = echarts.init(document.getElementById('lineChart'));
const gaugeChart = echarts.init(document.getElementById('gaugeChart'));
const MAX = 50;
let times = [], l1 = [], l2 = [], l3 = [];
// 折线图初始配置
lineChart.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
legend: { data: ['产线1', '产线2', '产线3'], textStyle: { color: '#8892b0' } },
xAxis: { type: 'category', data: [], axisLabel: { color: '#8892b0' } },
yAxis: { type: 'value', min: 50, max: 100, axisLabel: { color: '#8892b0' },
splitLine: { lineStyle: { color: '#7ec8f0' } } },
series: [
{ name: '产线1', type: 'line', smooth: true, data: [], lineStyle: { color: '#4facfe' } },
{ name: '产线2', type: 'line', smooth: true, data: [], lineStyle: { color: '#22c55e' } },
{ name: '产线3', type: 'line', smooth: true, data: [], lineStyle: { color: '#ff6b6b' } }
]
});
// 仪表盘初始配置
gaugeChart.setOption({
backgroundColor: 'transparent',
series: [{
type: 'gauge',
detail: { formatter: '{value}%', color: '#e0e6ff', fontSize: 28 },
data: [{ value: 0, name: 'OEE' }],
axisLine: { lineStyle: { color: [[0.6,'#ff6b6b'],[0.8,'#ffd93d'],[1,'#22c55e']] } },
axisLabel: { color: '#8892b0' },
animationDurationUpdate: 800,
animationEasingUpdate: 'cubicInOut'
}]
});
// ===== 2. 连接 Socket.IO =====
const socket = io('http://localhost:3000');
const statusEl = document.getElementById('status');
socket.on('connect', () => {
statusEl.textContent = '已连接 ' + socket.id;
statusEl.className = 'connected';
socket.emit('join-dashboard');
});
socket.on('disconnect', () => {
statusEl.textContent = '连接断开,自动重连中...';
statusEl.className = 'disconnected';
});
// ===== 3. 收到实时数据,更新图表 =====
socket.on('oee-update', (data) => {
const time = new Date(data.timestamp).toLocaleTimeString();
times.push(time);
l1.push(+data.lines.line1.oee);
l2.push(+data.lines.line2.oee);
l3.push(+data.lines.line3.oee);
if (times.length > MAX) { times.shift(); l1.shift(); l2.shift(); l3.shift(); }
// 更新曲线
lineChart.setOption({
xAxis: { data: times },
series: [{ data: l1 }, { data: l2 }, { data: l3 }]
});
// 更新仪表盘
gaugeChart.setOption({
series: [{ data: [{ value: l1[l1.length-1], name: 'OEE' }] }]
});
});
</script>
</body>
</html>
三种方案对比
| 对比维度 | HTTP 轮询 | SSE | WebSocket | Socket.IO |
|---|---|---|---|---|
| 通信方向 | 客户端 → 服务端 (请求-响应) |
服务端 → 客户端 (单向推送) |
双向全双工 (⇄ 自由互发) |
双向全双工 (⇄ 自由互发) |
| 协议 | HTTP | HTTP | WS (独立协议) | WS / HTTP 降级 |
| 复杂度 | 极低 | 低 | 中等 | 中等 |
| 浏览器支持 | 100% | 98% | 98% | 100% |
| 自动重连 | 无 | 浏览器内置 | 需手动实现 | 内置 |
| 降级兜底 | — | 不支持 | 不支持 | 自动降级到轮询 |
| 连接开销 | 高(每次新建连接) | 低(保持一条连接) | 低(保持一条连接) | 低(保持一条连接) |
| 服务端推送 | 不支持 | 原生支持 | 原生支持 | 原生支持 |
| 二进制数据 | 支持 | 不支持 | 支持 | 支持 |
| 典型场景 | 低频数据获取 简单后台管理 |
服务端单向推送 新闻/通知/日志 |
聊天/游戏/协同 需要双向通信 |
生产环境实时系统 大屏/IoT/协同 |
| 大屏推荐度 | ⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
制造业大屏(生产环境) → Socket.IO(最推荐,功能全,兜底好)
简单大屏(只展示不交互) → SSE(够用,最简单)
内部小工具(低频更新) → HTTP 轮询(最省事)
极致性能需求(IoT 海量连接) → 原生 WebSocket(无额外开销)
面试考点
核心区别:
- 通信模式:HTTP 是请求-响应模式(客户端问、服务端答);WebSocket 建立后双方可随时互发消息
- 连接生命周期:HTTP 每次请求建立连接、响应后断开(HTTP/1.0);WebSocket 一次握手、持久连接
- 开销:HTTP 每次请求带完整 Header(几百字节);WebSocket 帧头最小 2 字节
- 实时性:HTTP 无法主动推送,只能靠轮询模拟;WebSocket 服务端可主动推送
- 协议:HTTP 是独立协议;WebSocket 通过 HTTP Upgrade 握手升级建立
方向不同:WebSocket 全双工(双向),SSE 半双工(服务端→客户端单向)。
- SSE 优势:基于 HTTP、浏览器原生
EventSource、自动重连、实现简单 - SSE 劣势:只能服务端推、不支持二进制、HTTP/1.1 最多 6 个连接
- WebSocket 优势:双向通信、支持二进制、无连接数限制
- 选型原则:只需服务端推送(大屏展示、消息通知)→ SSE;需要双向交互(聊天、协同编辑、IoT 控制)→ WebSocket
原生 WebSocket 需要手动实现重连逻辑:
- 在
onclose回调中启动重连(setTimeout+ 递增延迟) - 使用指数退避策略:1s → 2s → 4s → 8s → 最大 30s
- 设置最大重试次数,避免无限重试
- 重连成功后重新发送订阅请求
- 重连期间在前端显示"连接中断"状态提示
推荐方案:使用 Socket.IO,内置完善的自动重连机制,无需手动实现。
分层治理策略:
- 服务端聚合:不推原始数据,推聚合后的结果(如 1 秒内 1000 条聚合为 1 条均值)
- 按需推送:客户端订阅特定设备/产线,只推送关注的数据(Room 机制)
- 降频推送:OEE 每秒变但人眼分辨不了,2-3 秒推一次足够
- 压缩传输:WebSocket 支持二进制帧,用 MessagePack / Protobuf 代替 JSON
- 消息分级:报警消息立即推送,OEE 数据定时推送,历史数据拉取走 HTTP
- 后端缓冲:用 Redis Pub/Sub 做消息中间件,WebSocket 服务端只做推送
三种策略:
- 节流(Throttle):不管数据来多快,图表更新频率固定(如每 500ms 更新一次),中间的数据缓存起来批量处理
- 防抖(Debounce):连续收到数据时不更新,停止收到数据后才更新 — 适合搜索框,不适合实时大屏
- requestAnimationFrame:把图表更新放在
requestAnimationFrame里,和浏览器刷新率同步(60fps → 16.7ms 一次)
制造业大屏推荐:用 requestAnimationFrame + 数据缓冲队列。
数据来了先存队列,每帧取队列中所有数据做一次批量更新。
六大核心优势:
- 自动重连:内置指数退避重连策略,断线自动恢复,无需手动实现
- 降级兜底:WebSocket 不可用时自动降级到 HTTP 长轮询,保证可用性
- 房间机制:原生支持 Rooms,可以分组广播(如只给"dashboard"房间推数据)
- 命名空间:一个端口支持多个命名空间(
/oee、/alarm),逻辑隔离 - 事件机制:自定义事件名(
socket.emit('oee-update', data)),比原生 WebSocket 的字符串更语义化 - 广播便捷:
io.to('room').emit()一行搞定定向广播,原生需要遍历所有连接
代价:多了一个库的依赖(~30KB gzip),协议不是标准 WebSocket(不能和非 Socket.IO 客户端互通)。
HTTP 轮询 → SSE → WebSocket → Socket.IO → ECharts 实时更新