js
import React, { useState, useEffect, 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) => ({
id: action === 'refresh' ? `new_${page}_${i}` : startIndex + i, // 刷新时使用新ID
content: `数据项 ${startIndex + i}`
}));
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 itemHeight = 60; // 每个列表项的高度(px)
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 0 });
const containerHeight = 400; // 滚动容器可视高度
// 加载更多数据(上拉)
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]);
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); // 用新数据替换旧数据
setPage(2); // 下次加载从第二页开始
} catch (error) {
console.error('刷新数据失败:', error);
} finally {
setRefreshing(false);
}
}, [refreshing]);
// 计算可视区域
const calculateVisibleRange = useCallback(() => {
const scrollTop = containerRef.current?.scrollTop || 0;
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(startIndex + Math.ceil(containerHeight / itemHeight) + 2, dataList.length); // 多渲染2项作为缓冲
setVisibleRange({ start: startIndex, end: endIndex });
}, [dataList.length, itemHeight, 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();
}
};
// 使用节流函数优化性能,避免滚动事件触发过于频繁[10](@ref)
const throttledScroll = throttle(handleScroll, 100);
container.addEventListener('scroll', throttledScroll);
calculateVisibleRange(); // 初始计算一次
return () => container.removeEventListener('scroll', throttledScroll);
}, [loadMore, onRefresh, loading, refreshing, finished, calculateVisibleRange]);
// 渲染可见的项目
const visibleItems = dataList.slice(visibleRange.start, visibleRange.end + 1);
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: `${dataList.length * itemHeight}px`, position: 'relative' }}>
{/* 可视项目的容器,通过定位偏移到正确位置 */}
<div style={{
position: 'absolute',
top: 0,
transform: `translateY(${visibleRange.start * itemHeight}px)`,
width: '100%'
}}>
{visibleItems.map(item => (
<div key={item.id} style={{ height: `${itemHeight}px`, borderBottom: '1px solid #eee', display: 'flex', alignItems: 'center' }}>
{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;
关键点解释
1.计算可视区域,渲染可视dom
js
// 计算可视区域
const calculateVisibleRange = useCallback(() => {
const scrollTop = containerRef.current?.scrollTop || 0;
const startIndex = Math.floor(scrollTop / itemHeight);
console.log('startIndex', startIndex)
const endIndex = Math.min(startIndex + Math.ceil(containerHeight / itemHeight) + 2, dataList.length); // 多渲染2项作为缓冲
setVisibleRange({ start: startIndex, end: endIndex });
}, [dataList.length, itemHeight, containerHeight]);
js
// 渲染可见的项目
const visibleItems = dataList.slice(visibleRange.start, visibleRange.end + 1);
这个函数
是 虚拟列表 的核心计算逻辑,用来确定在当前滚动位置下
,哪些列表项需要被渲染到DOM中。(主要获取 startIndex
、endIndex
,截取数据)
js
const startIndex = Math.floor(scrollTop / itemHeight);
-
计算 第一个 可见项的索引
scrollTop / itemHeight
= 滚动距离 ÷ 每项高度 =已经滚动了多少个
完整的列表项Math.floor()
向下取整,得到第一个可见项的索引
js
const endIndex = Math.min(startIndex + Math.ceil(containerHeight / itemHeight) + 2, dataList.length);
- 计算 最后一个 需要渲染项的索引
Math.ceil(containerHeight / itemHeight)
= 容器能完整
显示多少个项目- 2 =
额外渲染2个
项目作为缓冲区
(提升滚动体验) Math.min(..., dataList.length)
= 确保不超过数据总长度
2.上拉加载更多(无限滚动)
js
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 100 &&
!loading && !finished) {
loadMore();
}
解释各个属性:
- scrollTop :已滚动的距离
- scrollHeight :内容的总高度
- clientHeight :容器的
可视高度
触发条件:
scrollHeight - scrollTop - clientHeight < 100
:距离底部小于100px- !loading :当前没有在加载
- !finished :还有更多数据可以加载
图解:
┌─────────────────┐ ← 容器顶部
│ │
│ 可视区域 │ ← clientHeight
│ │
└─────────────────┘ ← 容器底部
│ │
│ 隐藏内容 │ ← 剩余距离 < 100px 时触发加载
│ │
└─────────────────┘ ← scrollHeight 总高度
3.用 translateY
把渲染的项目"推"到正确位置
html
<div style={{
position: 'absolute',
top: 0,
transform: `translateY(${visibleRange.start * itemHeight}px)`,
width: '100%'
}}>
1. 滚动条系统
html
<div style={{ height: `${dataList.length * itemHeight}px`,
position: 'relative' }}>
- 外层容器高度 =
总数据量 × 每项高度
(比如 1000 × 60 = 60000px) - 这个高度 只是
为了撑开滚动条
,让滚动条知道"总共有这么多内容" 滚动条
本身不知道具体渲染了什么,它只看容器高度
2. 内容渲染系统
html
<div style={{ transform: `translateY(${visibleRange.start *
itemHeight}px)` }}>
- 我们只渲染可见的几个项目(比如第
100-110
项) 但这些项目默认会出现在容器的 顶部
- 用户滚动到第
100
项时,期望看到第100
项,而不是第1项
问题和解决方案
问题
: 用户滚动到中间位置
,但我们渲染的项目出现在容器顶部
解决
: 用 translateY
把渲染的项目"推"到正确位置
具体例子
用户滚动到6000px位置(第100项开始):
makefile
容器总高度: 60000px
┌─────────────────┐ ← 0px
│ │
│ 空白区域 │ ← 用户已经滚动过的区域
│ │
├─────────────────┤ ← 6000px (用户当前看到的位置)
│ 第100项 │ ← 我们渲染的内容要出现在这里
│ 第101项 │
│ ... │
└─────────────────┘
如果不用 translateY:
- 渲染的第
100-110
项会出现在容器的0px位置
用户看不到
,因为已经滚动到6000px了
用了 translateY(6000px):
- 把渲染的内容
向下移动6000px
- 正好移动到用户
当前的可视区域