虚拟列表:支持“向上加载”的历史消息(Vue 3 & React 双版本)

前言

在AI聊天产品中,向上滚动加载历史消息是一个经典场景。如果直接渲染万级聊天记录,页面必卡无疑。而使用虚拟列表时,向上插入数据导致的位置偏移是最大的技术痛点。本文将分享如何实现一个支持"滚动位置锁定"和"动态高度补偿"的虚拟列表方案。

一、 核心困难点:为什么向上加载这么难?

  1. 滚动位置丢失 :当你向数组头部插入 5 条新消息时,总高度会增加。如果不处理,浏览器会停留在原来的 scrollTop,导致用户看到的内容被"顶走"。
  2. 动态高度计算 :聊天内容(图片、长文本)高度不一,必须在 DOM 渲染后通过 ResizeObserver 实时修正。
  3. 索引偏移:插入数据后,原来的索引全部失效,必须依赖"累计高度数组"和二分查找重新定位。

二、 实现思路

1、第一步:搭个"戏台子"(基础结构)

我们要搭一个三层嵌套的戏台,每一层都有它的"使命":

  1. 外层大管家:固定好高度,别让列表把页面撑坏了。
  2. "虚胖"占位层 :这是个空盒子,高度设为 totalHeight。它的唯一作用是欺骗浏览器,让滚动条以为这里有成千上万条数据,从而产生真实的滚动感。
  3. 舞台中心(可视区) :绝对定位。它会像电梯一样,跟着你的滚动距离通过 translateY 灵活位移,永远保证自己出现在观众视线内。

2、第二步:准备核心数据

为了让"戏"不演砸,我们需要掌握这些情报:

  • 预判值MIN_ITEM_HEIGHT(哪怕不知道多高,也得有个保底值)和 BUFFER_SIZE(多渲染几行,别让用户一滑就看到白屏)。
  • 雷达站LOAD_THRESHOLD(距离顶部还有多远时,赶紧去后台搬救兵/加载数据)。
  • 记账本 :用一个 Map 记录每个消息的真实高度,再整一个 cumulativeHeights(累计高度数组),记录每一条消息距离顶部的距离。

3、第三步:索引计算

  • 找起点 :用二分查找在"记账本"里搜一下,看现在的滚动位置对应哪一行的地盘。
  • 定终点:起点加上你能看到的行数,再算上"缓冲区"的几位,就是这一幕的结束。
  • 定位置 :算出起点项对应的累计高度,把舞台一推(offsetY),搞定!

4、第四步:时间回溯(向上加载的核心!核心!)

这是实现向上加载最难的地方:往开头塞了新胶片,怎么保证观众看到的画面不跳动?

  1. 做标记 :触发加载前,先死死记住现在的 scrollHeight(总高)和 scrollTop(进度)。
  2. 塞数据 :把新消息"砰"地一下插到 listData 的最前面。
  3. 神操作(高度补偿) :数据塞进去后,总高度肯定变了。这时候赶紧算一下:新高度 - 旧高度 = 增加的高度
  4. 瞬间平移 :把滚动条位置强制修改为 旧进度 + 增加的高度。这套动作要在浏览器刷新前完成,用户只会觉得加载了新内容,但眼前的画面纹丝不动。

5、第五步:实时监控(高度纠正)

万一某条消息里突然蹦出一张大图,高度变了怎么办?

  • 派出侦察兵 :子组件自带 ResizeObserver,一旦发现自己长高了,立马报告给父组件。
  • 精准打击 :父组件收到报告,更新账本。如果这个变高的项在观众视线上方,还得手动把滚动条再推一推,防止内容在眼皮子底下"乱跳"。

6、终章:开幕仪式(初始化)

  1. 一滚到底:聊天室嘛,进场肯定得看最下面(最新消息)。
  2. 双重保险 :调用 scrollToBottom 时,先用 requestAnimationFrame 请浏览器配合,再加个 setTimeout 兜底,确保无论网络多慢,都能准确降落在列表底部。

三、 Vue 3 + TailwindCSS 实现

1. 虚拟列表组件:

vue 复制代码
<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div
      class="bg-white mt-10 rounded-xl border shadow-lg relative"
      ref="containerRef"
    >
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative overflow-anchor-none"
        @scroll="handleScroll"
      >
        <!-- 顶部加载提示 -->
        <div
          v-if="isLoading"
          class="sticky top-0 z-10 py-2 flex justify-center items-center text-sm text-gray-500"
        >
          <div class="flex items-center space-x-2">
            <span>正在加载...</span>
          </div>
        </div>

        <div :style="{ height: `${totalHeight}px` }"></div>
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <VirtualListItem
            v-for="item in visibleList"
            :key="item.id"
            :item="item"
            @update-height="handleItemHeightUpdate"
          />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watch } from 'vue';
import VirtualListItem from './listItem.vue';

const MIN_ITEM_HEIGHT = 80; //预设虚拟列表项最小高度
const BUFFER_SIZE = 5; // 缓冲区大小,用于预加载项
const LOAD_THRESHOLD = 50; // 加载消息触发距离

const virtualListRef = ref<HTMLDivElement | null>(null); // 虚拟列表容器引用
const listData = ref<any[]>([]); // 列表数据
const itemHeights = ref<Map<number, number>>(new Map()); // 列表项高度数组:存储每个项的高度
const scrollTop = ref(0); // 滚动位置:当前滚动的垂直偏移量
const isLoading = ref(false); // 是否正在加载更多数据
const isInitialized = ref(false); // 是否已初始化:用于判断是否已加载初始数据
const hasMore = ref(true); // 是否有更多数据可加载
const containerRef = ref<HTMLDivElement | null>(null);

let minId = 10000; // 模拟生成消息ID

// 计算累计高度数组,对应了每个元素在列表中的垂直位置
const cumulativeHeights = computed(() => {
  const heights: number[] = [0];
  let currentSum = 0;
  for (const item of listData.value) {
    const h = itemHeights.value.get(item.id) || MIN_ITEM_HEIGHT;
    currentSum += h;
    heights.push(currentSum);
  }
  return heights;
});

// 列表总高度:列表所有项的累计高度
const totalHeight = computed(() => {
  const len = cumulativeHeights.value.length;
  return len > 0 ? cumulativeHeights.value[len - 1] : 0;
});

// 起始索引
const startIndex = computed(() => {
  let low = 0,
    high = cumulativeHeights.value.length - 1;
  // 核心:根据二分查找法出可视区内第一个可见项的索引!!!!!
  while (low <= high) {
    const mid = Math.floor((low + high) / 2);
    if (cumulativeHeights.value[mid] < scrollTop.value) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
  return Math.max(0, low - 1 - BUFFER_SIZE);
});

// 结束索引
const endIndex = computed(() => {
  if (!virtualListRef.value) return 10;
  const t = scrollTop.value + virtualListRef.value.clientHeight; // 可视区底部在列表中的垂直位置`
  let low = 0,
    high = cumulativeHeights.value.length - 1;
  // 核心:根据二分查找法出可视区内最后一个可见项的索引!!!!!
  while (low <= high) {
    const mid = Math.floor((low + high) / 2);
    if (cumulativeHeights.value[mid] < t) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
  return Math.min(listData.value.length, low + BUFFER_SIZE);
});

// 可见列表项:根据起始索引和结束索引截取列表数据
const visibleList = computed(() => {
  return listData.value.slice(startIndex.value, endIndex.value);
});

// 偏移量:根据起始索引计算列表项的垂直偏移量
const offsetY = computed(() => {
  if (startIndex.value === 0) return 0;
  return cumulativeHeights.value[startIndex.value];
});

// mock真实数据
const generateData = (count: number) => {
  const arr = [];
  for (let i = 0; i < count; i++) {
    minId--;
    arr.push({
      id: minId,
      content: `历史消息 ${minId}`,
      timestamp: new Date().toLocaleTimeString(),
    });
  }
  return arr;
};

// 初始化数据
const initData = async () => {
  const initialData = await new Promise<any[]>(
    (resolve) => setTimeout(() => resolve(generateData(20)), 100) // 模拟异步数据加载,初始加载时加载20条数据防止数据量过少撑不起容器
  );
  listData.value = initialData.reverse();
  await nextTick(); // 等待listData渲染到DOM中
  await nextTick(); // 再次等待子组件完全渲染并计算好实际高度

  isInitialized.value = true;
  scrollToBottom(); // 滚动到底部显示最新消息
};

// 滚动到底
const scrollToBottom = () => {
  if (!virtualListRef.value) return;
  const scroll = () => {
    nextTick(() => {
      if (virtualListRef.value) {
        const scrollHeight = virtualListRef.value.scrollHeight;
        const clientHeight = virtualListRef.value.clientHeight;
        virtualListRef.value.scrollTop = scrollHeight - clientHeight;
        scrollTop.value = virtualListRef.value.scrollTop;
      }
    });
  };

  // 双重保障:先使用requestAnimationFrame等待浏览器完成一次重绘,此时 scrollHeight 和 clientHeight 已正确计算,
  // 再用setTimeout兜底确保即使 requestAnimationFrame 失效也能执行
  requestAnimationFrame(() => {
    scroll();
    // 兜底方案,确保滚动执行
    setTimeout(() => {
      scroll();
    }, 100);
  });
};

// 监听totalHeight变化,初始化时确保滚动到底部
watch(
  totalHeight,
  (newVal, oldVal) => {
    if (isInitialized.value && oldVal === 0 && newVal > 0) {
      scrollToBottom();
    }
  },
  { immediate: true }
);

// 加载新消息
const loadNewMessages = async () => {
  if (isLoading.value || !hasMore.value || !isInitialized.value) return;
  isLoading.value = true;
  try {
    await new Promise((resolve) => setTimeout(resolve, 1000));    // 模拟1秒延迟
    const newData = generateData(5); // 每次加载5条新消息
    const currentScrollHeight = virtualListRef.value?.scrollHeight || 0;    // 记录当前滚动状态,为未加载前整个列表的高度(含不可见)!!!
    const currentScrollTop = scrollTop.value;
    listData.value = [...newData, ...listData.value];    // 在顶部添加新数据
    await nextTick();    // 等待DOM更新
    // 保持滚动位置,让用户停留在原来的地方
    if (virtualListRef.value) {
      const newScrollHeight = virtualListRef.value.scrollHeight;
      const heightAdded = newScrollHeight - currentScrollHeight;
      virtualListRef.value.scrollTop = currentScrollTop + heightAdded;
      scrollTop.value = virtualListRef.value.scrollTop;
    }
    // 模拟没有更多数据的情况
    if (minId <= 9000) {
      hasMore.value = false;
    }
  } catch (error) {
    console.error('加载消息失败:', error);
  } finally {
    isLoading.value = false;
  }
};

// 处理项目高度更新
const handleItemHeightUpdate = (id: number, realHeight: number) => {
  const oldHeight = itemHeights.value.get(id) || MIN_ITEM_HEIGHT;
  const diff = realHeight - oldHeight;
  if (Math.abs(diff) < 1) return;

  itemHeights.value.set(id, realHeight);
  // 如果项目在可视区域上方,调整滚动位置
  const index = listData.value.findIndex((item) => item.id === id);
  if (index < 0) return;

  const itemTop = cumulativeHeights.value[index];
  const viewportTop = scrollTop.value;

  if (itemTop < viewportTop && virtualListRef.value) {
    virtualListRef.value.scrollTop += diff;
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

// 处理滚动事件
const handleScroll = (e: Event) => {
  const target = e.target as HTMLDivElement;
  scrollTop.value = target.scrollTop;

  // 当滚动到距离顶部LOAD_THRESHOLD像素时,加载更多消息
  if (
    scrollTop.value <= LOAD_THRESHOLD &&
    !isLoading.value &&
    hasMore.value &&
    isInitialized.value
  ) {
    loadNewMessages();
  }
};


// 初始化
onMounted(() => {
  // 计算容器高度:视口高度减去上下边距和标题区域
  if (containerRef.value) {
    const computedHeight = window.innerHeight - 200; // 等价于 calc(100vh - 200px)
    containerRef.value.style.height = `${Math.max(200, computedHeight)}px`; // 防止负数或太小
  }
  // 确保DOM完全挂载后再初始化数据
  nextTick(() => {
    initData();
  });
});
</script>

<style scoped>
.overflow-anchor-none {
  overflow-anchor: none;
}
</style>

2. 子组件:

vue 复制代码
<template>
  <div
    ref="itemRef"
    class="py-2 px-4 border-b border-gray-200"
    :class="{
      'bg-pink-200': item.id % 2 !== 0,
      'bg-green-200': item.id % 2 === 0,
    }"
    :style="{ height: item.id % 2 === 0 ? '150px' : '100px' }"
  >
    {{ item.content }}
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUpdated, onUnmounted, watch, nextTick } from 'vue';

// 定义props:接收父组件传递的item数据
const props = defineProps<{
  item: {
    id: number;
    content: string;
  };
}>();

// 定义emit:向父组件传递高度更新事件
const emit = defineEmits<{
  (e: 'update-height', id: number, height: number): void;
}>();

const itemRef = ref<HTMLDivElement | null>(null);
let resizeObserver: ResizeObserver | null = null;

// 计算并发送当前组件的高度
const sendItemHeight = () => {
  if (!itemRef.value) return;
  const realHeight = itemRef.value.offsetHeight;
  emit('update-height', props.item.id, realHeight);
};

// 监听组件挂载:首次发送高度 + 监听高度变化
onMounted(() => {
  // 首次渲染完成后发送高度
  nextTick(() => {
    sendItemHeight();
  });

  // 监听元素高度变化(适配动态内容导致的高度变化)
  if (window.ResizeObserver) {
    resizeObserver = new ResizeObserver(() => {
      sendItemHeight();
    });
    if (itemRef.value) {
      resizeObserver.observe(itemRef.value);
    }
  }
});

// 组件更新后重新发送高度(比如内容变化)
onUpdated(() => {
  nextTick(() => {
    sendItemHeight();
  });
});

// 组件卸载:清理监听
onUnmounted(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});

// 监听item变化:如果item替换,重新计算高度
watch(
  () => props.item.id,
  () => {
    nextTick(() => {
      sendItemHeight();
    });
  }
);
</script>

3. 效果图:


四、 React + TailwindCSS实现

在React中我们需要利用 useMemo 优化索引计算,并利用 useLayoutEffect 处理滚动位置,避免视觉闪烁。

1. 虚拟列表组件:

tsx 复制代码
import React, {
  useState,
  useRef,
  useEffect,
  useMemo,
  useCallback,
} from 'react';
import VirtualListItem from './VirtualListItem';

const MIN_ITEM_HEIGHT = 80; // 每个列表项的最小高度
const BUFFER_SIZE = 5; // 缓冲区大小,用于预加载
const LOAD_THRESHOLD = 30; // 触发加载的px值
const NEW_DATA_COUNT = 5; // 每次加载的新数据数量
const PRE_LOAD_OFFSET = 100; // 预加载偏移量,用于提前加载部分数据

// 列表项类型定义
interface ListItem {
  id: number; // 列表项的唯一标识符
  content: string; // 列表项的内容
  timestamp: string; // 列表项的时间戳
}

const VirtualList: React.FC = () => {
  const virtualListRef = useRef<HTMLDivElement>(null); // 虚拟列表容器引用
  const containerRef = useRef<HTMLDivElement>(null); // 列表容器引用
  const loadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // 加载定时器引用
  const initScrollAttemptsRef = useRef(0); // 初始化滚动尝试次数引用,最多10次
  const [listData, setListData] = useState<ListItem[]>([]); // 列表数据状态,初始为空数组
  const [itemHeights, setItemHeights] = useState<Map<number, number>>(
    new Map()
  ); // 列表项高度映射Map
  const [scrollTop, setScrollTop] = useState<number>(0); // 滚动位置状态,初始为0
  const [isLoading, setIsLoading] = useState<boolean>(false); // 加载状态,初始为false
  const [isInitialized, setIsInitialized] = useState<boolean>(false); // 初始化状态,初始为false
  const [hasMore, setHasMore] = useState<boolean>(true); // 是否还有更多数据状态,初始为true

  const minIdRef = useRef(10000); // 最小ID引用,初始为10000
  const isLoadingRef = useRef(false); // 正在加载状态
  const hasMoreRef = useRef(true); // 是否还有更多数据
  const isFirstInitRef = useRef(true); // 是否第一次初始化
  const scrollStateRef = useRef<{
    isManualScroll: boolean;
    lastScrollTop: number;
  }>({
    isManualScroll: false,
    lastScrollTop: 0,
  }); // 滚动状态引用

  // 同步 ref 和 state
  useEffect(() => {
    isLoadingRef.current = isLoading;
    hasMoreRef.current = hasMore;
  }, [isLoading, hasMore]);

  // 计算累计高度
  const cumulativeHeights = useMemo(() => {
    const heights: number[] = [0];
    let currentSum = 0;
    for (const item of listData) {
      const h = itemHeights.get(item.id) || MIN_ITEM_HEIGHT;
      currentSum += h;
      heights.push(currentSum);
    }
    return heights;
  }, [listData, itemHeights]);

  // 列表总高度
  const totalHeight = useMemo(() => {
    return cumulativeHeights[cumulativeHeights.length - 1] || 0;
  }, [cumulativeHeights]);

  // 起始索引
  const startIndex = useMemo(() => {
    if (!virtualListRef.current || listData.length === 0) return 0;
    let low = 0,
      high = cumulativeHeights.length - 1;
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      if (cumulativeHeights[mid] < scrollTop) {
        low = mid + 1;
      } else {
        high = mid - 1;
      }
    }

    const baseIndex = Math.max(0, low - 1);
    return Math.max(0, baseIndex - BUFFER_SIZE);
  }, [cumulativeHeights, scrollTop, listData.length]);

  // 结束索引
  const endIndex = useMemo(() => {
    if (!virtualListRef.current || listData.length === 0)
      return BUFFER_SIZE * 2;
    const clientHeight = virtualListRef.current.clientHeight;
    const t = scrollTop + clientHeight + PRE_LOAD_OFFSET;
    let low = 0,
      high = cumulativeHeights.length - 1;
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      if (cumulativeHeights[mid] < t) {
        low = mid + 1;
      } else {
        high = mid - 1;
      }
    }

    return Math.min(listData.length, low + BUFFER_SIZE);
  }, [cumulativeHeights, scrollTop, listData.length]);

  // 可见列表项
  const visibleList = useMemo(() => {
    return listData.slice(startIndex, endIndex);
  }, [listData, startIndex, endIndex]);

  // 偏移量
  const offsetY = useMemo(() => {
    return startIndex === 0 ? 0 : cumulativeHeights[startIndex];
  }, [cumulativeHeights, startIndex]);

  // 生成模拟数据
  const generateData = useCallback(
    (count: number, isInitialLoad: boolean = false) => {
      const arr: ListItem[] = [];
      for (let i = 0; i < count; i++) {
        minIdRef.current--;
        arr.push({
          id: minIdRef.current,
          content: `历史消息 ${minIdRef.current}`,
          timestamp: new Date().toLocaleTimeString(),
        });
      }
      console.log('生成数据:', arr);
      if (!isInitialLoad) {
        arr.reverse();
      }
      return arr;
    },
    []
  );

  // 滚动到底部
  const scrollToBottom = useCallback(() => {
    if (!virtualListRef.current) return;

    const scrollEl = virtualListRef.current;

    // 使用多次尝试,直到成功滚动到底部
    const attemptScroll = () => {
      requestAnimationFrame(() => {
        const scrollHeight = scrollEl.scrollHeight;
        const clientHeight = scrollEl.clientHeight;

        if (scrollHeight > clientHeight) {
          const targetScrollTop = scrollHeight - clientHeight;
          const currentScrollTop = scrollEl.scrollTop;

          // 如果还没到底部,继续滚动
          if (Math.abs(currentScrollTop - targetScrollTop) > 1) {
            scrollEl.scrollTop = targetScrollTop;
            setScrollTop(targetScrollTop);

            // 增加尝试次数
            initScrollAttemptsRef.current++;

            // 最多尝试10次,每次间隔50ms
            if (initScrollAttemptsRef.current < 10) {
              setTimeout(attemptScroll, 50);
            } else {
              console.log('初始化滚动到底部完成');
              isFirstInitRef.current = false;
            }
          } else {
            console.log('已经滚动到底部');
            isFirstInitRef.current = false;
          }
        } else {
          isFirstInitRef.current = false; // 内容高度小于容器高度,不需要滚动
        }
      });
    };

    // 重置尝试次数并开始滚动
    initScrollAttemptsRef.current = 0;
    attemptScroll();
  }, []);

  // 初始化数据
  const initData = useCallback(async () => {
    try {
      const initialData = await new Promise<ListItem[]>((resolve) =>
        setTimeout(() => resolve(generateData(20, true)), 100)
      );
      setListData(initialData);
      setIsInitialized(true);
    } catch (error) {
      console.error('初始化数据失败:', error);
    }
  }, [generateData]);

  // 核心:加载新消息
  const loadNewMessages = useCallback(async () => {
    if (isLoadingRef.current || !hasMoreRef.current || !isInitialized) return;

    isLoadingRef.current = true;
    setIsLoading(true);

    try {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      const newData = generateData(NEW_DATA_COUNT, false);

      const scrollEl = virtualListRef.current;
      if (!scrollEl) return;

      // 1. 记录加载前的滚动位置
      const beforeScrollTop = scrollEl.scrollTop;
      const beforeScrollHeight = scrollEl.scrollHeight;

      // 2. 更新数据
      setListData((prev) => [...newData, ...prev]);

      // 3. 等待DOM更新后调整滚动位置
      requestAnimationFrame(() => {
        if (scrollEl) {
          const afterScrollHeight = scrollEl.scrollHeight;
          const heightAdded = afterScrollHeight - beforeScrollHeight;

          // 关键修复:检查当前是否仍在顶部附近
          const isStillNearTop = scrollEl.scrollTop <= LOAD_THRESHOLD + 50;

          // 只有当用户没有手动滚动且仍在顶部时才调整
          if (!scrollStateRef.current.isManualScroll && isStillNearTop) {
            scrollEl.scrollTop = beforeScrollTop + heightAdded;
            setScrollTop(scrollEl.scrollTop);
          }
        }
      });

      // 模拟没有更多数据
      if (minIdRef.current <= 9000) {
        hasMoreRef.current = false;
        setHasMore(false);
      }
    } catch (error) {
      console.error('加载消息失败:', error);
    } finally {
      isLoadingRef.current = false;
      setIsLoading(false);
    }
  }, [generateData, isInitialized]);

  // 处理列表项高度更新
  const handleItemHeightUpdate = useCallback(
    (id: number, realHeight: number) => {
      setItemHeights((prev) => {
        const newHeights = new Map(prev);
        const oldHeight = newHeights.get(id) || MIN_ITEM_HEIGHT;
        const diff = realHeight - oldHeight;

        if (Math.abs(diff) < 1) return prev;

        newHeights.set(id, realHeight);

        // 自动调整滚动位置
        if (
          virtualListRef.current &&
          !isFirstInitRef.current &&
          !scrollStateRef.current.isManualScroll
        ) {
          const scrollEl = virtualListRef.current;
          const index = listData.findIndex((item) => item.id === id);

          if (index >= 0) {
            const itemTop = cumulativeHeights[index];
            const viewportTop = scrollEl.scrollTop;

            // 仅当元素在视口上方时调整
            if (itemTop < viewportTop) {
              scrollEl.scrollTop += diff;
              setScrollTop(scrollEl.scrollTop);
            }
          }
        }

        return newHeights;
      });
    },
    [listData, cumulativeHeights]
  );

  // 处理滚动事件
  const handleScroll = useCallback(
    (e: React.UIEvent<HTMLDivElement>) => {
      const target = e.target as HTMLDivElement;
      const currentScrollTop = target.scrollTop;
      setScrollTop(currentScrollTop);

      // 标记手动滚动
      scrollStateRef.current = {
        isManualScroll: true,
        lastScrollTop: currentScrollTop,
      };

      // 检查是否需要加载
      const shouldLoad = currentScrollTop <= LOAD_THRESHOLD;

      if (
        shouldLoad &&
        !isLoadingRef.current &&
        hasMoreRef.current &&
        isInitialized
      ) {
        // 清除之前的防抖计时器
        if (loadTimerRef.current) {
          clearTimeout(loadTimerRef.current);
        }

        // 防抖处理
        loadTimerRef.current = setTimeout(() => {
          if (target.scrollTop <= LOAD_THRESHOLD && !isLoadingRef.current) {
            loadNewMessages();
          }
        }, 100);
      }
    },
    [isInitialized, loadNewMessages]
  );

  // 初始化
  useEffect(() => {
    console.log('组件挂载,开始初始化');

    // 设置容器高度
    if (containerRef.current) {
      const computedHeight = window.innerHeight - 200;
      containerRef.current.style.height = `${Math.max(200, computedHeight)}px`;
    }

    initData();

    // 清理函数
    return () => {
      console.log('组件卸载,清理定时器');
      if (loadTimerRef.current) {
        clearTimeout(loadTimerRef.current);
      }
    };
  }, [initData]);

  // 监听总高度变化,在数据完全渲染后滚动到底部
  useEffect(() => {
    if (isInitialized && totalHeight > 0 && isFirstInitRef.current) {
      // 延迟一段时间确保DOM完全渲染
      const timer = setTimeout(() => {
        scrollToBottom();
      }, 300); // 增加延迟时间,确保所有列表项都已渲染并测量高度

      return () => clearTimeout(timer);
    }
  }, [isInitialized, totalHeight, scrollToBottom]);

  // 监听列表数据变化,确保在高度测量后滚动
  useEffect(() => {
    if (listData.length > 0 && isInitialized && isFirstInitRef.current) {
      console.log('列表数据更新,当前数据量:', listData.length);

      // 再给一些时间让所有列表项完成高度测量
      const timer = setTimeout(() => {
        if (isFirstInitRef.current) {
          console.log('高度测量后尝试滚动');
          scrollToBottom();
        }
      }, 500);

      return () => clearTimeout(timer);
    }
  }, [listData.length, isInitialized, scrollToBottom]);

  // 重置手动滚动标记
  useEffect(() => {
    const timer = setTimeout(() => {
      scrollStateRef.current.isManualScroll = false;
    }, 500);
    return () => clearTimeout(timer);
  }, [scrollTop]);

  return (
    <div className="h-full bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5">
      <div
        ref={containerRef}
        className="bg-white mt-10 rounded-xl border shadow-lg relative"
      >
        <div
          ref={virtualListRef}
          className="h-full overflow-auto relative"
          onScroll={handleScroll}
          style={{
            overflowAnchor: 'none',
            overscrollBehavior: 'contain',
            scrollBehavior: 'auto',
          }}
        >
          {/* 加载提示(绝对定位,不影响布局) */}
          {isLoading && (
            <div className="absolute top-0 left-0 right-0 z-10 py-2 flex justify-center items-center text-sm text-gray-500 ">
              <div className="flex items-center space-x-2">
                <div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
                <span>正在加载历史消息...</span>
              </div>
            </div>
          )}

          {/* 列表占位容器 */}
          <div
            style={{
              height: `${totalHeight}px`,
              pointerEvents: 'none',
              opacity: 0,
            }}
          ></div>

          {/* 可视区域内容 */}
          <div
            className="absolute top-0 left-0 right-0"
            style={{
              transform: `translateY(${offsetY}px)`,
              width: '100%',
            }}
          >
            {visibleList.length === 0 ? (
              <div className="py-4 text-center text-gray-400">
                {listData.length === 0
                  ? '正在初始化...'
                  : '加载更多历史消息...'}
              </div>
            ) : (
              visibleList.map((item) => (
                <VirtualListItem
                  key={item.id}
                  item={item}
                  onUpdateHeight={handleItemHeightUpdate}
                />
              ))
            )}
          </div>

          {/* 没有更多数据的提示 */}
          {!hasMore && (
            <div className="absolute bottom-0 left-0 right-0 py-2 text-center text-sm text-gray-400 bg-white border-t">
              没有更多历史消息了
            </div>
          )}
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

2. 子组件:

tsx 复制代码
import React, {
  useEffect,
  useRef,
  forwardRef,
  useImperativeHandle,
} from 'react';

export interface ListItemProps {
  item: {
    id: number;
    content: string;
    timestamp: string;
  };
  onUpdateHeight: (id: number, height: number) => void;
}

const VirtualListItem = forwardRef<HTMLDivElement, ListItemProps>(
  ({ item, onUpdateHeight }, ref) => {
    const itemRef = useRef<HTMLDivElement>(null);
    const resizeObserverRef = useRef<ResizeObserver | null>(null);

    useImperativeHandle(ref, () => {
      if (itemRef.current) {
        return itemRef.current;
      }
      // 提供一个安全的默认值
      const emptyDiv = document.createElement('div');
      return emptyDiv;
    });

    // 使用 ResizeObserver 监听尺寸变化
    useEffect(() => {
      const updateHeight = () => {
        if (itemRef.current) {
          const height = itemRef.current.offsetHeight;
          onUpdateHeight(item.id, height);
        }
      };

      // 立即执行一次初始测量
      updateHeight();

      if (!resizeObserverRef.current) {
        resizeObserverRef.current = new ResizeObserver(() => {
          // 防抖处理,避免频繁触发
          if (itemRef.current) {
            requestAnimationFrame(updateHeight);
          }
        });
      }

      if (itemRef.current && resizeObserverRef.current) {
        resizeObserverRef.current.observe(itemRef.current);
      }

      // 额外的初始延迟测量,确保样式已应用
      const timer = setTimeout(() => {
        updateHeight();
      }, 10);

      return () => {
        if (resizeObserverRef.current && itemRef.current) {
          resizeObserverRef.current.unobserve(itemRef.current);
        }
        clearTimeout(timer);
      };
    }, [item.id, onUpdateHeight]);

    // 模拟不同的内容高度
    const itemStyle: React.CSSProperties = {
      height: item.id % 2 === 0 ? '150px' : '100px',
    };

    const itemClass = `${item.id % 2 !== 0 ? 'bg-pink-200' : 'bg-green-200'}`;

    return (
      <div ref={itemRef} className={itemClass} style={itemStyle}>
        {item.id}
      </div>
    );
  }
);

VirtualListItem.displayName = 'VirtualListItem';
export default VirtualListItem;

3. 效果图:


五、 注意事项

  • 浏览器干扰 :必须设置 overflow-anchor: none。现代浏览器尝试自动调整滚动位置,这会与我们的手动补偿冲突。

  • 索引边界检查 :对切片索引执行 Math.max(0, ...)Math.min(total, ...) 的区间收敛,防止因 startIndexendIndex 越界导致的渲染异常。

  • 初始化时机 :首次加载数据后,应调用 scrollToBottom()。为了确保渲染完成,建议采用 requestAnimationFrame + setTimeout 的双重保险。

  • 无感加载策略 :执行头部数据插入前,需快照记录当前的 scrollHeight。数据推送至渲染引擎后,通过 newScrollHeight - oldScrollHeight 算得 空间增量,并将其累加至当前滚动偏移量上。该补偿逻辑需在渲染刷新前完成,以实现"无感加载"

  • 性能瓶颈 :随着 listData 增加到数万条,cumulativeHeights 的计算可能变慢。此时可考虑分段计算维护高度。


相关推荐
css趣多多2 小时前
ctx 上下文对象控制新增 / 编辑表单显示隐藏的逻辑
前端
阔皮大师2 小时前
INote轻量文本编辑器
java·javascript·python·c#
lbb 小魔仙2 小时前
【HarmonyOS实战】React Native 表单实战:自定义 useReactHookForm 高性能验证
javascript·react native·react.js
_codemonster2 小时前
Vue的三种使用方式对比
前端·javascript·vue.js
寻找奶酪的mouse2 小时前
30岁技术人对职业和生活的思考
前端·后端·年终总结
梦想很大很大2 小时前
使用 Go + Gin + Fx 构建工程化后端服务模板(gin-app 实践)
前端·后端·go
We་ct2 小时前
LeetCode 56. 合并区间:区间重叠问题的核心解法与代码解析
前端·算法·leetcode·typescript
张3蜂2 小时前
深入理解 Python 的 frozenset:为什么要有“不可变集合”?
前端·python·spring
无小道2 小时前
Qt——事件简单介绍
开发语言·前端·qt