数据大屏 WebSocket 稳定性优化:心跳检测、断点续传和消息去重
前言
前面几篇文章主要在聊地图大量点位的渲染优化:
text
分层渲染
↓
聚合展示
↓
增量更新
↓
只更新变化点
这些优化解决的是:
text
前端拿到数据之后,怎么更快地渲染到地图上。
但真实的大屏项目里,还有另一个更容易被忽略的问题:
text
数据怎么稳定、低延迟地到达前端?
尤其是在内网环境、弱网环境、数据大屏这类场景下,我们一般希望实时数据延迟控制在 200ms 以内。正常连接时,WebSocket 可以做到很低的延迟。
真正麻烦的是网络波动:
text
网络抖动
↓
WebSocket 断开
↓
前端重连
↓
断线期间消息可能丢失
↓
重连后服务端可能补发
↓
前端又可能重复消费
如果这套链路没有设计好,大屏上就会出现:
text
无人机位置跳变
事件点状态回退
地图点位重复
统计数字忽大忽小
页面突然全量刷新闪烁
所以这一篇继续沿着前面的思路,聊一下实时大屏里的 WebSocket 应该怎么优化。
本文重点不是"怎么创建一个 WebSocket",而是:
text
在网络会波动、数据不能丢、页面不能闪的情况下,WebSocket 应该怎么设计?
最终方案是:
text
心跳检测 + 退避重连 + 断点续传 + 消息去重 + 增量消费
现有写法的问题
项目里原来的 WebSocket 封装大概是这样的:
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);
if (event === "connect" && this.socket.connected) {
handler();
}
}
}
emit(event, data) {
if (this.socket) {
this.socket.emit(event, data);
}
}
}
const socketManager = new SocketManager();
export default socketManager;
页面里使用:
js
mounted() {
socketManager.connect("http://shangcheng.cldeye.com");
socketManager.on("connect", () => {
socketManager.login();
});
socketManager.on("dockMessage", (data) => {
this.hangarData = data.message;
this.initHangarMarker();
});
socketManager.on("uavMessage", (data) => {
this.uavData = data.message;
this.initUavMarker();
});
}
这个版本能跑,但是在实时大屏里有几个隐患。
踩过的坑
简单重连
最容易想到的写法是:
js
socket.onclose = () => {
connect();
};
或者断开之后立即重新调用 connect。
这个方案的问题是:
text
如果网络持续不稳定,前端会疯狂重连。
几十个大屏客户端同时在线时,断网恢复的一瞬间,所有客户端一起重连,服务端压力会非常大。
所以重连不能是固定间隔,更不能是立即重连,而应该使用:
text
指数退避 + 随机抖动
比如:
text
第 1 次重连:1s 左右
第 2 次重连:2s 左右
第 3 次重连:4s 左右
第 4 次重连:8s 左右
最大不超过 30s
每次再加一点随机抖动
这样可以避免所有客户端在同一时间打到服务端。
全量刷新
有些项目在重连成功后,会重新请求一次全量数据:
text
WebSocket 断开
↓
重连成功
↓
重新拉取全部无人机、机场、事件数据
↓
清空地图
↓
重新渲染
这在数据少的时候没问题。
但如果地图上有几千个点,甚至上万个点,就会出现:
text
页面闪烁
地图点位重建
图标重新加载
聚合重新计算
用户看到大屏抖动
前面我们已经做了地图增量更新,所以 WebSocket 重连后也不能再走全量刷新。
正确思路应该是:
text
重连后告诉服务端:我最后消费到哪条消息了
服务端只补发断线期间缺失的消息
前端继续按增量 patch 合并
消息丢失
WebSocket 断开期间,服务端仍然可能产生数据:
text
t1:前端收到 seq = 100
t2:网络断开
t3:服务端产生 seq = 101, 102, 103
t4:前端重连成功
如果没有断点续传,前端很可能直接从最新消息开始接:
text
前端下一条收到 seq = 104
那么中间的:
text
101, 102, 103
就永久丢失了。
地图上表现出来可能就是:
text
无人机少走了一段轨迹
事件状态没有更新
机场状态停留在旧值
统计数字和真实数据不一致
重复消费
重连后服务端为了保证不丢消息,可能会从某个 seq 开始补发。
比如前端最后确认消费到:
text
seq = 100
服务端为了保险,从:
text
seq = 98
开始补发。
这样前端会再次收到:
text
98, 99, 100
如果前端没有去重,就会重复消费。
对于大屏来说,重复消费的风险很高:
text
同一个事件被 add 两次
同一个统计计数累加两次
同一个无人机轨迹点重复插入
同一个删除消息重复执行导致状态异常
所以前端必须具备:
text
消息级去重能力
最终选型
我最后会把 WebSocket 优化成这一套:
text
Socket.IO 连接层
↓
业务心跳检测
↓
指数退避重连
↓
登录时携带 lastSeq
↓
服务端补发 lastSeq 之后的消息
↓
前端按 messageId / seq 去重
↓
按业务类型转成地图 patch
↓
交给地图增量更新逻辑
这里有一个点要注意。
项目里用的是:
js
socket.io-client
它不是原生 WebSocket,而是 Socket.IO。
Socket.IO 自带连接管理、底层 ping/pong、自动重连等能力。那为什么前端还要做心跳和断点续传?
原因是:
text
Socket.IO 的心跳主要证明连接还活着;
业务心跳证明数据链路和业务服务还正常;
Socket.IO 的重连只恢复连接;
断点续传恢复的是断线期间丢失的业务消息。
所以这两个不是一回事。
我的选择是:
text
连接层继续用 Socket.IO
业务稳定性自己补齐
这样改造成本最低,也最适合已有项目。
消息协议怎么设计
要做断点续传和消息去重,后端推送的数据不能只是:
js
{
message: {}
}
最好统一成这种结构:
js
{
messageId: "uav_1720000000000_1001",
seq: 1001,
type: "uavMessage",
timestamp: 1720000000000,
action: "upsert",
message: {
id: "uav-001",
longitude: 120.12,
latitude: 30.25,
height: 80,
status: "online"
}
}
几个字段很关键。
messageId:
text
消息唯一 id,用来去重。
seq:
text
服务端递增序号,用来判断断点。
type:
text
业务消息类型,比如 uavMessage、dockMessage、eventMessage。
action:
text
当前消息是新增、更新还是删除。
message:
text
真正的业务数据。
如果后端暂时不能提供 messageId,也可以先用:
js
const messageId = `${type}:${seq}`;
但更建议由后端统一生成,因为后端最清楚消息的全局顺序和唯一性。
为什么要有 seq
很多同学会问:
text
有 messageId 去重了,为什么还需要 seq?
因为它们解决的问题不一样。
messageId 解决:
text
这条消息我有没有消费过?
seq 解决:
text
我现在消费到哪了?
断线后应该从哪里继续?
消息中间有没有断档?
比如:
text
前端最近收到 seq = 100
下一条直接收到 seq = 104
这说明中间可能漏了:
text
101, 102, 103
这时候前端就可以触发补偿:
text
向服务端请求 100 之后的消息
所以实时系统里,我一般会要求服务端推送消息带上递增 seq。
前端需要保存什么状态
前端至少要保存 4 类状态。
连接状态
js
connected: false
connecting: false
manualClose: false
reconnectTimes: 0
用来判断当前是否连接中、是否是用户主动关闭、是否需要重连。
断点状态
js
lastSeq: 0
每消费成功一条消息,就更新一次:
js
this.lastSeq = Math.max(this.lastSeq, message.seq);
为了刷新页面之后也能续传,可以写入 localStorage:
js
localStorage.setItem("screen:lastSeq", String(this.lastSeq));
去重状态
js
messageIdSet: new Set()
messageIdQueue: []
不能让 Set 无限增长,所以要做一个 LRU 窗口。
比如只保留最近 5000 条消息 id:
js
rememberMessageId(messageId) {
if (this.messageIdSet.has(messageId)) {
return false;
}
this.messageIdSet.add(messageId);
this.messageIdQueue.push(messageId);
if (this.messageIdQueue.length > this.maxMessageCache) {
const expiredId = this.messageIdQueue.shift();
this.messageIdSet.delete(expiredId);
}
return true;
}
业务数据状态
这就是上一篇文章里的:
text
markerDataMap
markerEntityMap
markerSnapshotMap
WebSocket 不应该直接清空地图重新画,而应该把消息转成 patch:
js
{
upserts: [],
removeIds: []
}
然后交给地图组件:
js
this.$refs.cesiumMap.applyMarkerPatch(patch);
前端 SocketManager 怎么改
下面是一个更适合大屏的封装版本。
它主要做了几件事:
text
1. 连接时固定 clientId,避免每次 login 都生成新身份
2. 登录时携带 lastSeq,让服务端知道从哪里补发
3. 增加业务心跳,检测业务链路是否正常
4. 增加指数退避重连,避免断网时疯狂重连
5. 增加消息去重,避免重连补发导致重复消费
6. 增加 off,避免页面销毁后监听残留
代码示例:
这里需要注意一个兼容点:当前项目页面里已经写了 connect 之后手动 login(),所以封装层不再自动 login(),避免同一次连接重复登录。后续如果所有页面统一收敛到 SocketManager 内部登录,再把 login() 挪进 connect 回调也可以。
js
import io from "socket.io-client";
const LAST_SEQ_KEY = "screen:last-message-seq";
const CLIENT_ID_KEY = "screen:client-id";
function getClientId() {
let clientId = window.localStorage.getItem(CLIENT_ID_KEY);
if (!clientId) {
clientId = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
window.localStorage.setItem(CLIENT_ID_KEY, clientId);
}
return clientId;
}
class SocketManager {
constructor() {
this.socket = null;
this.url = "";
this.options = {};
this.clientId = getClientId();
this.lastSeq = Number(window.localStorage.getItem(LAST_SEQ_KEY) || 0);
this.manualClose = false;
this.reconnectTimes = 0;
this.reconnectTimer = null;
//心跳机制
this.heartbeatTimer = null;
this.lastPongAt = Date.now();
this.heartbeatInterval = 10000;
this.heartbeatTimeout = 30000;
this.enableBusinessHeartbeat = false;
this.pingEventName = "clientPing";
this.pongEventName = "serverPong";
//主要用于数据去重
this.maxMessageCache = 5000;
this.messageIdSet = new Set();
this.messageIdQueue = [];
this.eventHandlers = new Map();
}
connect(url, options = {}) {
this.url = url;
this.options = options;
this.manualClose = false;
this.clearReconnectTimer();
this.createSocket();
return this;
}
createSocket() {
if (this.socket) {
this.socket.removeAllListeners();
this.socket.disconnect();
this.socket = null;
}
const defaultOptions = {
path: "/web.socket-new",
transports: ["websocket"],
autoConnect: true,
// 这里关闭 Socket.IO 默认无限重连,统一走业务自己的退避重连。
// 也可以保留 Socket.IO reconnection,但不要再手写一套立即重连。
reconnection: false,
};
const socketOptions = {
...defaultOptions,
...this.options,
};
this.enableBusinessHeartbeat = Boolean(
socketOptions.enableBusinessHeartbeat
);
this.pingEventName = socketOptions.pingEventName || this.pingEventName;
this.pongEventName = socketOptions.pongEventName || this.pongEventName;
delete socketOptions.enableBusinessHeartbeat;
delete socketOptions.pingEventName;
delete socketOptions.pongEventName;
this.socket = io(this.url, socketOptions);
this.bindBaseEvents();
this.bindRegisteredEvents();
}
bindBaseEvents() {
this.socket.on("connect", () => {
this.reconnectTimes = 0;
this.lastPongAt = Date.now();
this.startHeartbeat();
});
this.socket.on("disconnect", () => {
this.stopHeartbeat();
if (!this.manualClose) {
this.scheduleReconnect();
}
});
this.socket.on("connect_error", () => {
this.stopHeartbeat();
if (!this.manualClose) {
this.scheduleReconnect();
}
});
// 业务心跳返回。事件名需要和后端约定,不建议直接占用底层 ping/pong。
this.socket.on(this.pongEventName, () => {
this.lastPongAt = Date.now();
});
}
bindRegisteredEvents() {
this.eventHandlers.forEach((handlers, event) => {
handlers.forEach((handler) => {
this.socket.on(event, handler);
});
});
}
login() {
if (!this.socket) {
return;
}
this.socket.emit("login", {
token: window.localStorage.getItem("thirdToken"),
clientId: this.clientId,
uavSwitch: true,
// 关键点:告诉后端我已经消费到哪条消息了。
lastSeq: this.lastSeq,
});
}
startHeartbeat() {
if (!this.enableBusinessHeartbeat) {
return;
}
this.stopHeartbeat();
this.heartbeatTimer = window.setInterval(() => {
if (!this.socket || !this.socket.connected) {
return;
}
const now = Date.now();
if (now - this.lastPongAt > this.heartbeatTimeout) {
this.socket.disconnect();
this.scheduleReconnect();
return;
}
this.socket.emit(this.pingEventName, {
clientId: this.clientId,
lastSeq: this.lastSeq,
timestamp: now,
});
}, this.heartbeatInterval);
}
stopHeartbeat() {
if (this.heartbeatTimer) {
window.clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
scheduleReconnect() {
if (this.reconnectTimer) {
return;
}
const baseDelay = 1000;
const maxDelay = 30000;
const delay = Math.min(baseDelay * 2 ** this.reconnectTimes, maxDelay);
const jitter = Math.floor(Math.random() * 1000);
this.reconnectTimes += 1;
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null;
this.createSocket();
}, delay + jitter);
}
clearReconnectTimer() {
if (this.reconnectTimer) {
window.clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
on(event, handler) {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, new Set());
}
this.eventHandlers.get(event).add(handler);
if (this.socket) {
this.socket.on(event, handler);
}
}
off(event, handler) {
const handlers = this.eventHandlers.get(event);
if (handlers) {
handlers.delete(handler);
}
if (this.socket) {
if (typeof this.socket.off === "function") {
this.socket.off(event, handler);
} else {
this.socket.removeListener(event, handler);
}
}
}
emit(event, data) {
if (this.socket) {
this.socket.emit(event, data);
}
}
disconnect() {
this.manualClose = true;
this.stopHeartbeat();
this.clearReconnectTimer();
this.eventHandlers.clear();
if (this.socket) {
this.socket.removeAllListeners();
this.socket.disconnect();
this.socket = null;
}
}
rememberMessage(message) {
const messageId = message.messageId || `${message.type}:${message.seq}`;
if (!messageId) {
return true;
}
if (this.messageIdSet.has(messageId)) {
return false;
}
this.messageIdSet.add(messageId);
this.messageIdQueue.push(messageId);
if (this.messageIdQueue.length > this.maxMessageCache) {
const expiredId = this.messageIdQueue.shift();
this.messageIdSet.delete(expiredId);
}
if (Number(message.seq) > this.lastSeq) {
this.lastSeq = Number(message.seq);
window.localStorage.setItem(LAST_SEQ_KEY, String(this.lastSeq));
}
return true;
}
}
const socketManager = new SocketManager();
export default socketManager;
这段代码不一定要完全照搬,但核心思想是:
text
连接只是第一步;
真正重要的是连接断开之后,怎么恢复到正确的数据状态。
页面消费怎么改
原来的页面里是直接消费消息:
js
socketManager.on("uavMessage", (data) => {
this.uavData = data.message;
this.initUavMarker();
});
这个写法的问题是:
text
收到一条消息就重新初始化一次 marker。
如果消息频率高,地图就会频繁重建。
优化后应该变成:
js
mounted() {
socketManager.connect("http://shangcheng.cldeye.com");
this.handleSocketConnect = () => {
socketManager.login();
};
this.handleUavMessage = (packet) => {
if (!socketManager.rememberMessage(packet)) {
return;
}
const patch = this.createMarkerPatchFromSocket(packet);
if (patch) {
this.$refs.cesiumMap.applyMarkerPatch(patch);
}
};
this.handleDockMessage = (packet) => {
if (!socketManager.rememberMessage(packet)) {
return;
}
const patch = this.createMarkerPatchFromSocket(packet);
if (patch) {
this.$refs.cesiumMap.applyMarkerPatch(patch);
}
};
socketManager.on("connect", this.handleSocketConnect);
socketManager.on("uavMessage", this.handleUavMessage);
socketManager.on("dockMessage", this.handleDockMessage);
},
beforeDestroy() {
socketManager.off("connect", this.handleSocketConnect);
socketManager.off("uavMessage", this.handleUavMessage);
socketManager.off("dockMessage", this.handleDockMessage);
}
然后把不同业务消息转成统一 patch:
js
methods: {
createMarkerPatchFromSocket(packet) {
const data = packet.message;
if (!data) {
return null;
}
if (packet.action === "delete") {
return {
removeIds: [data.id],
};
}
return {
upserts: [
{
id: data.id,
type: packet.type === "uavMessage" ? "uav" : "airport",
status: data.status,
longitude: data.longitude,
latitude: data.latitude,
height: data.height,
raw: data,
},
],
};
},
}
这样 WebSocket 和地图渲染就打通了:
text
WebSocket 消息
↓
消息去重
↓
转换成 patch
↓
地图 applyMarkerPatch
↓
只更新变化点
为什么不要重连后全量刷新
重连后全量刷新看起来最稳:
text
反正断过线,不如重新拉一遍。
但它的问题是体验和性能都不好。
地图大屏最怕的是:
text
清空再重画
因为用户能明显看到点位闪烁。
而断点续传的思路是:
text
我不假设前端状态已经失效。
我只补齐断线期间缺少的消息。
这样可以保留当前地图状态,只把缺失的变化补上。
前面做的 markerDataMap 和 applyMarkerPatch 正好可以承接这件事:
text
断线前地图是什么状态
↓
重连后补发缺失 patch
↓
前端合并 patch
↓
地图局部更新
页面不会闪,数据也能追平。
为什么需要业务心跳
Socket.IO 自己有 ping/pong,那还要业务心跳吗?
我觉得在大屏场景里有必要。
因为底层连接活着,只能说明:
text
客户端和 Socket.IO 服务之间的连接还在。
但业务链路还可能有问题:
text
业务服务卡住了
消息队列堆积了
服务端没有继续推业务数据
代理层连接还在但后端不可用
所以业务心跳最好携带:
js
{
clientId,
lastSeq,
timestamp
}
它至少能做三件事:
text
1. 让服务端知道客户端还活着
2. 让服务端知道客户端消费到哪了
3. 让前端知道业务链路是否长时间没有响应
如果连续超过一定时间没有业务 pong,前端可以主动断开并重连。
这比一直等浏览器或底层 WebSocket 自己发现断线更可控。
为什么要用退避重连
简单重连的问题是:
text
所有客户端断线后一起重连。
这会造成服务端瞬时压力。
退避重连的核心是:
text
失败越多,重连越慢。
随机抖动的核心是:
text
不要让所有客户端同一秒重连。
所以推荐:
js
const delay = Math.min(1000 * 2 ** reconnectTimes, 30000);
const jitter = Math.floor(Math.random() * 1000);
最终等待:
js
delay + jitter
这样网络恢复时,客户端会分散重连,服务端更稳。
为什么需要消息去重
断点续传通常会带来一个副作用:
text
服务端为了保证不丢,可能会多补几条。
比如前端最后消费到:
text
seq = 100
服务端可能从:
text
seq = 98
开始补。
这时候重复收到 98, 99, 100 是正常的。
前端不能假设服务端永远不会重复推。
更稳的原则是:
text
服务端至少送达;
前端保证幂等消费。
也就是:
text
消息可以重复到达,但重复消息不能重复生效。
这就是 messageIdSet 的作用。
js
if (!socketManager.rememberMessage(packet)) {
return;
}
重复消息直接跳过。
和地图增量更新怎么结合
WebSocket 优化不是孤立的。
它应该服务于地图渲染优化。
之前地图已经从全量更新变成:
text
维护 markerDataMap
↓
收到 patch
↓
合并 patch
↓
只更新变化的 marker
那 WebSocket 消息也应该设计成 patch。
比如后端推送无人机位置变化:
js
{
messageId: "uav_1001",
seq: 1001,
type: "uavMessage",
action: "upsert",
message: {
id: "uav-001",
longitude: 120.12,
latitude: 30.25,
height: 80,
status: "online"
}
}
前端转成:
js
{
upserts: [
{
id: "uav-001",
type: "uav",
longitude: 120.12,
latitude: 30.25,
height: 80,
status: "online"
}
]
}
如果是删除:
js
{
messageId: "uav_1002",
seq: 1002,
type: "uavMessage",
action: "delete",
message: {
id: "uav-001"
}
}
前端转成:
js
{
removeIds: ["uav-001"]
}
最后交给地图:
js
this.$refs.cesiumMap.applyMarkerPatch(patch);
整体链路就是:
text
后端推送 seq 消息
↓
前端判断是否重复
↓
更新 lastSeq
↓
转换成 marker patch
↓
合并 markerDataMap
↓
重新判断当前图层聚合/明细模式
↓
聚合层重算聚合
↓
明细层只更新变化点
这样 WebSocket 的稳定性优化,最后会直接体现在地图体验上。
后端需要配合什么
这套方案不是纯前端就能完整闭环的。
后端至少需要支持:
text
1. 每条消息有全局递增 seq
2. 每条消息有唯一 messageId
3. 客户端 login 时可以带 lastSeq
4. 服务端能补发 lastSeq 之后的消息
5. 服务端保留一段时间的消息缓存
6. 服务端收到 ack 或心跳后记录客户端消费进度
一个简单的服务端逻辑可以是:
text
客户端 login(lastSeq)
↓
服务端查询 seq > lastSeq 的消息
↓
按顺序补发
↓
再继续推实时消息
消息缓存可以保留:
text
最近 5 分钟
最近 10 万条
按业务 topic 分区保存
具体保留多久,要看业务数据量和断线恢复要求。
ack 要不要做
如果要求更严格,可以增加 ack。
前端消费成功后:
js
socketManager.emit("ack", {
clientId: socketManager.clientId,
seq: socketManager.lastSeq,
});
服务端收到 ack 后,记录这个客户端已经消费到哪个 seq。
有了 ack,服务端可以更准确地知道:
text
客户端收到消息了
客户端处理成功了
客户端可以从哪个位置继续
但 ack 也会增加通信量。
如果消息频率很高,不建议每条都 ack,可以批量 ack:
text
每 1 秒 ack 一次
每消费 100 条 ack 一次
心跳时顺带 ack lastSeq
大屏场景里,我更倾向于:
text
心跳携带 lastSeq
重要业务消息单独 ack
这样既能降低网络压力,也能保证关键数据可靠性。
最终效果
优化前:
text
WebSocket 断开
↓
立即重连
↓
重连成功后重新拉全量
↓
地图闪烁
↓
断线期间消息可能丢失
↓
补发消息可能重复消费
优化后:
text
WebSocket 断开
↓
停止心跳
↓
指数退避重连
↓
重连 login 携带 lastSeq
↓
服务端补发缺失消息
↓
前端 messageId 去重
↓
消息转 patch
↓
地图增量更新
↓
页面不闪,数据追平
总结
WebSocket 在实时大屏里不是连上就完事。
真正难的是:
text
连接断了怎么办?
重连时缺失的数据怎么办?
服务端补发导致重复怎么办?
页面怎么避免全量刷新闪烁?
这次优化的核心不是把 WebSocket 写得更复杂,而是把它从"连接工具"升级成"可靠实时数据通道"。
最终方案可以概括成:
text
心跳检测:发现假连接和业务链路异常
退避重连:避免断网时疯狂打服务端
断点续传:重连后补齐缺失消息
消息去重:避免补发消息重复消费
增量 patch:避免重连后全量刷新地图
对于地图大屏来说,这套 WebSocket 优化和前面的点位增量更新是配套的。
前端不应该在重连后重新初始化整个地图,而应该让实时消息继续走:
text
去重
↓
合并
↓
diff
↓
局部更新
这样才能同时保证:
text
低延迟
稳定性
不丢消息
不重复消费
页面不闪烁