消息确认机制在前端项目中的实践踩坑总结

一潇郡 交互 阅读 1,363
赞 5 收藏
二维码
手机扫码查看
反馈

消息确认机制踩坑记

今天搞消息确认功能又折腾了半天,说起来这个需求其实挺简单的,就是用户发消息后要确保对方收到了,然后显示一个已读的小图标。但真正做起来才发现坑不少,特别是网络不稳定的时候,各种边界情况要考虑。

消息确认机制在前端项目中的实践踩坑总结

最早的想法很简单,客户端发消息,服务端收到后返回一个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连接并不是万能的,特别是在移动环境下,还是要配合轮询或者其他备用机制,确保消息的可靠性。

以上是我踩坑后的总结,如果你有更好的消息确认实现方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论