有时候面试经常会被问到,如果页面上同时渲染10w条数据,该怎么设计呢?虽然已经有很多库已经实现,但是我们还是需要了解一下具体过程。
首先我们来看下直接渲染会出现什么问题?
因为我们知道同时渲染10w条数据,就意味着在页面上创建了10w个DOM,然后进行渲染,这就给了浏览器很大压力。内存占用高、首次加载慢、滚动卡顿(甚至页面崩溃)。
所以就出现了虚拟列表的解决方案。
定高列表实现思路
定高列表指的是每个列表项的高度是固定的。由于每个项的高度相同,计算列表中可见项的数量变得非常简单。
因为我们知道设备都有可视区域,那我们岂不是只需要渲染可视区域内的DOM,滚动的时候,通过计算滚动了多少,再计算现在应该渲染哪些DOM,这个问题不就解决了?
比如1w条数据,我的可视窗口为500px, 每一条的高度是50px,那么第一次渲染的时候,只需要在1w条数据中取出前10条展示DOM即可。
此时如果发生滚动,通过监听滚动事件,发现此时滚动了150px,也就是向上滚动了三条,这个时候可视区域就变成了 4- 13.
实现
有了上面的例子,也就有了实现思路:
- 需要知道可视区域的高度(固定,取决于我们自己设定)
- 需要知道当前可视区域渲染多少条数据(可视区域高度 / item高度 )
- 需要知道当前渲染的哪些数据(计算出起始位置索引和终点位置的索引)
- 需要知道真实列表的高度(根据数据量可以计算出真实的高度)
- 需要知道偏移量,因为当真实列表向上滚动的时候,我们需要将可视区域保持不动,否则就会跟着正式列表一起向上走了; 计算偏移量让我们要渲染的这个div始终保持在当前的区域(只要知道了起始位置索引就知道了偏移量)
先看效果
代码实现
import React, { useState, useEffect, useRef, useCallback } from 'react';
const VirtualList = ({ data, itemHeight, containerHeight = 400 }) => {
const containerRef = useRef(null); // 用于引用容器 DOM 元素,通过它来获取当前滚动位置 (scrollTop)。
const [visibleData, setVisibleData] = useState([]);// 存储当前可见的列表项。
const [totalHeight, setTotalHeight] = useState(0);// 列表的总高度,基于数据项的数量和每项的高度计算得出。
const [scrollTop, setScrollTop] = useState(0);// 当前滚动的位置,用于计算可见项的位置。
const [offsetY, setOffsetY] = useState(0);// 视口的偏移量,影响当前显示的内容的位置。
// 计算可见区域
const calculateVisibleData = useCallback(() => {
if (!containerRef.current) return;
// 计算可见项数量(多渲染2个作为缓冲区)
const visibleItemCount = Math.ceil(containerHeight / itemHeight) + 2;
// 根据 scrollTop 计算出当前显示区域的开始和结束索引。
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = startIndex + visibleItemCount;
// 截取出当前可见的数据。使用 Math.max 和 Math.min 防止越界。
const newVisibleData = data.slice(
Math.max(0, startIndex - 1),
Math.min(data.length, endIndex + 1)
);
// 计算偏移量,通过滚动位置 (scrollTop) 来确定内容的偏移位置,从而使得可见部分正确对齐。
const newOffsetY = startIndex * itemHeight;
setVisibleData(newVisibleData);
setOffsetY(newOffsetY);
setTotalHeight(data.length * itemHeight);
}, [scrollTop, data, itemHeight, containerHeight]);
// 初始化 + 滚动时重新计算
useEffect(() => {
calculateVisibleData();
}, [calculateVisibleData]);
// 滚动事件
const handleScroll = useCallback(() => {
// 使用 requestAnimationFrame 优化一下,不然会出现不连续问题
requestAnimationFrame(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
});
}, []);
return (
<div
ref={containerRef}
style={{
height: containerHeight,
overflowY: 'auto',
position: 'relative'
}}
onScroll={handleScroll}
>
{/* 占位容器,撑开滚动条 */}
<div style={{ height: totalHeight }}>
{/* 实际渲染内容的容器,通过transform定位 */}
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${offsetY}px)`
}}>
{visibleData.map((item, index) => (
<div
key={`${index}-${item.id || index}`}
style={{ height: itemHeight }}
>
{item.content || item}
</div>
))}
</div>
</div>
</div>
);
};
export default VirtualList;
//测试用例
// const data = Array.from({ length: 1000 }, (_, index) => `Item ${index + 1}`);
// const itemHeight = 50; // 每项的高度
不定高列表实现思路
起始从《不定高》这个名字,就可以知道,不定高列表的实现难点在于:高度未知。
首先,定高虚拟列表的核心是计算可见项的起始索引和偏移量,这可以通过简单的除法完成。
而不定高的情况下,每个项目的高度不固定,需要动态测量并维护每个项目的位置信息。初始化的时候,我们需要给他预设一个高度,类似于骨架屏,这样做的目的是可以让页面平滑过渡,不会出现抖动。
定高列表中维护了每一项的高度就可以计算偏移量,不定高就不能单纯的记录每一项的高度了,还要记录他的height/top/bottom,这样就可以方便元素定位和滚动计算。
有了大致思路,我们就可以实现了,再来看看步骤:
- 初始化预设高度
- 渲染页面,计算渲染项的高度,更新后续整个列表的信息
- 监听滚动,计算偏移量,使用二分法找到起始索引,计算出终点索引,找到可视的数据,进行数据渲染。
- 注册观察器,监听高度是否发生变化,触发后,再次更新该项的高度,同时更新后续所有列表。
看效果:
实现:
import React, { useState, useEffect, useRef, useCallback } from "react";
const DynamicHeightVirtualList = ({
data,
estimatedItemHeight = 50,
containerHeight = 400,
}) => {
// 标记外层DOm
const containerRef = useRef(null);
const [visibleData, setVisibleData] = useState([]);
// interface Position {
// height: number; // 实际测量高度
// top: number; // 相对于列表顶部的起始位置
// bottom: number; // 起始位置 + 高度
// }
// 记录每一项的位置信息
const [positions, setPositions] = useState([]);
// 实际列表的总高
const [totalHeight, setTotalHeight] = useState(0);
// 记录滚动的高度
const [scrollTop, setScrollTop] = useState(0);
//存储每个列表项的DOM元素引用
const itemsRef = useRef({});
const resizeObserverRef = useRef(null);
// 初始化或调整位置缓存
useEffect(() => {
const newLength = data.length;
const oldLength = positions.length;
if (newLength === oldLength) return;
let newPositions = [...positions];
// 初始化计算每一项的信息
for (let i = 0; i < data.length; i++) {
const prevBottom = i === 0 ? 0 : newPositions[i - 1]?.bottom || 0;
newPositions[i] = {
height: estimatedItemHeight,
top: prevBottom,
bottom: prevBottom + estimatedItemHeight,
};
}
// 处理数组长度变化
if (newLength > oldLength) {
for (let i = oldLength; i < newLength; i++) {
const prevBottom = i === 0 ? 0 : newPositions[i - 1]?.bottom || 0;
newPositions[i] = {
height: estimatedItemHeight,
top: prevBottom,
bottom: prevBottom + estimatedItemHeight,
};
}
} else {
newPositions = newPositions.slice(0, newLength);
}
// 重新计算所有项的位置
let currentTop = 0;
const updatedPositions = newPositions.map((pos) => {
const newPos = {
...pos,
top: currentTop,
bottom: currentTop + pos.height,
};
currentTop = newPos.bottom;
return newPos;
});
setPositions(updatedPositions);
}, [data.length, estimatedItemHeight]);
// 更新总高度
useEffect(() => {
if (positions.length > 0) {
setTotalHeight(positions[positions.length - 1].bottom);
}
}, [positions]);
// 重新更新每一项的高度
const updatePosition = (index, height) => {
setPositions((prev) => {
const newPositions = [...prev];
if (newPositions[index]?.height === height) return prev;
newPositions[index] = {
...newPositions[index],
height,
bottom: newPositions[index].top + height,
};
// 需要更新后续所有的每一项
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 newPositions;
});
};
// 二分法查询
const findStartIndex = (scrollTop) => {
let left = 0,
right = positions.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (positions[mid].bottom < scrollTop) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return left;
};
// 计算可视区域的数据
useEffect(() => {
if (!positions.length) return;
const startIndex = findStartIndex(scrollTop);
const endIndex = findStartIndex(scrollTop + containerHeight);
//多缓存两项
const bufferStart = Math.max(0, startIndex - 2);
const bufferEnd = Math.min(data.length, endIndex + 2);
// 得到可视区域的数据
setVisibleData(
data.slice(bufferStart, bufferEnd).map((item, i) => ({
...item,
index: bufferStart + i,
}))
);
}, [scrollTop, positions, data]);
// 初始化交叉观察器
useEffect(() => {
resizeObserverRef.current = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const index = parseInt(entry.target.dataset.index);
const height = entry.contentRect.height;
updatePosition(index, height);
});
});
return () => resizeObserverRef.current?.disconnect();
}, [updatePosition]);
const handleScroll = useCallback(() => {
requestAnimationFrame(() => {
setScrollTop(containerRef.current?.scrollTop || 0);
});
}, []);
return (
<div
ref={containerRef}
style={{
height: containerHeight,
overflowY: "auto",
position: "relative",
}}
onScroll={handleScroll}
>
<div style={{ height: totalHeight }}>
<div
style={{
position: "absolute",
width: "100%",
transform: `translateY(${
visibleData[0] ? positions[visibleData[0].index]?.top || 0 : 0
}px)`,
}}
>
{visibleData.map((item) => (
<div
key={item.id}
data-index={item.index}
// 当元素被挂载或卸载时,会执行这个回调
ref={(el) => {
const realIndex = item.index;
if (el) {
// 如果el存在,再判断itemsRef 是否有了该元素的引用,如果没有,则需要添加交叉观察器观察该项,
if (!itemsRef.current[realIndex]) {
itemsRef.current[realIndex] = el;
resizeObserverRef.current?.observe(el);
const height = el.getBoundingClientRect().height;
if (height !== positions[realIndex]?.height) {
updatePosition(realIndex, height);
}
}
} else {
// 如果el不存在,则说明元素被卸载,需要移除该项的交叉观察器和引用,避免内存泄漏
const oldEl = itemsRef.current[realIndex];
if (oldEl) {
resizeObserverRef.current?.unobserve(oldEl);
delete itemsRef.current[realIndex];
}
}
}}
>
{item.content}
</div>
))}
</div>
</div>
</div>
);
};
export default DynamicHeightVirtualList;
// 测试用例
// const dynamicData = Array(1000).fill().map((_, i) => ({
// id: i,
// content: <div style={{ height: 30 + Math.random() * 100 ,border:'1px #ccc solid'}} >Item {i}</div>
// }));