消息确认机制在前端项目中的实践踩坑总结
消息确认机制踩坑记
今天搞消息确认功能又折腾了半天,说起来这个需求其实挺简单的,就是用户发消息后要确保对方收到了,然后显示一个已读的小图标。但真正做起来才发现坑不少,特别是网络不稳定的时候,各种边界情况要考虑。
最早的想法很简单,客户端发消息,服务端收到后返回一个ack确认,然后更新UI状态。但这样有个问题:如果服务端收到了但客户端没收到ack呢?那用户界面就一直显示未读状态,但实际上消息早就被对方看到了。
最初的简单版本
刚开始写的代码特别简单粗暴:
// 发送消息
function sendMessage(message) {
return fetch('/api/send-message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
}).then(res => res.json());
}
// 消息发送后轮询确认状态
function pollMessageStatus(messageId, callback) {
const interval = setInterval(() => {
fetch(/api/message-status/${messageId})
.then(res => res.json())
.then(data => {
if (data.status === 'read') {
clearInterval(interval);
callback(data);
}
});
}, 1000);
}
这个方案最大的问题是轮询太频繁,而且没有考虑网络断开的情况。一旦网络中断,轮询就完全失效了。
WebSocket长连接方案
后来改成WebSocket方案,感觉靠谱多了。客户端和服务端建立长连接,消息确认通过WebSocket实时推送:
class MessageService {
constructor() {
this.ws = null;
this.messageCallbacks = new Map();
this.connect();
}
connect() {
this.ws = new WebSocket('ws://localhost:8080/ws');
this.ws.onopen = () => {
console.log('WebSocket connected');
// 重连后重新发送未确认的消息
this.resendUnconfirmedMessages();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch(data.type) {
case 'MESSAGE_ACK':
this.handleMessageAck(data);
break;
case 'MESSAGE_READ':
this.handleMessageRead(data);
break;
case 'SYNC_UNREAD':
this.handleSyncUnread(data);
break;
}
};
this.ws.onclose = () => {
console.log('WebSocket disconnected, reconnecting...');
setTimeout(() => this.connect(), 3000);
};
}
// 发送消息并等待确认
sendMessage(content, toUserId) {
const messageId = this.generateId();
const messageData = {
type: 'SEND_MESSAGE',
id: messageId,
content,
toUserId,
timestamp: Date.now()
};
// 先保存到本地,标记为待确认状态
this.saveToLocalStorage(messageId, content, 'sending');
// 发送消息
this.ws.send(JSON.stringify(messageData));
// 设置超时重试机制
this.setMessageTimeout(messageId);
return messageId;
}
handleMessageAck(data) {
const { messageId } = data;
// 更新本地消息状态
this.updateMessageStatus(messageId, 'sent');
// 清除超时定时器
clearTimeout(this.timeoutMap.get(messageId));
// 触发回调
const callback = this.messageCallbacks.get(messageId);
if (callback) {
callback(null, 'acked');
this.messageCallbacks.delete(messageId);
}
}
handleMessageRead(data) {
const { messageId } = data;
this.updateMessageStatus(messageId, 'read');
}
// 超时重试机制
setMessageTimeout(messageId) {
const timeoutId = setTimeout(() => {
console.log(Message ${messageId} not acked, retrying...);
this.retrySendMessage(messageId);
}, 5000); // 5秒超时
this.timeoutMap.set(messageId, timeoutId);
}
retrySendMessage(messageId) {
const message = this.getLocalMessage(messageId);
if (message && message.status === 'sending') {
const retryData = {
type: 'RETRY_MESSAGE',
...message
};
this.ws.send(JSON.stringify(retryData));
// 延长超时时间
this.setMessageTimeout(messageId);
}
}
// 重连后同步未确认的消息
resendUnconfirmedMessages() {
const unconfirmedMsgs = this.getUnconfirmedMessages();
unconfirmedMsgs.forEach(msg => {
this.retrySendMessage(msg.id);
});
}
generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
saveToLocalStorage(id, content, status) {
const message = {
id,
content,
status,
timestamp: Date.now()
};
const messages = JSON.parse(localStorage.getItem('messages') || '[]');
messages.push(message);
localStorage.setItem('messages', JSON.stringify(messages));
}
updateMessageStatus(messageId, status) {
const messages = JSON.parse(localStorage.getItem('messages') || '[]');
const index = messages.findIndex(m => m.id === messageId);
if (index !== -1) {
messages[index].status = status;
localStorage.setItem('messages', JSON.stringify(messages));
// 更新UI
this.updateUI(messageId, status);
}
}
}
这个方案看起来不错,但还是有几个坑:
- 页面刷新后WebSocket会断开,需要重新建立连接
- 多标签页情况下会有问题,不同标签页的WebSocket状态不一致
- 移动端网络切换时经常断线
存储同步和状态管理
这里我踩了不少坑,特别是多标签页的同步问题。Chrome浏览器下,如果用户开了多个聊天窗口,每个窗口都会有自己的WebSocket连接,状态就乱了。
后来用了localStorage的storage事件来解决:
class MultiTabSync {
constructor() {
this.init();
}
init() {
// 监听storage事件,同步消息状态变化
window.addEventListener('storage', (e) => {
if (e.key === 'message_sync' && e.newValue) {
const syncData = JSON.parse(e.newValue);
if (syncData.type === 'message_status_change') {
this.updateLocalMessageStatus(syncData.messageId, syncData.status);
}
}
});
}
// 同步消息状态到其他标签页
broadcastMessageStatus(messageId, status) {
const syncData = {
type: 'message_status_change',
messageId,
status,
timestamp: Date.now()
};
// 使用storage触发其他标签页的同步
localStorage.setItem('message_sync', JSON.stringify(syncData));
// 避免循环触发,延迟清除
setTimeout(() => {
localStorage.removeItem('message_sync');
}, 0);
}
updateLocalMessageStatus(messageId, status) {
// 更新当前标签页的UI状态
const messageElement = document.querySelector([data-message-id="${messageId}"]);
if (messageElement) {
this.updateMessageElementStatus(messageElement, status);
}
}
}
这个方案解决了多标签页的问题,但又遇到了新的坑:localStorage的storage事件只有在数据真的发生变化时才会触发,如果值没变就不会通知其他标签页。所以每次都要set然后再remove,确保触发事件。
网络异常处理
最头疼的是网络异常处理。移动设备环境下网络很不稳定,WiFi和4G切换时WebSocket经常断开,而且用户可能长时间离线再回来。
最后加了一个完整的重连和状态恢复机制:
class NetworkManager {
constructor(messageService) {
this.messageService = messageService;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.setupNetworkListeners();
}
setupNetworkListeners() {
// 监听网络状态变化
window.addEventListener('online', () => {
console.log('Network online, reconnecting...');
this.handleOnline();
});
window.addEventListener('offline', () => {
console.log('Network offline');
this.handleOffline();
});
// 监听页面可见性变化(移动端后台运行)
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
this.checkConnectionStatus();
}
});
}
handleOnline() {
// 网络恢复后,检查消息状态是否需要同步
setTimeout(() => {
this.syncMessageStatus();
}, 1000);
}
handleOffline() {
// 标记所有待确认消息为"离线发送"
this.messageService.markAllPendingAsOffline();
}
checkConnectionStatus() {
// 尝试ping服务器检测连接状态
fetch('https://jztheme.com/api/ping')
.then(response => response.json())
.then(() => {
// 服务器可达,检查WebSocket是否正常
if (this.messageService.ws.readyState !== WebSocket.OPEN) {
this.messageService.connect();
}
})
.catch(() => {
// 服务器不可达,等待重连
console.log('Server unreachable, waiting for reconnection...');
});
}
syncMessageStatus() {
// 同步本地未确认的消息到服务器
const pendingMessages = this.messageService.getPendingMessages();
if (pendingMessages.length > 0) {
fetch('https://jztheme.com/api/sync-pending-messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: pendingMessages.map(m => m.id) })
}).then(res => res.json())
.then(data => {
data.confirmed.forEach(msgId => {
this.messageService.updateMessageStatus(msgId, 'sent');
});
});
}
}
}
最终集成方案
把上面的各个模块整合起来,得到最终的完整方案:
class RobustMessageSystem {
constructor() {
this.messageService = new MessageService();
this.multiTabSync = new MultiTabSync();
this.networkManager = new NetworkManager(this.messageService);
this.init();
}
init() {
// 初始化消息监听
this.messageService.ws.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
switch(data.type) {
case 'MESSAGE_ACK':
this.onMessageConfirmed(data.messageId);
break;
case 'MESSAGE_READ':
this.onMessageRead(data.messageId);
break;
}
});
}
sendMessage(content, toUserId) {
const messageId = this.messageService.sendMessage(content, toUserId);
// 绑定确认回调
this.messageService.messageCallbacks.set(messageId, (error, status) => {
if (!error) {
this.multiTabSync.broadcastMessageStatus(messageId, status);
}
});
return messageId;
}
onMessageConfirmed(messageId) {
this.messageService.updateMessageStatus(messageId, 'sent');
this.multiTabSync.broadcastMessageStatus(messageId, 'sent');
}
onMessageRead(messageId) {
this.messageService.updateMessageStatus(messageId, 'read');
this.multiTabSync.broadcastMessageStatus(messageId, 'read');
}
}
// 使用
const msgSystem = new RobustMessageSystem();
// 发送消息
const msgId = msgSystem.sendMessage('Hello World!', 'user123');
这个方案基本解决了大部分消息确认的问题,包括网络断线、多标签页同步、移动端后台运行等情况。当然还有一些小问题,比如偶尔会有重复的状态更新,但这不影响整体功能。
踩坑总结
整个开发过程中最大的坑就是状态一致性问题。网络不可靠的情况下,如何保证客户端和服务端的状态同步是个复杂问题。还有就是多端同时在线时的状态管理,这些都需要仔细考虑各种边界情况。
另一个需要注意的是,WebSocket连接并不是万能的,特别是在移动环境下,还是要配合轮询或者其他备用机制,确保消息的可靠性。
以上是我踩坑后的总结,如果你有更好的消息确认实现方案欢迎评论区交流。

暂无评论