React聊天组件无限滚动定位异常问题求助
React聊天组件加载历史消息时滚动位置异常问题
我开发的React聊天组件中,消息加载功能正常,但加载历史消息时,页面会直接跳转到新加载消息的顶部(即最早的那条新加载消息)。期望加载完成后保持当前滚动位置,让用户可以向上滚动查看新加载的历史消息。
原组件代码
import React, { useState, useEffect, useRef } from "react"; import { useParams } from "react-router-dom"; import Profile from "./components/Profile"; import ChatLog from "./components/Chat"; import apiService from "../../services/api"; import HttpError from "../../httpError"; import { useLocalStorage } from "usehooks-ts"; import { backendURL, backendWsURL } from "../../../config"; import ChatPanel from "./components/ChatPanel"; const ChatRoom = (props) => { const [messageInput, setMessageInput] = useState(""); const [userName, setUserName] = useState(""); const [isReady, setIsReady] = useState(false); const [isAuthReady, setIsAuthReady] = useState(false); const [chatMessages, setChatMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const [hasNextPage, setHasNextPage] = useState(true); const [page, setPage] = useState(1); const chatLog = useRef(null); const ws = useRef(); const [isAuthenticated, saveisAuthenticated] = useLocalStorage("token", null); const [userData, setUserData] = useLocalStorage("userData", null); const [profileData, setProfileData] = useState(null); const chatLogContainerRef = useRef(null); const [activeChat, setActiveChat] = useState(""); const [showupMessage, setShowupMessage] = useState(""); const [showPanel,setShowPanel] = useState(true); useEffect(() => { if (activeChat) { const fetchData = async () => { try { const getProfile = await apiService.getChatProfile(activeChat); setProfileData(getProfile); } catch (error) { console.error("Error fetching profile data:", error); } }; fetchData(); } }, [activeChat]); useEffect(() => { if (isAuthenticated) { setUserName(userData.username); setIsReady(true); } }, [isAuthReady]); useEffect(() => { if (!isReady || !activeChat) { return; } ws.current = new WebSocket( `ws://${backendWsURL}/ws/chat_panel/chat/${activeChat}/?token=${isAuthenticated}` ); ws.current.onopen = () => { console.log("Connected to chat socket"); }; ws.current.onmessage = (event) => { setChatMessages((messages) => [...messages, JSON.parse(event.data)]); setShowupMessage(JSON.parse(event.data).content_object.text); }; ws.current.onclose = (event) => { if (event.code === 4003) { console.log("Error: You are not assigned to this customer."); } else { console.log("WebSocket connection closed."); } }; return () => { ws.current.close(); }; }, [isReady, activeChat]); const handleInputChange = (event) => { setMessageInput(event.target.value); }; const handleSubmitf = (event) => { event.preventDefault(); if (!messageInput.trim()) { return; } if ( messageInput && ws.current && ws.current.readyState === WebSocket.OPEN ) { ws.current.send( JSON.stringify({ message: `${messageInput}`, sender_username: `${userName}`, }) ); setMessageInput(""); } }; useEffect(() => { scrollToBottom(); }, [chatMessages]); const scrollToBottom = () => { if (chatLogContainerRef.current) { const { scrollHeight } = chatLogContainerRef.current; chatLogContainerRef.current.scrollTop = scrollHeight; } }; const fetchMessages = async () => { if (isLoading || !hasNextPage || !activeChat) return; setIsLoading(true); const getMessages = await apiService.fetchMessages({ roomName: activeChat, page: page, }); if (!(getMessages instanceof HttpError)) { if (getMessages.error) { console.log(getMessages.error); } else { const newMessages = getMessages.messages; setChatMessages((prevMessages) => [...newMessages, ...prevMessages]); setHasNextPage(getMessages.next_page); setPage((prevPage) => prevPage + 1); } } setIsLoading(false); }; useEffect(() => { if (isReady) { fetchMessages(); } }, [isReady, activeChat]); const handleScroll = (event) => { const { scrollTop } = event.target; if (scrollTop <= 0) { fetchMessages(); } }; const activeChatSetter = (active) => { setActiveChat(active); setShowPanel(false); }; const setMessageReaded = async () => { const readed = await apiService.readMessages(activeChat); if (!(readed instanceof HttpError)) { if (readed.error) { console.log(readed.error); } else { fetchMessages(); } } }; const toggleShowPanel = ()=>{ setShowPanel(!showPanel); } return ( <div className="flex"> <ChatPanel activeChat={activeChat} activeChatSetter={activeChatSetter} showupMessage={showupMessage} showPanel={showPanel} /> <div className={`flex-1 p:2 sm:p-6 justify-between md:flex flex-col h-[calc(100vh-55px)] md:h-screen shadow-[inset_0_0_4px_rgba(0,0,0,0.6)] ${showPanel && ("hidden")}`}> <Profile profileData={profileData} roomName={activeChat} togglePanel={()=>toggleShowPanel()} /> <div id="messages" className="flex flex-col space-y-4 p-3 h-[73%] overflow-y-auto scrollbar-thumb-blue scrollbar-thumb-rounded scrollbar-track-blue-lighter scrollbar-w-2 scrolling-touch" ref={chatLog} onScroll={handleScroll} > <ChatLog profileData={profileData} chatMessages={chatMessages} userName={userName} chatLogRef={chatLog} onChatLogClick={setMessageReaded} /> </div> <div className="border-t-2 border-gray-200 px-4 py-4 sm:mb-0"> <div className="relative flex"> <form className="message-input-form w-full" onSubmit={handleSubmitf} > <input type="text" placeholder="Write your message!" value={messageInput} onChange={handleInputChange} className="w-full focus:outline-none focus:placeholder-gray-400 text-gray-600 placeholder-gray-600 bg-gray-200 rounded-md py-3" /> <div className="absolute right-0 items-center inset-y-0 hidden sm:flex"> <button type="button" className="inline-flex items-center justify-center rounded-full h-10 w-10 transition duration-500 ease-in-out text-gray-500 hover:bg-gray-300 focus:outline-none" > <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" className="h-6 w-6 text-gray-600" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /> </svg> </button> <button type="button" className="inline-flex items-center justify-center rounded-lg px-4 py-3 transition duration-500 ease-in-out text-white bg-blue-500 hover:bg-blue-400 focus:outline-none" > <span className="font-bold">Send</span> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-6 w-6 ml-2 transform rotate-90" > <path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" /> </svg> </button> </div> </form> </div> </div> </div> </div> ); }; export default ChatRoom;
问题复现示例
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Chat Log Example</title> <style> #chatLog { height: 300px; overflow-y: scroll; border: 1px solid #ccc; } </style> </head> <body> <div id="chatLog"> <!-- ... messages ... --> </div> <button id="loadMessages">Load Messages</button> <script> const chatLog = document.getElementById("chatLog"); const loadMessagesButton = document.getElementById("loadMessages"); loadMessagesButton.addEventListener("click", () => { for (let i = 0; i < 20; i++) { const newMessages = document.createElement("div"); newMessages.innerText = "New message " + i; chatLog.prepend(newMessages); } chatLog.scrollTop = 0; }); </script> </body> </html>
解决方案
问题根源
- 每次
chatMessages更新都会触发scrollToBottom,加载历史消息时也会强制滚动到底部 - 历史消息添加到列表头部后,容器滚动高度增加,默认滚动位置被顶到新加载消息的顶部
修复步骤
1. 添加状态标记与滚动记录Ref
在组件中新增状态和Ref,区分历史消息加载场景并记录滚动状态:
const [isLoadingHistory, setIsLoadingHistory] = useState(false); const prevScrollTopRef = useRef(0); const prevScrollHeightRef = useRef(0);
2. 修改历史消息加载逻辑
更新fetchMessages函数,记录加载前的滚动状态,加载完成后计算并恢复滚动位置:
const fetchMessages = async () => { if (isLoading || !hasNextPage || !activeChat) return; setIsLoading(true); setIsLoadingHistory(true); // 记录加载前的滚动位置和容器高度 const container = chatLogContainerRef.current; prevScrollTopRef.current = container.scrollTop; prevScrollHeightRef.current = container.scrollHeight; const getMessages = await apiService.fetchMessages({ roomName: activeChat, page: page, }); if (!(getMessages instanceof HttpError)) { if (getMessages.error) { console.log(getMessages.error); } else { const newMessages = getMessages.messages; setChatMessages((prevMessages) => [...newMessages, ...prevMessages]); setHasNextPage(getMessages.next_page); setPage((prevPage) => prevPage + 1); } } setIsLoading(false); setIsLoadingHistory(false); };
3. 控制滚动到底部的触发时机
修改监听chatMessages的useEffect,仅在新消息推送时滚动到底部:
useEffect(() => { // 仅当不是加载历史消息时,才自动滚动到底部(对应WebSocket新消息) if (!isLoadingHistory) { scrollToBottom(); } }, [chatMessages, isLoadingHistory]);
4. 修复滚动位置恢复逻辑
新增useEffect监听消息更新,在历史消息加载完成后恢复滚动位置:
useEffect(() => { if (isLoadingHistory && chatLogContainerRef.current) { const container = chatLogContainerRef.current; // 计算新增内容的高度,调整滚动位置保持用户视角不变 const addedHeight = container.scrollHeight - prevScrollHeightRef.current; container.scrollTop = prevScrollTopRef.current + addedHeight; } }, [chatMessages, isLoadingHistory]);
5. 修正Ref绑定
将消息容器的ref改为正确的chatLogContainerRef:
<div id="messages" className="flex flex-col space-y-4 p-3 h-[73%] overflow-y-auto scrollbar-thumb-blue scrollbar-thumb-rounded scrollbar-track-blue-lighter scrollbar-w-2 scrolling-touch" ref={chatLogContainerRef} onScroll={handleScroll} > <ChatLog profileData={profileData} chatMessages={chatMessages} userName={userName} chatLogRef={chatLogContainerRef} onChatLogClick={setMessageReaded} /> </div>
完成以上修改后,加载历史消息时会保持用户当前的滚动位置,新加载的消息会出现在视图上方,用户可向上滚动查看。
内容的提问来源于stack exchange,提问作者M.Mawlawi
相关产品推荐
相关产品推荐

