数据大屏实时通信第一步:为什么选择 WebSocket,以及如何接入 Socket.IO
前言
前面几篇文章主要在聊地图大量点位的渲染优化:
text
分层渲染
↓
聚合展示
↓
增量更新
↓
只更新变化点
这些优化解决的是:
text
前端拿到数据之后,怎么更快地渲染到地图上。
但真实的数据大屏里,还有一个更靠前的问题:
text
数据怎么实时到达前端?
如果数据链路不稳定,后面的地图渲染优化做得再多,也只能优化"已经到达前端的数据"。
比如无人机位置、机场状态、事件告警这些数据,本身就是持续变化的。如果还用传统接口一遍遍请求,不仅延迟高,而且会浪费很多请求。
所以在进入 WebSocket 稳定性优化之前,我先把第一步单独拆出来:
text
为什么选 WebSocket?
为什么项目里用 Socket.IO?
如何初步和服务器建立连接?
第一版连接方案有哪些问题?
这一篇先解决"从 0 到 1 建立实时通信"的问题。下一篇再继续讲:
text
心跳检测
退避重连
断点续传
消息去重
为什么选择 WebSocket
在做数据大屏之前,我也考虑过几种常见的实时数据方案:
text
HTTP 轮询
↓
定时请求接口
SSE
↓
服务端单向推送
WebSocket
↓
客户端和服务端双向通信
不同方案适合不同场景。
如果只是普通后台管理系统,比如每隔 10 秒刷新一次列表,HTTP 轮询其实就够了。
但数据大屏不一样。
我的场景里有这些特点:
text
1. 内网部署
2. 大屏常驻展示
3. 无人机、机场、事件等状态需要实时变化
4. 数据延迟最好控制在 200ms 以内
5. 后端会持续推送增量数据
6. 前端不仅要接收消息,还要告诉服务端当前消费进度
如果用 HTTP 轮询,大概会变成这样:
js
setInterval(async () => {
const res = await getLatestData();
updateScreen(res.data);
}, 1000);
这个方案简单,但问题也很明显。
如果轮询间隔太长:
text
数据延迟高,大屏不够实时。
如果轮询间隔太短:
text
大量无效请求,服务端和前端都有压力。
更关键的是,轮询天然是客户端主动问:
text
有新数据吗?
有新数据吗?
有新数据吗?
但真实业务里,服务端才知道什么时候有新数据。
所以更合适的方式应该是:
text
服务端有变化,就主动推给前端。
这就是 WebSocket 更适合实时大屏的原因。
WebSocket 建立连接后,客户端和服务端之间会保持一条长连接。后续数据可以直接通过这条连接推送:
text
服务端产生无人机位置变化
↓
通过 WebSocket 推给前端
↓
前端转成 marker patch
↓
地图局部更新
相比轮询,它的优势是:
text
1. 延迟更低
2. 不需要频繁建立 HTTP 请求
3. 服务端可以主动推送
4. 前端也可以主动发送 ack、心跳、lastSeq
5. 更适合持续在线的数据大屏
为什么不直接用 SSE
SSE 也是一种服务端推送方案。
它的特点是:
text
服务端 → 客户端
也就是服务端单向推送。
如果只是通知类消息,比如:
text
订单状态变化
系统通知
任务进度
SSE 是可以考虑的。
但我的大屏场景里,前端也需要给服务端发送信息:
text
心跳
当前 lastSeq
消息 ack
订阅开关
业务过滤条件
也就是说,它不是单纯的"服务端告诉前端",而是需要双向通信:
text
服务端推送业务数据
前端反馈连接状态和消费进度
所以最终没有选择 SSE,而是选择 WebSocket。
简单总结就是:
text
普通列表刷新:HTTP 轮询可以接受
服务端单向通知:SSE 可以考虑
实时大屏 + 双向通信 + 低延迟:WebSocket 更合适
为什么选择 Socket.IO
这里还要区分一个概念:
text
WebSocket 是浏览器原生协议能力。
Socket.IO 是基于实时通信封装出来的一套库。
项目里实际使用的是:
js
socket.io-client
也就是 Socket.IO 客户端。
刚开始我也可以直接用浏览器原生 WebSocket:
js
const socket = new WebSocket("ws://example.com");
socket.onopen = () => {};
socket.onmessage = (event) => {};
socket.onclose = () => {};
socket.onerror = () => {};
原生 WebSocket 的优点是:
text
协议标准
依赖少
链路更直接
但它也比较"薄"。
很多工程化能力需要自己补:
text
重连
事件命名
消息分发
连接状态管理
兼容降级
命名空间
房间/订阅
服务端配套能力
而当前项目后端已经是 Socket.IO 这一套通信方式,前端自然也使用:
js
import io from "socket.io-client";
我选择继续使用 Socket.IO,主要是因为:
text
1. 和现有后端协议匹配,改造成本最低
2. 支持事件名通信,比如 uavMessage、dockMessage
3. 连接、断开、错误事件封装更完整
4. 页面代码更接近业务语义
比如原生 WebSocket 收到消息后,通常还要自己解析消息类型:
js
socket.onmessage = (event) => {
const packet = JSON.parse(event.data);
if (packet.type === "uavMessage") {
handleUavMessage(packet);
}
if (packet.type === "dockMessage") {
handleDockMessage(packet);
}
};
而 Socket.IO 可以直接按事件监听:
js
socket.on("uavMessage", handleUavMessage);
socket.on("dockMessage", handleDockMessage);
这对业务大屏来说更直观。
不过也要注意一点:
text
Socket.IO 不等于原生 WebSocket。
如果服务端是原生 WebSocket 服务,前端不能直接用 Socket.IO 客户端连接。两边协议要匹配。
当前项目里前后端已经约定使用 Socket.IO,所以我的选型不是重新造一套实时通信,而是在现有 Socket.IO 的基础上继续增强:
text
Socket.IO 负责连接和事件通信
业务层负责心跳、断点续传、消息去重和增量消费
初步建立 WebSocket 连接
在项目里,我先封装了一个最简单的 SocketManager。
它做的事情很少:
text
连接服务器
登录鉴权
监听事件
发送事件
断开连接
初始版本大概是这样:
js
import io from "socket.io-client";
class SocketManager {
constructor() {
this.socket = null;
}
connect(url, options = {}) {
const defaultOptions = {
path: "/web.socket-new",
autoConnect: true,
transports: ["websocket"],
};
const socketOptions = { ...defaultOptions, ...options };
this.socket = io(url, socketOptions);
return this;
}
disconnect() {
this.socket.disconnect();
}
login() {
if (this.socket) {
this.socket.emit("login", {
token: window.localStorage.getItem("thirdToken"),
clientId: Math.random(),
uavSwitch: true,
});
}
}
on(event, handler) {
if (this.socket) {
this.socket.on(event, handler);
}
}
emit(event, data) {
if (this.socket) {
this.socket.emit(event, data);
}
}
}
const socketManager = new SocketManager();
export default socketManager;
这里几个配置比较关键。
path:
js
path: "/web.socket-new"
表示 Socket.IO 连接服务端时使用的路径,需要和后端配置保持一致。
autoConnect:
js
autoConnect: true
表示创建 socket 实例后自动连接。
transports:
js
transports: ["websocket"]
表示强制使用 WebSocket 传输。
Socket.IO 默认可能会有轮询等传输方式。为了让大屏实时链路更明确,我这里直接指定:
text
只走 websocket。
页面里如何使用
有了 SocketManager 之后,页面里就不用直接操作 io() 了。
页面只需要关心:
text
什么时候连接
什么时候登录
监听哪些业务消息
收到消息后怎么更新页面
页面销毁时怎么断开
代码大概是这样:
js
import socketManager from "@/utils/socketManager";
export default {
mounted() {
socketManager.connect("http://xxxxxx.xxxx.com");
socketManager.on("connect", () => {
socketManager.login();
});
socketManager.on("dockMessage", (data) => {
const hangarData = data.message;
this.insertHangarData(hangarData);
});
socketManager.on("uavMessage", (data) => {
const liveData = data.message;
this.inisertUavData(liveData);
});
},
beforeDestroy() {
socketManager.disconnect();
},
};
这段逻辑可以拆成几步。
第一步,连接服务器:
js
socketManager.connect("http://shangcheng.cldeye.com");
第二步,连接成功后登录:
js
socketManager.on("connect", () => {
socketManager.login();
});
这里的 login 不是页面登录,而是 WebSocket 连接建立后的业务鉴权。
它会把本地保存的 token 发给服务端:
js
socket.emit("login", {
token: window.localStorage.getItem("thirdToken"),
clientId: Math.random(),
uavSwitch: true,
});
服务端校验通过后,再开始推送业务消息。
第三步,监听机场消息:
js
socketManager.on("dockMessage", (data) => {
const hangarData = data.message;
this.insertHangarData(hangarData);
});
第四步,监听无人机消息:
js
socketManager.on("uavMessage", (data) => {
const liveData = data.message;
this.inisertUavData(liveData);
});
这样,一个最小可用的实时通信链路就建立起来了。
初始链路是什么样
整体流程可以画成这样:
text
页面进入
↓
socketManager.connect
↓
Socket.IO 与服务端建立连接
↓
触发 connect
↓
前端发送 login
↓
服务端校验 token
↓
服务端持续推送 dockMessage / uavMessage
↓
前端更新大屏数据
这也是很多实时大屏第一版最常见的写法。
它的优点是:
text
接入成本低
代码结构清楚
业务事件语义明确
可以快速验证实时数据链路
但第一版只解决了:
text
正常网络下,怎么把服务端消息推到前端。
它还没有解决:
text
断线怎么办?
重连怎么办?
断线期间的数据怎么办?
重连后收到重复消息怎么办?
页面反复进入导致重复监听怎么办?
第一版会遇到的问题
正常网络下,这套代码可以跑。
但一旦进入弱网、断网、重连场景,就会暴露出几个问题。
连接断了只能被动等待
第一版里没有心跳检测。
如果网络出现假连接:
text
浏览器以为连接还在
服务端其实已经不可用
业务消息不再推送
页面数据停留在旧状态
前端很难及时发现。
重连策略不可控
如果完全依赖默认重连,或者自己写简单立即重连:
text
disconnect
↓
connect
↓
disconnect
↓
connect
在弱网环境下,可能会给服务端造成很大压力。
尤其是多个大屏客户端同时断线、同时恢复时,服务端会被集中重连打到。
重连后容易全量刷新
很多第一版代码在重连后会重新拉一次全量数据:
text
重连成功
↓
重新请求全量数据
↓
清空地图
↓
重新渲染
这样虽然简单,但是页面会闪烁,地图点位也会被重复创建。
前面做过的增量更新,也会被这一步绕开。
断线期间消息可能丢失
假设前端收到:
text
seq = 100
然后断线了。
断线期间服务端产生:
text
seq = 101, 102, 103
如果没有断点续传,前端重连后可能直接收到最新消息:
text
seq = 104
中间的 101, 102, 103 就丢了。
补发消息可能重复消费
为了避免消息丢失,服务端可能会在重连后补发一段历史消息。
比如前端最后消费到:
text
seq = 100
服务端为了保险,从:
text
seq = 98
开始补发。
这时候前端会重复收到:
text
98, 99, 100
如果前端没有去重,就可能重复更新数据,导致状态错乱。
下一步怎么优化
所以第一版 WebSocket 接入,只是把实时通信链路跑通。
它解决的是:
text
服务端怎么把消息推给前端。
但对于数据大屏来说,还需要继续解决:
text
连接是否还活着?
断线后什么时候重连?
重连时从哪里继续消费?
重复消息怎么跳过?
收到消息后怎么避免全量刷新?
下一步就要把这套最小可用连接升级成:
text
心跳检测
↓
退避重连
↓
断点续传
↓
消息去重
↓
增量消费
这也就是下一篇要讲的 WebSocket 稳定性优化。
总结
这一步的核心不是一上来就把 WebSocket 写得很复杂,而是先把选型和基础链路想清楚。
在实时大屏场景里:
text
HTTP 轮询简单,但延迟和无效请求问题明显。
SSE 适合服务端单向推送,但不适合需要频繁双向交互的大屏。
WebSocket 更适合低延迟、持续在线、双向通信的实时数据场景。
Socket.IO 则是在 WebSocket 能力之上,提供了更适合业务开发的事件通信模型。
项目第一版通过 socket.io-client 建立连接:
text
connect
↓
login
↓
监听 dockMessage / uavMessage
↓
更新大屏
这样可以快速完成实时数据接入。
但它还不是一个足够稳定的大屏实时通道。
真正上线到弱网、内网、常驻大屏环境后,还要继续补上:
text
心跳检测
退避重连
断点续传
消息去重
增量更新
也就是下一篇要继续优化的内容。