You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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>

解决方案

问题根源

  1. 每次chatMessages更新都会触发scrollToBottom,加载历史消息时也会强制滚动到底部
  2. 历史消息添加到列表头部后,容器滚动高度增加,默认滚动位置被顶到新加载消息的顶部

修复步骤

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.08 11:02:03