前言 #
面对大量数据渲染的时候,总逃不开的话题就是如何解决卡顿,常见的有懒加载,虚拟列表等方法,接下来的要说的就是如何实现一个虚拟列表。
虚拟列表使用的场景还是根据展示的数据量来的,平常的表格通过分页,每次只请求部分数据,从而达到性能优化,这个时候的性能瓶颈往往是数据库查询的快慢了。但是有些产品会提出一页1000条数据的需求,这种情况就需要使用到虚拟列表了,诸如此类的例子,本质上就是dom数量过多,渲染性能hold不住了。
猜想 #
虚拟列表的概念比较简单,只渲染出现在可视窗口的数据,我们可以从react-window库的 demo 中猜想一下内部的实现逻辑
import { FixedSizeList as List } from "react-window";
const Row = ({ index, style }) => <div style={style}>Row {index}</div>;
const Example = () => (
<List height={150} itemCount={1000} itemSize={35} width={300}>
{Row}
</List>
);我们试着来分析一下这些传参的作用,height决定了容器的高度,itemCount表示数据量,itemSize代表元素的大小,极有可能和高度有关系,width决定了容器的宽度。
现在来猜想一下可能存在的逻辑,有一个高度 150px 的盒子里面有 1000 个 35px 高的元素,每个 Row 接收了设置的 itemSize,有了固定高度,那么 150/35≈4,显示的区域大概会有 4-5 个元素,滚动的时候,计算展示元素的下标,获取展示的数据。
思路 #
通过猜想,对于实现有了大概的思路,外面有一个盒子,盒子有个高度,里面是需要展示的数据,每个数据也有个固定的高度,这样我们能计算出显示的大概数量,再通过滚动,当超过单个元素高度时,重新计算显示的数据。
虚拟列表 #
- 整体 dom 结构
这里使用了绝对定位和相对定位去控制元素的位置,也可以用 translate 或者 padding
<div className="container" ref={scrollRef}>
<div
className="virtual-list-wrapper"
style={{ height: mockData.length * 100 }}
>
{visibleItems.map((item, index) => (
<div
className="virtual-list-item"
key={item}
style={{
height: `${itemHeight}px`,
position: "absolute",
top: (startIndex + index) * itemHeight,
width: "100%",
display: "flex",
alignItems: "center",
color: "#fff",
justifyContent: "center",
}}
>
{item}
</div>
))}
</div>
</div>- 获取滚动高度
useEffect(() => {
const container = scrollRef.current;
const handleScroll = () => {
if (container) {
setScrollTop(container.scrollTop);
}
};
container.addEventListener("scroll", handleScroll);
return () => {
container.removeEventListener("scroll", handleScroll);
};
}, []);- 计算开始位置和起始位置
const [startIndex, setStartIndex] = useState(0); // 添加开始索引
useEffect(() => {
const startRow = Math.max(0, Math.floor(scrollTop / itemHeight));
const currentStartIndex = Math.max(0, startRow);
const endIndex = Math.min(mockData.length, currentStartIndex + 15); // + 15可以改为startRow再加上缓冲区,看具体情况
setVisibleItems(mockData.slice(currentStartIndex, endIndex));
setStartIndex(currentStartIndex);
}, [scrollTop]);外层高度的设置有些时候用的是百分比,需要额外去获取外层高度来计算可视区域的显示个数,也可以根据实际渲染的方式进行手动调整
进阶-不定高度 #
上述实现了一个元素高度固定的虚拟列表滚动,在实际开发中,常常会出现高度不固定的情况,例如文字、图片高度不一致,这个时候需要对动态高度的元素进行处理。 在实现逻辑上和固定高度是一样的,不同点在于对于元素高度的获取和处理。
- 整体 dom 结构
<div
className="container"
ref={scrollRef}
style={{ height: containerHeight, overflow: "auto" }}
>
<div
className="virtual-list-wrapper"
style={{ height: totalHeight, position: "relative" }}
>
<div style={{ transform: `translateY(${offsetY}px)` }} ref={wrapperRef}>
{visibleItems.map((item) => (
<div
key={item.index}
id={item.index}
className="virtual-list-item"
style={{
color: "#fff",
borderBottom: "1px solid #ccc",
boxSizing: "border-box",
}}
>
{item.content}
</div>
))}
</div>
</div>
</div>- 状态声明
const [visibleItems, setVisibleItems] = useState([]); // 可见数据
const [scrollTop, setScrollTop] = useState(0); // 滚动距离
const [startIndex, setStartIndex] = useState(0); // 起始坐标
const scrollRef = useRef(null); // 获取滚动距离
const wrapperRef = useRef(null); // 获取渲染元素
// 初始化数据,其中top和bottom表示上边距和下边距距离容器顶部的距离
const [positions, setPositions] = useState(
mockData.map((item, index) => {
return {
index: index,
height: defaultHeight,
top: index * defaultHeight,
bottom: (index + 1) * defaultHeight,
};
})
);- 获取滚动高度
在处理滚动高度时,同时获取开始元素的下标
const getStartIndex = useCallback(
(scrollTop) => {
let item = positions.find((pos) => pos.bottom > scrollTop);
return item ? item.index : 0;
},
[positions]
);
useEffect(() => {
const container = scrollRef.current;
const handleScroll = () => {
if (container) {
const newScrollTop = container.scrollTop;
setScrollTop(newScrollTop);
const newStartIndex = getStartIndex(newScrollTop);
setStartIndex((preIndex) => {
if (newStartIndex !== preIndex) {
return newStartIndex;
}
return preIndex;
});
}
};
if (container) {
container.addEventListener("scroll", handleScroll);
}
return () => {
if (container) {
container.removeEventListener("scroll", handleScroll);
}
};
}, [getStartIndex]);- 数据计算
const totalHeight = useMemo(() => {
return positions.length > 0 ? positions[positions.length - 1].bottom : 0;
}, [positions]);
const viewCount = useMemo(() => {
let sum = 0;
for (let i = 0; i < positions.length; i++) {
sum += positions[i].height;
if (sum >= containerHeight) {
return i + 1;
}
}
}, [positions]);
const endIndex = useMemo(() => {
return Math.min(startIndex + viewCount + bufferSize, mockData.length);
}, [startIndex, viewCount]);
const offsetY = startIndex > 0 ? positions[startIndex].top : 0;
useEffect(() => {
setVisibleItems(mockData.slice(startIndex, endIndex));
}, [endIndex, startIndex]);- 更新元素真实高度
useEffect(() => {
if (!wrapperRef.current) return;
const nodeList = wrapperRef.current?.childNodes;
if (!nodeList || nodeList.length === 0) return;
setPositions((prevPositions) => {
const newPositions = [...prevPositions];
let needUpdate = false;
nodeList.forEach((node) => {
const newHeight = node.getBoundingClientRect().height; // 使用更准确的高度获取方法
const index = Number(node.id);
if (index >= 0 && index < newPositions.length) {
const oldHeight = newPositions[index].height;
const deltaHeight = newHeight - oldHeight;
if (Math.abs(deltaHeight) > 1) {
// 添加阈值,避免微小变化导致频繁更新
needUpdate = true;
newPositions[index].height = newHeight;
// 重新计算当前项和后续项的位置
newPositions[index].top =
index > 0 ? newPositions[index - 1].bottom : 0;
newPositions[index].bottom = newPositions[index].top + newHeight;
for (let i = index + 1; i < newPositions.length; i++) {
newPositions[i].top = newPositions[i - 1].bottom;
newPositions[i].bottom =
newPositions[i].top + newPositions[i].height;
}
}
}
});
return needUpdate ? newPositions : prevPositions;
});
}, [scrollTop]);
获取显示个数、endIndex 这些比较好理解,和定高的差不多,有几个点比较难理解,需要说明一下
- 关于滚动后更新元素的真实高度,最开始都设置为默认值,在滚动过程中不断更新高度是为了获取更加精确的元素位置,而且是必要的一步
- 关于偏移量的获取,可以理解为 top,也可以理解为视口是滑动的,如果没有偏移量会出现滚动后,页面没有数据的情况,并且需要元素的真实高度,才能获取到精确的偏移量
优化 #
对于两种虚拟列表的实现都可以进行一定程度的优化
- 存在滚动过快的白屏问题,首先可以使用缓冲区,相当于提前加载一些数据,但是不能滥用,缓冲区设置过大的话和不用虚拟列表也没区别了。白屏的彻底解决还有待研究,最大的一个 case 就是滚动条直接从头拉到尾
- 不定高度查找 startIndex 可以使用算法,例如二分之类的
泛化 #
虚拟列表的应用范围不止是单列渲染,是能够扩展到多列的,主要的改动在于对 startIndex 的获取,这时候可以从行的角度开始,比如一行两个,那么根据行高计算最上面的是第几行,根据列来获取最终的 startIndex
const startRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 1);
const startIndex = Math.max(0, startRow * 2); // 这里的2就是一行两列