React Native Firestore实时监听导致聊天消息重复问题
React Native Firestore聊天消息重复加载问题
问题场景
基于React Native + Firebase Firestore开发聊天应用时,需要监听messages集合变更,拉取当前roomId对应房间的最新消息,出现监听器触发后重复加载最后一条消息的问题。
当前代码拆分了3个useEffect处理逻辑:
- 第一个:初始化校验房间状态,判断两个用户间是否已创建对应聊天房间
- 第二个:房间存在时初始拉取全量历史消息
- 第三个:挂载实时消息更新监听器
原始实现代码如下:
const [messages, setMessages] = useState([]); const [roomId, setRoomId] = useState(''); useEffect(() => { // 房间校验与roomId赋值逻辑 // ... if (item?.members?.includes(userDetails?.uid)) { setRoomId(item.roomId); return true; } // ... }, []); // 初始拉取全量历史消息 useEffect(() => { if (roomId) { const q = query( collection(db, 'messages'), where('roomId', '==', roomId), orderBy('createdAt', 'asc'), ); getDocs(q) .then(result => { const messages = result.docs.map(doc => { const data = doc.data(); return { id: data?.id, roomId: data?.roomId, sentBy: data?.sentBy, text: data?.text, user: data?.user, createdAt: data?.createdAt, }; }); setMessages(messages); }) .catch(error => console.log(error)); } }, [roomId]); // 实时消息监听器 useEffect(() => { const unsub = onSnapshot( query( collection(db, 'messages'), where('roomId', '==', roomId), orderBy('createdAt', 'asc'), ), snapshot => { if (snapshot.docs.length > 0) { const lastMessage = snapshot.docs[snapshot.docs.length - 1]; const data = lastMessage.data(); const newMessage: MessageType = { id: data?.id, roomId: data?.roomId, sentBy: data?.sentBy, text: data?.text, user: data?.user, createdAt: data?.createdAt, }; setMessages((prevMessages: MessageType[]) => [ ...prevMessages, newMessage, ]); } }, ); return () => unsub(); }, [roomId]);
核心矛盾
如果不在实时监听的
useEffect依赖数组中传入roomId,setRoomId异步执行后不会触发UI重渲染,roomId值始终为空字符串,监听器无法绑定到正确房间;但添加roomId作为依赖后,就会出现最后一条消息重复加载的问题。
已尝试的非最优方案
将监听器逻辑改为每次触发时全量遍历快照所有文档,重新生成完整消息列表覆盖状态,该方案功能可正常运行,但消息量级达到10万至100万条时,每次触发都全量遍历会产生严重性能问题,代码如下:
// 实时消息监听器(全量覆盖版本) useEffect(() => { const unsub = onSnapshot( query( collection(db, 'messages'), where('roomId', '==', roomId), orderBy('createdAt', 'asc'), ), snapshot => { if (snapshot.docs.length > 0) { const _messages = snapshot.docs.map(doc => { const data = doc.data(); return { id: data?.id, roomId: data?.roomId, sentBy: data?.sentBy, text: data?.text, user: data?.user, createdAt: data?.createdAt, }; }); setMessages(_messages); } }, ); return () => unsub(); }, [roomId]);
问题根因
- 当
roomId从空字符串更新为有效值时,初始拉取历史消息的useEffect和挂载实时监听器的useEffect会几乎同时触发,产生竞态 - Firestore的
onSnapshot监听器首次绑定完成时会立刻返回匹配查询条件的全量数据集,并非只返回绑定后的新增变更 - 初始拉取的
getDocs请求先返回,将最后一条历史消息写入messages状态;随后onSnapshot首次触发,返回的全量数据中包含同一条最后一条消息,逻辑直接取最后一条追加到状态末尾,造成消息重复
最优解决方案
- 删除单独用于初始拉取历史消息的
useEffect,完全通过onSnapshot处理首次数据加载和后续实时更新,从根源避免两个异步请求的竞态问题 - 不直接读取
snapshot.docs全量数据或取最后一条追加,而是通过Firestore快照自带的docChanges()方法,仅筛选type === 'added'的新增变更处理:首次绑定监听器时,所有历史消息都会被标记为added,直接写入状态即可;后续监听器触发时,仅会返回真正新增的消息,不会重复返回已加载过的历史数据 - 增加基于消息唯一id的去重兜底逻辑,应对网络重连等极端场景下的重复推送问题
- 切换房间(
roomId变化)时先清空旧消息,避免不同房间消息串扰
优化后代码如下:
const [messages, setMessages] = useState<MessageType[]>([]); const [roomId, setRoomId] = useState(''); // 房间初始化校验逻辑保持不变 useEffect(() => { // 房间校验与roomId赋值逻辑 // ... if (item?.members?.includes(userDetails?.uid)) { setRoomId(item.roomId); return true; } // ... }, []); // 合并初始加载+实时监听逻辑 useEffect(() => { // roomId无效时不绑定监听器 if (!roomId) return; // 切换房间时先清空旧房间消息 setMessages([]); const q = query( collection(db, 'messages'), where('roomId', '==', roomId), orderBy('createdAt', 'asc'), ); const unsub = onSnapshot(q, (snapshot) => { // 仅处理本次快照中新增的文档,无需遍历全量 const addedMessages = snapshot.docChanges() .filter(change => change.type === 'added') .map(change => { const data = change.doc.data(); return { id: data?.id, roomId: data?.roomId, sentBy: data?.sentBy, text: data?.text, user: data?.user, createdAt: data?.createdAt, }; }); setMessages(prev => { // 合并消息并按id去重兜底 const mergedList = [...prev, ...addedMessages]; const msgMap = new Map(); mergedList.forEach(msg => msgMap.set(msg.id, msg)); return Array.from(msgMap.values()); }); }); return () => unsub(); }, [roomId]);
方案优势
- 无竞态问题:去掉了独立的
getDocs初始请求,仅保留单一数据源,不会出现重复追加同一条消息的问题 - 性能优异:每次快照触发仅处理本次新增的消息,哪怕消息量级达到百万级,单次触发的计算量也仅和新增消息数成正比,无全量遍历开销
- 符合Hook规范:
roomId作为依赖完全符合React Hook依赖规则,不需要关闭eslint校验,也不会出现闭包引用旧值的问题 - 鲁棒性强:内置id去重兜底,网络波动、重连等极端场景下也不会出现消息重复
内容的提问来源于stack exchange,提问作者dev1ce
相关产品推荐
相关产品推荐

