即时通讯前端实现中,已读回执是一个看似简单但很容易出现同步偏差的功能。用户滚动会话页面时,消息是否应该立刻标记为已读?已读状态应该由前端主动上报,还是由后端根据拉取行为判定?如果ACK确认包丢失,前端状态要不要回滚?这些问题如果不在架构阶段想清楚,后续会出现消息显示已读但对方实际没有打开过的尴尬情况。在React项目中,我们可以通过明确的消息状态机、IntersectionObserver自动检测以及带超时重试的ACK确认机制来保证各方状态一致。

一、消息状态模型与ACK确认的边界
在讨论已读回执之前,需要先定义消息状态。典型的消息状态包括发送中、已发送、已送达、已读和发送失败。部分团队会把已送达和已读合并为服务端确认,但对于即时通讯系统来说,这两个状态的含义完全不同。已送达代表消息已经写入接收方客户端,已读则代表用户真正打开了会话并让消息进入可视区域。React只负责展示状态和触发上报行为,状态的权威来源应该始终放在后端。
ACK确认发生在两个阶段。第一阶段是客户端发送消息后,服务端收到并持久化,返回一个发送ACK;第二阶段是接收方上报已读回执后,服务端返回一个已读ACK,发送方据此更新消息为已读。前端需要为每个ACK设计唯一的ackId或seq,否则并发场景下无法关联请求和响应。消息状态流转可以用一个简单的JavaScript对象结构来描述。
// 消息状态枚举
const MessageStatus = {
SENDING: 'sending',
SENT: 'sent',
DELIVERED: 'delivered',
READ: 'read',
FAILED: 'failed',
};
// 单条消息结构
const message = {
id: 'msg_1710000000_001',
conversationId: 'conv_888',
senderId: 'user_A',
content: '你好,这份文档请查收',
status: MessageStatus.DELIVERED,
timestamp: 1710000000000,
ackSeq: 'ack_20240310_001',
};
在状态机中,从发送中到已发送由发送ACK驱动;从已发送到已送达由服务端下发送达回执驱动;从已送达到已读由接收方上报已读并收到ACK驱动。任意一个环节的ACK丢失都可能导致状态停滞,因此前端必须支持超时重试和幂等上报,而不能只依赖一次性请求。
二、React中基于IntersectionObserver的已读触发
已读回执的触发时机通常有两种:用户点击会话时一次性上报所有未读消息;或者消息在视口中暴露达到一定比例时自动上报。第二种方式更自然,但需要处理批量上报,否则每条消息进入视口都发一个请求,会形成已读回执风暴。IntersectionObserver非常适合这个场景,它能异步观察DOM元素与视口的交叉状态,比监听scroll事件性能更好,也不会在滚动过程中阻塞主线程。
在React函数组件中,可以封装一个useReadReceipt Hook,它维护一个未读消息ID集合,当消息节点进入视口时把ID加入集合,并通过防抖统一触发onAck回调。这里需要注意,observer只观察未绑定data-message-id的节点,避免重复观察。完整代码示例如下:
import { useEffect, useRef, useCallback } from 'react';
export function useReadReceipt(conversationId, onAck) {
const unreadIdsRef = useRef(new Set());
const timerRef = useRef(null);
const observerRef = useRef(null);
const reportRead = useCallback(() => {
const ids = Array.from(unreadIdsRef.current);
if (ids.length === 0) return;
onAck(ids);
unreadIdsRef.current.clear();
}, [onAck]);
const scheduleBatch = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
reportRead();
timerRef.current = null;
}, 300);
};
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
let hasNew = false;
for (const entry of entries) {
if (entry.isIntersecting) {
const messageId = entry.target.dataset.messageId;
if (messageId && !unreadIdsRef.current.has(messageId)) {
unreadIdsRef.current.add(messageId);
hasNew = true;
}
}
}
if (hasNew) scheduleBatch();
},
{ threshold: 0.6 }
);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
observerRef.current && observerRef.current.disconnect();
};
}, []);
const observeMessage = (node) => {
if (node && observerRef.current) {
observerRef.current.observe(node);
}
};
return { observeMessage };
}
使用该Hook时,消息列表的每条消息根节点需要设置data-message-id属性,并用回调ref绑定observeMessage。当用户快速滚动时,300毫秒的防抖可以合并多个消息,减少上报次数。已经上报的消息会从Set中移除,即使下次重新进入视口也不会重复发送,除非上报失败被重新放回集合。
三、带超时重试的ACK确认机制
已读回执上报后,前端不能立刻把发送方的消息状态改为已读。因为可能上报失败,或者服务端没有成功写入。正确的做法是:接收方本地先进入已读已上报状态,发送方等待服务端推回已读ACK后再更新UI。如果直接乐观更新,一旦ACK超时,就会出现对方没读却显示已读的尴尬。
ACK机制的核心是一个带超时的Promise封装。WebSocket不像HTTP有天然的请求响应,因此需要手动关联ackId。每次发送时生成唯一ackId,把回调注册到socket的message事件中,如果在规定时间内没有收到匹配的ackId,就触发重试或降级。示例代码如下:
function sendWithAck(socket, payload, timeout = 5000) {
return new Promise((resolve, reject) => {
const ackId = `${payload.type}_${Date.now()}_${Math.random()}`;
const timer = setTimeout(() => {
socket.removeEventListener('message', handler);
reject(new Error('ACK timeout'));
}, timeout);
const handler = (event) => {
const msg = JSON.parse(event.data);
if (msg.ackId === ackId) {
clearTimeout(timer);
socket.removeEventListener('message', handler);
resolve(msg);
}
};
socket.addEventListener('message', handler);
socket.send(JSON.stringify({ ...payload, ackId }));
});
}
重试策略建议采用指数退避,例如第一次超时后等待1秒重试,第二次等待2秒,最多重试3次。如果仍然失败,就把该批次消息ID重新放入未读集合,等待下一次触发。服务端需要对同一批消息ID做幂等处理,避免重复计数。对于离线消息,可以在重新连接后统一拉取最新的已读锚点,而不是依赖本地状态。
四、全局状态同步与Zustand实践
在React中,如果只用组件局部state管理消息状态,跨组件同步会非常痛苦。比如会话列表中的未读角标需要实时更新,聊天窗口中的消息气泡需要根据ACK回包改变状态。引入轻量状态库如Zustand可以避免Context重渲染问题。Zustand的selector可以精确订阅某个会话的消息数组,减少无关渲染。
存储结构上,建议以conversationId为键维护一个对象,值为消息数组。更新单条消息状态时,通过map只修改目标消息,保持不可变更新。下面是一个最小可用的Zustand store示例:
import { create } from 'zustand';
const useMessageStore = create((set) => ({
messagesByConversation: {},
setMessages: (conversationId, messages) => set((state) => ({
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: messages,
},
})),
updateMessageStatus: (conversationId, messageId, status) => set((state) => {
const messages = state.messagesByConversation[conversationId] || [];
return {
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: messages.map((msg) =>
msg.id === messageId ? { ...msg, status } : msg
),
},
};
}),
}));
export default useMessageStore;
收到已读ACK后,调用updateMessageStatus将对应消息状态改为read。如果当前用户在会话页,气泡右侧的状态文本会自然从已送达更新为已读。如果不在当前会话,会话列表的未读计数需要根据消息状态变化重新计算,无需额外请求。这样可以保证所有UI区域都消费同一份状态源,避免出现角标已清零但聊天窗口未同步的问题。
五、常见问题与优化建议
已读回执的准确性会被多个因素影响。离线消息在重新连接后可能已经被其他端标记为已读,此时前端需要拉取最新的已读锚点,而不是依赖本地状态。可以维护一个lastReadMessageId,用它过滤掉已经读过的消息。服务端也可以为每个会话维护一个已读水位线,减少重复读取压力。
已读回执的批量上报还要注意页面切到后台时的行为。浏览器会节流定时器,IntersectionObserver可能暂停,因此最好监听visibilitychange事件,在页面重新可见时补报一次。另一个常见问题是多标签页同时打开同一会话,每个标签页都可能上报已读,服务端要通过会话维度去重,或者使用BroadcastChannel在前端就做跨标签页协调。
优化方面,对于大型群聊,可以对已读回执做聚合,例如每500毫秒收集一次,按会话发送一个包含messageIds数组的报文。ACK响应也可以设计成合并响应,减少WebSocket帧数。同时,消息状态文本在UI上可以只显示已读或未读,不必展示送达和已发送的中间状态,这样能降低用户对状态变化延迟的感知。
总体来看,React中的已读未读回执不是简单的UI标记,而是一个前端状态机、ACK确认机制和后端幂等处理共同协作的过程。将触发时机、批量上报、超时重试和全局状态同步拆开设计,可以显著降低联调时的数据不一致问题,也能让聊天应用的交互体验更加可靠。

