虚拟列表
定高子项滚动加载实现
虚拟列表组件
- 给定容器高度
- 每个项目的高度固定
- 滚动加载,数据加载完回调
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type ForwardedRef, type ReactElement, type ReactNode } from 'react';
interface VirtualListProps<T> {
loadMore?: () => void; // 滚动到底部时的回调函数
data: T[];
itemHeight: number;
containerHeight?: number;
overscan?: number; // 预渲染额外项目数
threshold?: number; // 距离底部的阈值,单位px
children: (item: T) => ReactNode;
}
export interface VirtualListHandle {
resetLoading: () => void;
setHasMore: (hasMore: boolean) => void;
}
const VirtualList = forwardRef(<T,>({
loadMore,
data,
itemHeight,
containerHeight = window.innerHeight,
overscan = 5,
threshold = 100,
children
}: VirtualListProps<T>, ref: ForwardedRef<VirtualListHandle>) => {
const [scrollTop, setScrollTop] = useState(0);
const [containerHeightState, setContainerHeight] = useState(containerHeight);
const containerRef = useRef<HTMLDivElement>(null);
const hasMoreRef = useRef(true); // 是否还有更多数据
const isFetchingRef = useRef(false); // 是否正在获取数据
// 响应式容器高度
useEffect(() => {
const updateHeight = () => {
if (containerRef.current) {
setContainerHeight(containerRef.current.clientHeight);
}
};
if (!containerHeight) {
updateHeight();
window.addEventListener('resize', updateHeight);
return () => window.removeEventListener('resize', updateHeight);
}
}, [containerHeight]);
// 计算可视范围内的项目
const itemsPerViewport = Math.ceil(containerHeightState / itemHeight);
// 计算起始索引时减去哨位数量
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
// 获取结束索引时加上哨位数量
const endIndex = Math.min(
data.length,
Math.ceil(scrollTop / itemHeight) + itemsPerViewport + overscan
);
const visibleItems = data.slice(startIndex, endIndex);
// 计算偏移量
const topOffset = startIndex * itemHeight;
const bottomOffset = (data.length - endIndex) * itemHeight;
// 检查是否滚动到底部并触发回调
const checkAndTriggerCallback = useCallback(() => {
// 不满足触发条件则返回
if (!loadMore || !containerRef.current || !hasMoreRef.current || isFetchingRef.current) {
return;
}
// 计算距离底部的距离
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
// 当距离底部小于阈值时触发回调
if (distanceToBottom <= threshold) {
isFetchingRef.current = true; // 标记正在获取数据
loadMore();
}
}, [loadMore, threshold]);
// 暴露方法来重置加载状态
useImperativeHandle(ref, () => ({
resetLoading: () => {
isFetchingRef.current = false;
hasMoreRef.current = true;
},
setHasMore: (hasMore: boolean) => {
hasMoreRef.current = hasMore;
}
}), []);
// 滚动处理函数
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
setScrollTop(target.scrollTop);
// 检查是否需要加载更多数据
checkAndTriggerCallback();
};
// 当数据更新时,重置加载状态
useEffect(() => {
if (data.length > 0) {
// 如果数据长度没有达到容器高度,继续尝试加载
if (data.length < itemsPerViewport + overscan * 2) {
hasMoreRef.current = true;
isFetchingRef.current = false;
// 主动触发加载更多数据
if (loadMore && hasMoreRef.current && !isFetchingRef.current) {
isFetchingRef.current = true;
loadMore();
}
} else {
// 检查是否还有更多数据
checkAndTriggerCallback();
}
}
}, [data.length, loadMore, itemsPerViewport, overscan, checkAndTriggerCallback]);
return (
<div
ref={containerRef}
style={{
height: containerHeight ? `${containerHeight}px` : '100vh',
overflowY: 'auto',
boxSizing: 'border-box',
border: '1px solid #ccc'
}}
onScroll={handleScroll}
>
{/* 顶部占位元素 */}
{topOffset > 0 && (
<div style={{ height: `${topOffset}px`, flexShrink: 0 }} />
)}
{/* 可视项目 */}
{visibleItems.map((item) =>
children(item)
)}
{/* 底部占位元素 */}
{bottomOffset > 0 && (
<div style={{ height: `${bottomOffset}px`, flexShrink: 0 }} />
)}
</div>
);
}) as <T>(props: VirtualListProps<T> & { ref?: ForwardedRef<VirtualListHandle> }) => ReactElement;;
export default VirtualList;
使用
import { useCallback, useEffect, useRef, useState } from 'react';
import VirtualList, { type VirtualListHandle } from './components/VirtualList';
const App = () => {
const [data, setData] = useState<number[]>([]);
const virtualListRef = useRef<VirtualListHandle>(null);
const initialized = useRef(false); // 添加初始化标记
const loadMore = useCallback(() => {
// 模拟加载更多数据
setTimeout(() => {
const newData = Array.from({ length: 10 }, (_, i) => data.length + i + 1);
setData(prev => [...prev, ...newData]);
// 重置加载状态
virtualListRef.current?.resetLoading();
}, 100);
}, [data.length]);
useEffect(() => {
// 在开发模式的严格模式下防止重复执行
if (initialized.current) return;
initialized.current = true;
// 初始化加载更多数据
loadMore();
}, []);
const itemHeight = 50;
useEffect(() => {
if (data.length > 60) {
virtualListRef.current?.setHasMore(false);
console.log('没有更多数据了');
}
}, [data.length]);
return (
<VirtualList<number>
ref={virtualListRef}
data={data}
itemHeight={itemHeight}
overscan={2}
threshold={50}
loadMore={loadMore}
>
{(item) => <div key={item} style={{
height: `${itemHeight}px`,
lineHeight: `${itemHeight}px`,
textAlign: 'center',
borderBottom: '1px solid #eee'
}} data-index={item}>
{item}
</div>}</VirtualList >
);
};
export default App;
不定高子项滚动加载实现思路
核心挑战
- 动态高度计算:无法预知每个子项的实际高度
- 位置计算:需要根据已知高度动态计算每个子项的位置
- 滚动位置映射:滚动位置与数据索引之间的映射关系变得复杂
解决方案设计
高度缓存机制
- 使用
Map或对象缓存已测量的子项高度 - 初始时使用预估高度,测量后更新实际高度
位置映射表
- 维护一个数组,记录每个子项的起始位置和高度
- 根据该映射表快速计算可视区域
实现代码
interface VariableVirtualListProps<T> {
loadMore?: () => void;
data: T[];
estimatedItemHeight: number; // 预估高度,用于初始渲染
overscan?: number;
threshold?: number;
children: (item: T, index: number, measureRef: (el: HTMLElement | null) => void) => ReactNode;
}
interface ItemPosition {
index: number;
start: number;
end: number;
height: number;
measured: boolean;
}
核心实现(有 bug 待修复)
const VariableVirtualList = forwardRef(<T,>({
loadMore,
data,
estimatedItemHeight = 50,
overscan = 5,
threshold = 100,
children
}: VariableVirtualListProps<T>, ref: ForwardedRef<VirtualListHandle>) => {
const [scrollTop, setScrollTop] = useState(0);
const [containerHeight, setContainerHeight] = useState(window.innerHeight);
const containerRef = useRef<HTMLDivElement>(null);
const hasMoreRef = useRef(true);
const isFetchingRef = useRef(false);
// 存储每个项目的实际位置和高度
const positionMapRef = useRef<ItemPosition[]>([]);
// 初始化位置映射表
useEffect(() => {
// 扩展位置映射表以匹配数据长度
while (positionMapRef.current.length < data.length) {
const index = positionMapRef.current.length;
const prevItem = positionMapRef.current[index - 1];
const startPosition = prevItem ? prevItem.end : 0;
positionMapRef.current.push({
index,
start: startPosition,
end: startPosition + estimatedItemHeight,
height: estimatedItemHeight,
measured: false
});
}
// 如果数据长度减少,截断位置映射表
if (positionMapRef.current.length > data.length) {
positionMapRef.current = positionMapRef.current.slice(0, data.length);
}
}, [data.length, estimatedItemHeight]);
// 测量子项真实高度
const measureHeight = useCallback((index: number, element: HTMLElement) => {
if (index >= 0 && index < data.length) {
const actualHeight = element.offsetHeight;
const itemPosition = positionMapRef.current[index];
if (itemPosition && itemPosition.height !== actualHeight) {
const heightDiff = actualHeight - itemPosition.height;
// 更新当前项目高度
itemPosition.height = actualHeight;
itemPosition.end = itemPosition.start + actualHeight;
itemPosition.measured = true;
// 更新后续项目的位置
for (let i = index + 1; i < positionMapRef.current.length; i++) {
positionMapRef.current[i].start += heightDiff;
positionMapRef.current[i].end += heightDiff;
}
// 强制重新渲染
forceUpdate();
}
}
}, [data.length]);
// 计算可视范围
const calculateVisibleRange = useCallback(() => {
if (positionMapRef.current.length === 0) {
return { startIndex: 0, endIndex: 0 };
}
// 找到起始索引(考虑overscan)
let startIndex = positionMapRef.current.length - 1;
for (let i = 0; i < positionMapRef.current.length; i++) {
if (positionMapRef.current[i].end >= scrollTop - estimatedItemHeight * overscan) {
startIndex = Math.max(0, i - overscan);
break;
}
}
// 找到结束索引(考虑overscan)
let endIndex = 0;
const scrollBottom = scrollTop + containerHeight;
for (let i = positionMapRef.current.length - 1; i >= 0; i--) {
if (positionMapRef.current[i].start <= scrollBottom + estimatedItemHeight * overscan) {
endIndex = Math.min(data.length, i + 1 + overscan);
break;
}
}
return { startIndex, endIndex };
}, [containerHeight, scrollTop, estimatedItemHeight, overscan, data.length]);
const { startIndex, endIndex } = calculateVisibleRange();
const visibleItems = data.slice(startIndex, endIndex);
// 计算顶部和底部偏移
const topOffset = startIndex > 0 ? positionMapRef.current[startIndex].start : 0;
const bottomOffset = positionMapRef.current.length > endIndex && positionMapRef.current[endIndex - 1]
? getTotalHeight() - positionMapRef.current[endIndex - 1].end
: 0;
// 获取总高度
const getTotalHeight = useCallback(() => {
if (positionMapRef.current.length === 0) return 0;
const lastItem = positionMapRef.current[positionMapRef.current.length - 1];
return lastItem.end;
}, []);
// 检查是否需要加载更多
const checkAndTriggerCallback = useCallback(() => {
if (!loadMore || !containerRef.current || !hasMoreRef.current || isFetchingRef.current) {
return;
}
const totalHeight = getTotalHeight();
const distanceToBottom = totalHeight - scrollTop - containerHeight;
if (distanceToBottom <= threshold) {
isFetchingRef.current = true;
loadMore();
}
}, [loadMore, threshold, scrollTop, containerHeight, getTotalHeight]);
// 暴露方法
useImperativeHandle(ref, () => ({
resetLoading: () => {
isFetchingRef.current = false;
hasMoreRef.current = true;
},
setHasMore: (hasMore: boolean) => {
hasMoreRef.current = hasMore;
}
}), []);
// 滚动处理
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
setScrollTop(target.scrollTop);
checkAndTriggerCallback();
};
// 强制组件更新的函数
const [, forceUpdate] = useReducer(x => x + 1, 0);
return (
<div
ref={containerRef}
style={{
height: `${containerHeight}px`,
overflowY: 'auto',
boxSizing: 'border-box',
border: '1px solid #ccc'
}}
onScroll={handleScroll}
>
{topOffset > 0 && (
<div style={{ height: `${topOffset}px`, flexShrink: 0 }} />
)}
{visibleItems.map((item, index) => {
const actualIndex = startIndex + index;
const measureRef = (el: HTMLElement | null) => {
if (el) {
measureHeight(actualIndex, el);
}
};
return (
<div key={`${actualIndex}`} ref={measureRef}>
{children(item, actualIndex, measureRef)}
</div>
);
})}
{bottomOffset > 0 && (
<div style={{ height: `${bottomOffset}px`, flexShrink: 0 }} />
)}
</div>
);
});
关键实现要点
高度测量
- 通过
ref获取 DOM 元素的实际高度 - 更新位置映射表中的高度信息
- 调整后续元素的位置
位置映射表
- 维护每个子项的起始位置、结束位置和高度
- 支持动态更新已测量的高度
- 用于快速计算可视区域
可视区域计算
- 根据滚动位置和预估高度确定可视范围
- 考虑
overscan参数以提高渲染流畅性
滚动加载检测
- 基于实际总高度和滚动位置判断是否接近底部
- 触发
loadMore回调加载更多数据