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

反向无限滚动加载时滚动位置偏移问题排查求助

反向无限滚动加载后滚动位置偏移问题排查

问题描述

实现反向无限滚动效果,当前滚动到顶部能触发数据加载,但加载新数据后滚动位置固定在顶部,预期加载后应保持在当前查看的内容位置。

服务端返回数据格式

[{ channelIdx: 1, sender: 'wochae', msg: 'channelIdx 1, msg 71' },{ channelIdx: 1, sender: 'jujeon', msg: 'channelIdx 1, msg 72' },{ channelIdx: 1, sender: 'jaekim', msg: 'channelIdx 1, msg 73' },{ channelIdx: 1, sender: 'haryu', msg: 'channelIdx 1, msg 74' },{ channelIdx: 1, sender: 'wochae', msg: 'channelIdx 1, msg 75' }]

现有代码

App.js

import Chats2 from "./Chats2";

const App = () => {
  return (
    <div
      style={{
        width: "50%",
        height: "500px",
        margin: "auto",
        backgroundColor: "purple",
        overflow: "auto",
        
      }}
    >
      <h1>Inverse Scroll</h1>
      <Chats2 />
    </div>
  );
};

export default App;

Chats2.js

import axios from "axios";
import { useEffect, useState, useRef, useCallback } from "react";

const TOTAL_PAGES = 100;

const options = {
  threshold: 0.1,
};

const Chats = () => {
  const [loading, setLoading] = useState(true);
  const [pageNum, setPageNum] = useState(0);

  const [chats, setChats] = useState([]);

  const observerTarget = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting) {
          setPageNum((num) => num + 1)
        }
      }, options)
  
    if (observerTarget.current) {
      observer.observe(observerTarget.current)
    }

    return () => {
      if (observerTarget.current) {
        observer.unobserve(observerTarget.current)
      }
    }
    }, [observerTarget])


  const callUser = useCallback(async () => {
    console.log(pageNum);
    await axios
      .get(`http://localhost:4000/chat/messages?channelIdx=1&index=${pageNum}`)
      .then((res) => {
        const newData = Array.isArray(res.data) ? res.data : [res.data];
        setChats((prevChats) => [...prevChats, ...newData]);
        setLoading(false);
      });
  }, [pageNum]);

  useEffect(() => {
    if (pageNum <= TOTAL_PAGES) {
      setTimeout(() => {
        callUser();
      }, 500);
      setLoading(true);
    }
  }, [pageNum]);

  return (
    <>
      <div
        style={{
          display: "flex",
          flexDirection: "column-reverse",
          overflow: "auto",
          overflowAnchor: "none",
          position: "sticky",
          overflowAnchor: "none", 
        }}
      >
        {chats.map((chats, i) => {
          return (
            <div
            key={i}
            style={{
              height: "100px",
            }}
            >
              <li>{chats.sender}</li>
              <li>{chats.msg}</li>
            </div>
          );
        })}
        <div ref={observerTarget}></div>
        {loading === true && <p>loading...</p>}
      </div>
    </>
  );
};

export default Chats;

问题原因分析

  1. 数据追加方向错误:使用flex-direction: column-reverse时,数组末尾对应视觉顶部,当前代码把新数据追加到数组末尾,导致旧内容被挤到下方,滚动位置自然跳转到顶部。
  2. 滚动位置未做补偿:加载新数据后,容器总高度增加,没有计算并调整滚动偏移,无法维持原查看位置。
  3. 观察目标位置错误:IntersectionObserver的目标放在了视觉底部(数组渲染后的末尾),反向滚动时应该观察视觉顶部的元素,才能正确触发加载。
  4. 无效样式干扰:重复设置overflowAnchor: none,且position: sticky无意义,可能影响滚动行为。

修复方案

1. 调整数据更新逻辑

将新数据添加到数组开头,对应视觉顶部的新内容:

setChats((prevChats) => [...newData, ...prevChats]);

2. 记录并补偿滚动位置

添加滚动容器的ref,加载前记录当前滚动状态,加载后计算新的滚动位置:

  • 新增scrollContainerRef = useRef(null)
  • 在请求前后记录并调整滚动偏移,抵消新数据带来的高度变化

3. 调整观察目标位置

将observerTarget放在渲染列表的最前面(视觉顶部),确保滚动到顶部时能触发加载检测。

4. 清理无效样式

去掉position: sticky,合并重复的overflowAnchor设置,避免样式冲突。

修改后的完整Chats2.js代码

import axios from "axios";
import { useEffect, useState, useRef, useCallback } from "react";

const TOTAL_PAGES = 100;

const options = {
  threshold: 0.1,
};

const Chats = () => {
  const [loading, setLoading] = useState(true);
  const [pageNum, setPageNum] = useState(0);
  const [chats, setChats] = useState([]);

  const observerTarget = useRef(null);
  const scrollContainerRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting && !loading) {
          setPageNum((num) => num + 1);
        }
      }, options);
  
    if (observerTarget.current) {
      observer.observe(observerTarget.current);
    }

    return () => {
      if (observerTarget.current) {
        observer.unobserve(observerTarget.current);
      }
    };
  }, [loading]);

  const callUser = useCallback(async () => {
    if (pageNum > TOTAL_PAGES) return;
    const scrollContainer = scrollContainerRef.current;
    if (!scrollContainer) return;

    // 记录加载前的滚动状态
    const oldScrollHeight = scrollContainer.scrollHeight;
    const oldScrollTop = scrollContainer.scrollTop;

    try {
      const res = await axios.get(`http://localhost:4000/chat/messages?channelIdx=1&index=${pageNum}`);
      const newData = Array.isArray(res.data) ? res.data : [res.data];
      setChats((prevChats) => [...newData, ...prevChats]);
      
      // 等待DOM更新后调整滚动位置
      setTimeout(() => {
        const newScrollHeight = scrollContainer.scrollHeight;
        scrollContainer.scrollTop = newScrollHeight - oldScrollHeight + oldScrollTop;
      }, 0);
      setLoading(false);
    } catch (err) {
      console.error(err);
      setLoading(false);
    }
  }, [pageNum]);

  useEffect(() => {
    if (pageNum <= TOTAL_PAGES && !loading) {
      setLoading(true);
      setTimeout(() => {
        callUser();
      }, 500);
    }
  }, [pageNum, loading]);

  return (
    <>
      <div
        ref={scrollContainerRef}
        style={{
          display: "flex",
          flexDirection: "column-reverse",
          overflow: "auto",
          overflowAnchor: "none",
        }}
      >
        <div ref={observerTarget} style={{ height: "1px" }}></div>
        {chats.map((chat, i) => {
          return (
            <div
              key={i}
              style={{
                height: "100px",
              }}
            >
              <li>{chat.sender}</li>
              <li>{chat.msg}</li>
            </div>
          );
        })}
        {loading && <p>loading...</p>}
      </div>
    </>
  );
};

export default Chats;

内容的提问来源于stack exchange,提问作者jujeon

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.14 05:40:53