js
import React, { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
// 模拟API,每次返回20条数据
const mockFetchData = (page, action = 'load') => {
return new Promise((resolve) => {
setTimeout(() => {
const startIndex = (page - 1) * 20 + 1;
const newData = Array.from({ length: 20 }, (_, i) => {
const id = action === 'refresh' ? `new_${page}_${i}` : startIndex + i;
// 通过重复内容制造不同高度(1-4行),以演示可变高度
const lines = (startIndex + i) % 4 + 1;
const content = `数据项 ${startIndex + i} ` + '内容 '.repeat(lines);
return { id, content };
});
resolve(newData);
}, 500);
});
};
const VirtualListWithScrollLoad = () => {
// 状态管理
const [dataList, setDataList] = useState([]); // 所有已加载的数据
const [loading, setLoading] = useState(false); // 是否正在加载(上拉)
const [refreshing, setRefreshing] = useState(false); // 是否正在下拉刷新
const [page, setPage] = useState(1); // 当前页码
const [finished, setFinished] = useState(false); // 数据是否已全部加载完毕
const containerRef = useRef(null); // 滚动容器Ref
// 虚拟列表相关参数
const estimatedItemHeight = 60; // 估算高度(用于未测量项)
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 0 });
const containerHeight = 400; // 滚动容器可视高度
const [itemHeights, setItemHeights] = useState([]); // 每项的真实高度(测量后更新)
const [prefixSums, setPrefixSums] = useState([0]); // 高度前缀和数组,prefixSums[i] = 0..i-1之和
const itemRefs = useRef(new Map()); // 保存可见项的DOM引用,按索引存
const bufferPx = 120; // 额外缓冲高度,提升滚动顺滑
// 加载更多数据(上拉)
const loadMore = useCallback(async () => {
if (loading || finished) return;
setLoading(true);
try {
const newData = await mockFetchData(page, 'load');
if (newData.length === 0) {
setFinished(true); // 没有新数据了
} else {
setDataList(prev => [...prev, ...newData]);
// 为新增项预置估算高度,待渲染后测量真实高度再修正
setItemHeights(prev => [...prev, ...Array(newData.length).fill(estimatedItemHeight)]);
setPage(prev => prev + 1);
}
} catch (error) {
console.error('加载数据失败:', error);
} finally {
setLoading(false);
}
}, [loading, finished, page]);
// 下拉刷新
const onRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
setFinished(false);
try {
const newData = await mockFetchData(1, 'refresh'); // 总是请求第一页
setDataList(newData); // 用新数据替换旧数据
setItemHeights(Array(newData.length).fill(estimatedItemHeight));
setPage(2); // 下次加载从第二页开始
setVisibleRange({ start: 0, end: Math.min(Math.ceil(containerHeight / estimatedItemHeight) + 2, newData.length) });
} catch (error) {
console.error('刷新数据失败:', error);
} finally {
setRefreshing(false);
}
}, [refreshing]);
// 根据itemHeights构建前缀和
useEffect(() => {
const sums = new Array(itemHeights.length + 1);
sums[0] = 0;
for (let i = 0; i < itemHeights.length; i++) {
sums[i + 1] = sums[i] + (itemHeights[i] || estimatedItemHeight);
}
setPrefixSums(sums);
}, [itemHeights]);
// 计算可视区域(支持可变高度)
const calculateVisibleRange = useCallback(() => {
const container = containerRef.current;
const scrollTop = container?.scrollTop ?? 0;
const totalItems = dataList.length;
const sums = prefixSums;
// 二分查找startIndex,使得prefixSums[startIndex] <= scrollTop < prefixSums[startIndex + 1]
let startIndex;
if (sums.length === totalItems + 1) {
let low = 0, high = totalItems;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (sums[mid] <= scrollTop) {
low = mid + 1;
} else {
high = mid;
}
}
startIndex = Math.max(0, low - 1);
} else {
// 前缀和未就绪时,用估算高度计算
startIndex = Math.floor(scrollTop / estimatedItemHeight);
}
// 计算endIndex,覆盖视窗高度 + 缓冲高度
const viewportBottom = scrollTop + containerHeight + bufferPx;
let endIndex = startIndex;
if (sums.length === totalItems + 1) {
while (endIndex < totalItems && sums[endIndex + 1] <= viewportBottom) {
endIndex++;
}
} else {
endIndex = Math.min(startIndex + Math.ceil(containerHeight / estimatedItemHeight) + 2, totalItems);
}
setVisibleRange({ start: startIndex, end: endIndex });
}, [dataList.length, prefixSums, containerHeight]);
// 初始化数据和监听滚动
useEffect(() => {
loadMore(); // 初始化加载第一页数据
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = () => {
calculateVisibleRange(); // 滚动时重新计算可视区域
// 检查是否滚动到底部(上拉加载)
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 100 && !loading && !finished) { // 距离底部100px触发
loadMore();
}
// 检查是否滚动到顶部并下拉(下拉刷新)
if (scrollTop < -80 && !refreshing) { // 下拉超过80px触发
onRefresh();
}
};
// 使用节流函数优化性能,避免滚动事件触发过于频繁
const throttledScroll = throttle(handleScroll, 100);
container.addEventListener('scroll', throttledScroll);
calculateVisibleRange(); // 初始计算一次
return () => container.removeEventListener('scroll', throttledScroll);
}, [loadMore, onRefresh, loading, refreshing, finished, calculateVisibleRange]);
// 当前缀和变化时,基于最新高度再算一遍可视区,避免跳动
useEffect(() => {
calculateVisibleRange();
}, [prefixSums]);
// 渲染可见的项目
const visibleItems = dataList.slice(visibleRange.start, visibleRange.end + 1);
// 渲染后测量可见项的真实高度,并写回itemHeights
useLayoutEffect(() => {
visibleItems.forEach((item, idx) => {
const index = visibleRange.start + idx;
const el = itemRefs.current.get(index);
if (el) {
const h = el.getBoundingClientRect().height;
if (h && itemHeights[index] !== h) {
setItemHeights(prev => {
const next = prev.slice();
next[index] = h;
return next;
});
}
}
});
// 依赖可见项和起始索引
}, [visibleItems, visibleRange.start]);
const totalHeight = (prefixSums[prefixSums.length - 1] ?? 0) || dataList.length * estimatedItemHeight;
return (
<div
ref={containerRef}
style={{
height: `${containerHeight}px`,
overflow: 'auto',
border: '1px solid #ccc',
position: 'relative'
}}
>
{/* 下拉刷新指示器 */}
<div style={{ textAlign: 'center', height: refreshing ? '50px' : '0', transition: 'height 0.2s' }}>
{refreshing && <div>刷新中...</div>}
</div>
{/* 虚拟列表容器,其高度撑开滚动条 */}
<div style={{ height: `${totalHeight}px`, position: 'relative' }}>
{/* 可视项目的容器,通过定位偏移到正确位置 */}
<div style={{
position: 'absolute',
top: 0,
transform: `translateY(${(prefixSums[visibleRange.start] ?? visibleRange.start * estimatedItemHeight)}px)`,
width: '100%'
}}>
{visibleItems.map((item, idx) => {
const index = visibleRange.start + idx;
return (
<div
key={item.id}
ref={el => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}
style={{ borderBottom: '1px solid #eee', padding: '8px 12px', boxSizing: 'border-box' }}
>
{item.content}
</div>
);
})}
</div>
</div>
{/* 上拉加载指示器 */}
<div style={{ textAlign: 'center', padding: '10px' }}>
{loading && <div>加载中...</div>}
{finished && <div>没有更多数据了</div>}
</div>
</div>
);
};
// 简单的节流函数
function throttle(func, delay) {
let timeoutId;
return function (...args) {
if (!timeoutId) {
timeoutId = setTimeout(() => {
func.apply(this, args);
timeoutId = null;
}, delay);
}
};
}
export default VirtualListWithScrollLoad;
难点
二分查找startIndex,使得prefixSums[startIndex] <= scrollTop < prefixSums[startIndex + 1]
js
// 二分查找startIndex,使得prefixSums[startIndex] <= scrollTop < prefixSums[startIndex + 1]
let startIndex;
if (sums.length === totalItems + 1) {
let low = 0, high = totalItems;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (sums[mid] <= scrollTop) {
low = mid + 1;
} else {
high = mid;
}
}
startIndex = Math.max(0, low - 1);
} else {
// 前缀和未就绪时,用估算高度计算
startIndex = Math.floor(scrollTop / estimatedItemHeight);
}
// 计算endIndex,覆盖视窗高度 + 缓冲高度
const viewportBottom = scrollTop + containerHeight + bufferPx;
let endIndex = startIndex;
if (sums.length === totalItems + 1) {
while (endIndex < totalItems && sums[endIndex + 1] <= viewportBottom) {
endIndex++;
}
} else {
endIndex = Math.min(startIndex + Math.ceil(containerHeight / estimatedItemHeight) + 2, totalItems);
}
这段在做什么
- 把滚动条位置
scrollTop
换算成"当前视口从第几个元素开始渲染",得到startIndex
。 - 再计算"要渲染到第几个元素才能填满视口并留一点缓冲",得到
endIndex
。 - 最终把 {
start
,end
} 存到状态,页面只渲染这段区间的元素。
关键概念(先建立直觉)
- 把列表看成一栋楼,每层楼高不一样。
prefixSums
是"到每一层为止的累计高度"(0楼=0,1楼=第0项高度累加,2楼=第0、1项高度累加...)。scrollTop
是电梯当前上升的高度
,我们要知道"电梯此刻停在哪一层
"。- 找到这一层的索引就是
startIndex
;再往下"加楼层高度"直到超过视口底部就是endIndex
1. 如何找到 startIndex
(对半查找的直观版
)
- 目标:找到最大的 i ,使得
prefixSums[i] <= scrollTop < prefixSums[i+1]
。 - 思路:因为 prefixSums 是递增的,我们可以"每次看中间那层楼"来缩小范围:
- 如果"中间那层的累计高度"还
不超过
scrollTop ,说明你在更高楼层
,去右半边找。 - 如果
超过
了,说明你在更低楼层
,去左半边找。 - 这样一半一半地排除,直到收敛到正确的楼层。
- 如果"中间那层的累计高度"还
- 代码里用
low
和high
表示"还没排除的楼层范围",循环结束后:- low 指向"
第一个累计高度大于 scrollTop 的楼层
",所以真正所在楼层是 low - 1 。 - 用
Math.max(0, low - 1)
防止出现负数索引。
- low 指向"
- 如果
prefixSums
还没准备好(比如首次渲染还没测到真实高度),就用均高近似
:startIndex = Math.floor(scrollTop / estimatedItemHeight)
,粗略猜一个开始位置。
2. 如何确定 endIndex
- 先算视口底部的物理位置:
viewportBottom = scrollTop + containerHeight + bufferPx
。 - 从
startIndex
开始往下走,累计高度不超过viewportBottom
的都算"需要渲染"(这样能填满视口,还多渲染一点缓冲,滚动更顺)。 - 如果没有前缀和(还未测量),就用"
估算高度
"近似地算需要的个数:startIndex + ceil(containerHeight/estimated) + 2
。
一个小例子
- 假设三项的真实高度是
[50, 80, 60]
,则 prefixSums =[0, 50, 130, 190]
。 - 当前 scrollTop = 120 :
- 因为
50 <= 120 < 130
,所以你"在第1项所在的楼层",startIndex = 1
。 - 视口高度
400
,加缓冲120
, viewportBottom =640
,往下累加到不超过 640
的索引就是endIndex
。
- 因为
记忆法
startIndex
:在累计高度表里找"刚好不超过视口顶部
"的那一项(对半排除法)。- endIndex :从 startIndex 开始往下累积,直到"超过视口底部 + 缓冲"为止。
- 没测量时用估算高度先近似,测完再用前缀和精确计算。