跳到主要内容

虚拟列表

定高子项滚动加载实现

虚拟列表组件

  • 给定容器高度
  • 每个项目的高度固定
  • 滚动加载,数据加载完回调

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 回调加载更多数据