-
什么是虚拟列表?
虚拟列表其实是按需显示的一种实现。监控滚动事件,截取可见区域内的内容进行渲染,对非可见区域中的数据不渲染或部分渲染,来达到性能优化的目的。 -
为什么要用虚拟列表?
假设有10000条数据需要展示到页面上,且不能分页,如果采用一次性渲染的方式,渲染时间会比较长,交互时也会有卡顿的现象出现,为了解决这个问题,可以使用
虚拟列表的方式。 -
怎么实现虚拟列表?
- 首先应该有一个用于展示高度固定的容器,高度由内容撑开,监听
scroll事件 - 存在一个占位,高度为真实列表的高度(真实高度如何计算?)
- 可视区域:内容由列表截取,
startIndex为开始截取位置,endIndex为结束位置,并存在一个位置偏移startOffset
- 首先应该有一个用于展示高度固定的容器,高度由内容撑开,监听
<div className="visual-list-container">
<div className="occupancy-list"></div>
<div className="render-list"></div>
</div>
visual-list-container为容器;occupancy-list为占位高度为总列表高度,用于形成滚动条;render-list为渲染区域,真实渲染列表。
占位列表高度以及startIndex如何计算?滚动距离:scrollTop, 项目高度:itemHeight
-
项目高度固定的情况:
- 真实高度:
listHeight= itemHeight * list.length - 显示的数量:
count= Math.ceil(height / itemHeight) - 开始索引:
startIndex= Math.floor(scrollTop / itemHeight) - 结束索引:
endIndex= startIndex + count - 偏移:
startOffset= startIndex * itemHeight - 渲染列表
renderList= list.slice(startIndex, endIndex)
- 真实高度:
-
项目高度不固定的情况(更常见): 可以先预测项目高度
estimatedHeight,并存储包含每一项height、top、bottom信息positions,然后当项目高度发生改变时进行更新,高度的变化可以通过ResizeObserver进行监控// 初始化positions positions = list.map((v, index) => ({ height: estimatedHeight, top: index * estimatedHeight, bottom: (index + 1) * estimatedHeight, }))- 真实高度:
listHeight= positions[positions.length - 1].bottom - 偏 移:
startOffset= positions[startIndex].top
- 真实高度:
具体代码如下:
VisualList.tsx
import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import VisualItem from './VisualItem.tsx';
import './visual.css';
interface IVisualListProps {
list: any[];
height: string; // 高度
estimatedHeight: number; // 预测高度
buffer: number; // 缓冲区
children: (data: any) => ReactNode;
}
// 二分查找
const binarySearch = (list, value) => {
let start = 0;
let end = list.length - 1;
let tempIndex: number | null = null;
while (start <= end) {
let midIndex = Math.floor((start + end) / 2);
let midValue = list[midIndex].bottom;
if (midValue === value) {
return midIndex + 1;
} else if (midValue < value) {
start = midIndex + 1;
} else if (midValue > value) {
if (tempIndex === null || tempIndex > midIndex) {
tempIndex = midIndex;
}
// 由于是要找到第一个比value大的,所以是end-1
end = end - 1;
}
}
return tempIndex;
};
const VisualList = ({
list,
height,
children,
estimatedHeight,
buffer = 4,
}: IVisualListProps) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [positions, setPositions] = useState<
{ height: number; top: number; bottom: number }[]
>(
list.map((v, index) => ({
height: estimatedHeight,
top: index * estimatedHeight,
bottom: (index + 1) * estimatedHeight,
}))
);
const [count, setCount] = useState(0); // 渲染的个数
const [startIndex, setStartIndex] = useState(0); // 开始索引
const formatList = useMemo(
() => list.map((v, index) => ({ ...v, __index: index })),
[list]
); // 格式化list, 设置index
// 计算需要渲染的个数
useEffect(() => {
if (containerRef.current) {
const container = containerRef.current.getBoundingClientRect();
const containerHeight = container.height;
const count = Math.ceil(containerHeight / estimatedHeight);
setCount(count);
}
}, [estimatedHeight]);
// 渲染列表
const renderList = useMemo(() => {
// 无缓冲
// const start = startIndex;
// const end = startIndex + count;
// return formatList.slice(start, end);
const start = startIndex < buffer ? 0 : startIndex - buffer;
const end = startIndex + count + buffer;
return formatList.slice(start, end);
}, [formatList, startIndex, count, buffer]);
// 滚动事件
const onScroll = (e) => {
const scrollTop = e.target.scrollTop;
const index = binarySearch(positions, scrollTop);
if (index !== null) {
setStartIndex(index);
}
};
// 更新position、startIndex
const onMeasure = (index, height) => {
const diffHeight = height - positions[index].height;
if (diffHeight) {
positions[index].height = height;
positions[index].bottom = positions[index].bottom + diffHeight;
// 更新其他的
for (let k = index + 1; k < positions.length; k++) {
positions[k].top = positions[k - 1].bottom;
positions[k].bottom = positions[k].bottom + diffHeight;
}
setPositions(positions);
}
};
let offset = 0;
if (startIndex >= 1) {
const bufferTop =
startIndex < buffer
? positions[0].top
: positions[startIndex - buffer].top;
const bufferSize = positions[startIndex].top - bufferTop;
offset = positions[startIndex].top - bufferSize; // ?bufferTop
}
return (
<div
className="visual-list-container"
ref={containerRef}
style={{ height }}
onScroll={onScroll}
>
<div
className="occupancy-list"
style={{ height: positions[positions.length - 1]?.bottom || 0 }}
></div>
<div
className="render-list"
style={{
transform: `translate3d(0, ${offset}px, 0)`,
}}
>
{renderList.map((v) => (
<VisualItem
key={v.__index}
measure={(height) => onMeasure(v.__index, height)}
>
{children(v)}
</VisualItem>
))}
</div>
</div>
);
};
export default VisualList;
.visual
VisualItem.tsx
import React, { ReactNode, useEffect, useRef } from 'react';
interface IVisualItem {
children: ReactNode,
measure: (height: number) => void
}
const VisualItem = ({ children, measure }: IVisualItem) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let observer;
if (ref.current) {
observer = new ResizeObserver(() => {
if (ref.current && ref.current.offsetHeight) {
measure(ref.current.offsetHeight);
}
});
observer.observe(ref.current);
}
return () => {
if (observer) {
observer.disconnect();
}
};
}, []);
return (
<div ref={ref} className="render-item">
{children}
</div>
);
};
export default VisualItem;
.visual-list-container {
border: 1px solid #000;
border-radius: 4px;
position: relative;
overflow: auto;
}
.occupancy-list {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: -1;
}
.render-list {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1;
}
.render-item {
border-bottom: 1px solid saddlebrown;
}
App.js
import VisualList from './components/VisualList.tsx';
import faker from 'faker';
let data = [];
for (let id = 0; id < 10000; id++) {
const item = {
id,
value: faker.lorem.paragraphs(), // 长文本
};
data.push(item);
}
function App() {
return (
<div className="App">
<VisualList
height="400px"
list={data}
estimatedHeight={100}
children={(v) => (
<div>
<span style={{ color: '#f40', fontSize: 16 }}>{v.id}</span>
{v.value}
</div>
)}
/>
</div>
);
}
export default App;
参考: