虚拟列表:支持“向上加载”的历史消息(Vue 3 & React 双版本)
前言
在AI聊天产品中,向上滚动加载历史消息是一个经典场景。如果直接渲染万级聊天记录,页面必卡无疑。而使用虚拟列表时,向上插入数据导致的位置偏移是最大的技术痛点。本文将分享如何实现一个支持“滚动位置锁定”和“动态高度补偿”的虚拟列表方案。
一、 核心困难点:为什么向上加载这么难?
-
滚动位置丢失:当你向数组头部插入 5 条新消息时,总高度会增加。如果不处理,浏览器会停留在原来的
scrollTop,导致用户看到的内容被“顶走”。 -
动态高度计算:聊天内容(图片、长文本)高度不一,必须在 DOM 渲染后通过
ResizeObserver实时修正。 - 索引偏移:插入数据后,原来的索引全部失效,必须依赖“累计高度数组”和二分查找重新定位。
二、 实现思路
1、第一步:搭个“戏台子”(基础结构)
我们要搭一个三层嵌套的戏台,每一层都有它的“使命”:
- 外层大管家:固定好高度,别让列表把页面撑坏了。
-
“虚胖”占位层:这是个空盒子,高度设为
totalHeight。它的唯一作用是欺骗浏览器,让滚动条以为这里有成千上万条数据,从而产生真实的滚动感。 -
舞台中心(可视区) :绝对定位。它会像电梯一样,跟着你的滚动距离通过
translateY灵活位移,永远保证自己出现在观众视线内。
2、第二步:准备核心数据
为了让“戏”不演砸,我们需要掌握这些情报:
-
预判值:
MIN_ITEM_HEIGHT(哪怕不知道多高,也得有个保底值)和BUFFER_SIZE(多渲染几行,别让用户一滑就看到白屏)。 -
雷达站:
LOAD_THRESHOLD(距离顶部还有多远时,赶紧去后台搬救兵/加载数据)。 -
记账本:用一个
Map记录每个消息的真实高度,再整一个cumulativeHeights(累计高度数组),记录每一条消息距离顶部的距离。
3、第三步:索引计算
- 找起点:用二分查找在“记账本”里搜一下,看现在的滚动位置对应哪一行的地盘。
- 定终点:起点加上你能看到的行数,再算上“缓冲区”的几位,就是这一幕的结束。
-
定位置:算出起点项对应的累计高度,把舞台一推(
offsetY),搞定!
4、第四步:时间回溯(向上加载的核心!核心!)
这是实现向上加载最难的地方:往开头塞了新胶片,怎么保证观众看到的画面不跳动?
-
做标记:触发加载前,先死死记住现在的
scrollHeight(总高)和scrollTop(进度)。 -
塞数据:把新消息“砰”地一下插到
listData的最前面。 -
神操作(高度补偿) :数据塞进去后,总高度肯定变了。这时候赶紧算一下:
新高度 - 旧高度 = 增加的高度。 -
瞬间平移:把滚动条位置强制修改为
旧进度 + 增加的高度。这套动作要在浏览器刷新前完成,用户只会觉得加载了新内容,但眼前的画面纹丝不动。
5、第五步:实时监控(高度纠正)
万一某条消息里突然蹦出一张大图,高度变了怎么办?
-
派出侦察兵:子组件自带
ResizeObserver,一旦发现自己长高了,立马报告给父组件。 - 精准打击:父组件收到报告,更新账本。如果这个变高的项在观众视线上方,还得手动把滚动条再推一推,防止内容在眼皮子底下“乱跳”。
6、终章:开幕仪式(初始化)
- 一滚到底:聊天室嘛,进场肯定得看最下面(最新消息)。
-
双重保险:调用
scrollToBottom时,先用requestAnimationFrame请浏览器配合,再加个setTimeout兜底,确保无论网络多慢,都能准确降落在列表底部。
三、 Vue 3 + TailwindCSS 实现
1. 虚拟列表组件:
<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. 子组件:
<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. 虚拟列表组件:
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. 子组件:
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, ...)的区间收敛,防止因startIndex或endIndex越界导致的渲染异常。 -
初始化时机:首次加载数据后,应调用
scrollToBottom()。为了确保渲染完成,建议采用requestAnimationFrame+setTimeout的双重保险。 -
无感加载策略:执行头部数据插入前,需快照记录当前的
scrollHeight。数据推送至渲染引擎后,通过newScrollHeight - oldScrollHeight算得 空间增量,并将其累加至当前滚动偏移量上。该补偿逻辑需在渲染刷新前完成,以实现“无感加载” -
性能瓶颈:随着
listData增加到数万条,cumulativeHeights的计算可能变慢。此时可考虑分段计算维护高度。