虚拟列表(Virtual List)是一种前端性能优化技术,用于高效渲染大量数据列表。其核心思想是只渲染当前可视区域内的元素,而非一次性渲染所有数据项,从而大幅减少 DOM 节点数量和内存占用,提升页面性能。
实现虚拟列表
虚拟列表的原理如图所示,它维护了一个可视区,同时为了确保滚动平滑,在可视区域的上下侧还维护了两个缓冲区。
当虚拟列表向下滚动,某个元素离开可视范围,它就会加入上缓冲区域,如果上缓冲区顶部的元素超过缓冲区的长度,就会被移除。
元素高度固定
当元素高度固定时,可以根据滚动位置(scrollTop)和元素高度,方便地确定可视区的起始索引和结束索引。采用防抖函数控制计算频率,通过 useMemo 缓存计算结果减少重复渲染。
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { VirtualState, UseVirtualReturn } from '../types';
import { debounce } from 'lodash';
/**
* 虚拟列表的核心逻辑Hook
* @param data 列表数据
* @param itemHeight 列表项高度
* @param containerHeight 容器高度
* @param bufferSize 缓冲区大小
*/
export const useVirtual = (
data: any[],
itemHeight: number,
containerHeight: number,
bufferSize: number = 5,
): UseVirtualReturn => {
const containerRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<VirtualState>({
startIndex: 0,
endIndex: 0,
scrollTop: 0,
});
// 计算总高度
const totalHeight = data.length * itemHeight;
/**
* 根据滚动位置计算起始和结束索引
* @param scrollTop 滚动位置
*/
const calculateRange = useCallback(
(scrollTop: number) => {
// 计算起始索引,考虑缓冲区
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - bufferSize);
// 计算结束索引,考虑缓冲区和数据长度
const end = Math.min(data.length, Math.ceil((scrollTop + containerHeight) / itemHeight) + bufferSize);
setState({
startIndex: start,
endIndex: end,
scrollTop,
});
},
[data.length, itemHeight, containerHeight, bufferSize],
);
// 使用防抖处理滚动事件,提高性能
const debouncedCalculateRange = useMemo(() => debounce(calculateRange, 50), [calculateRange]);
// 处理滚动事件
const onScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const { scrollTop } = event.currentTarget;
debouncedCalculateRange(scrollTop);
},
[debouncedCalculateRange],
);
// 初始化时计算一次范围
useEffect(() => {
calculateRange(0);
}, [calculateRange]);
// 当数据或容器高度变化时重新计算范围
useEffect(() => {
calculateRange(state.scrollTop);
}, [data.length, containerHeight, calculateRange, state.scrollTop]);
// 获取当前需要渲染的列表项
const virtualItems = useMemo(() => {
return data.slice(state.startIndex, state.endIndex).map((item, index) => ({
...item,
index: state.startIndex + index,
offsetTop: (state.startIndex + index) * itemHeight,
}));
}, [data, state.startIndex, state.endIndex, itemHeight]);
return {
virtualItems,
totalHeight,
containerRef,
onScroll,
};
};
可视区内放置一个撑开滚动区域的容器,高度 = 列表长度 * 每个元素的高度。
在该容器内渲染列表项,每个 Item 使用绝对定位,通过 top 确定位置。
// 虚拟列表组件
const VirtualList: React.FC = () => {
// 配置参数
const ITEM_HEIGHT = 100; // 列表项高度
const CONTAINER_HEIGHT = 600; // 容器高度
const BUFFER_SIZE = 5; // 缓冲区大小
// 使用虚拟列表hook
const { virtualItems, totalHeight, containerRef, onScroll } = useVirtual(
mockData,
ITEM_HEIGHT,
CONTAINER_HEIGHT,
BUFFER_SIZE,
);
return (
<div>
<h2>虚拟列表示例</h2>
<p>总数据量: {mockData.length} 条</p>
<div
ref={containerRef}
className={styles.virtualListContainer}
style={{ height: CONTAINER_HEIGHT }}
onScroll={onScroll}
>
{/* 用于撑开滚动区域的容器 */}
<div className={styles.virtualListInner} style={{ height: totalHeight }}>
{/* 渲染可视区域的列表项 */}
{virtualItems.map(item => (
<ListItem
key={item.id}
item={item}
style={{
height: ITEM_HEIGHT,
top: item.offsetTop,
}}
/>
))}
</div>
</div>
</div>
);
};
// 列表元素
const ListItem = memo<{
item: MockItem;
style: React.CSSProperties;
}>(({ item, style }) => (
<div className={styles.virtualListItem} style={style}>
<div className={styles.title}>{item.title}</div>
<div className={styles.description}>{item.description}</div>
<div className={styles.meta}>
<span className={`${styles.status} ${styles[item.status]}`}>{item.status}</span>
<span className={styles.priority} data-priority={item.priority}>
{item.priority}
</span>
<span>{new Date(item.timestamp).toLocaleString()}</span>
</div>
</div>
));
元素高度不固定
每个元素高度不固定的时候,会有以下痛点:
- 撑开容器的高度,不能用之前的方法简单乘算。
- 不能直接根据 scrollTop 算出 startIndex 和 endIndex。
需要维护:
- 曾计算过(出现在可视区)的所有 Item 的高度和 offset(距顶部的高度,top)。
- 目前为止滚动最下方的 item 的 index。
const measuredData: MeasuredData = {
measuredDataMap: {},
LastMeasuredItemIndex: -1, // 目前为止滚动最下方的 item 的 index
};
// measuredDataMap 的结构:
{
0: {
size: 50,
offset: 0,
},
1: {
size: 30,
offset: 50,
},
// key 为 index
2: {
size: 20, // item 的高度
offset: 80 // 距离顶部的距离,等于上一个 item 的 offset + size
}
}
由于 measuredDataMap 里记录的 top 递增,可以使用二分查找,传入此时 scrollTop,找到可视区第一个元素的索引,即 startIndex。
const getStartIndex = (props: VariableSizeListProps, scrollOffset: number): number => {
const { LastMeasuredItemIndex } = measuredData;
// 处理边界情况:如果没有测量过的项,从0开始
if (LastMeasuredItemIndex < 0) {
return 0;
}
// 如果 scrollOffset 小于等于第一个元素的 offset,直接返回0
const firstOffset = getItemMetaData(props, 0).offset;
if (scrollOffset <= firstOffset) {
return 0;
}
// 二分查找
let low = 0;
let high = LastMeasuredItemIndex;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
// getItemMetaData: 传入 item 的索引,从 measuredData 中获取该 item,返回值含有 size 和 offset 属性
const currentOffset = getItemMetaData(props, mid).offset;
if (currentOffset === scrollOffset) {
return mid;
} else if (currentOffset < scrollOffset) {
if (mid === LastMeasuredItemIndex || getItemMetaData(props, mid + 1).offset > scrollOffset) {
return mid + 1;
}
low = mid + 1;
} else {
high = mid - 1;
}
}
return low;
};
const getItemMetaData = (props: VariableSizeListProps, index: number): ItemMetaData => {
const { itemSize } = props;
const { measuredDataMap, LastMeasuredItemIndex } = measuredData;
// 如果 index 大于目前维护的最大的 index
// 需要把 LastMeasuredItemIndex + 1 ~ index 这个区间的值更新到 measuredDataMap
if (index > LastMeasuredItemIndex) {
let offset = 0;
if (LastMeasuredItemIndex >= 0) {
const lastMeasuredItem = measuredDataMap[LastMeasuredItemIndex];
offset += lastMeasuredItem.offset + lastMeasuredItem.size;
}
for (let i = LastMeasuredItemIndex + 1; i <= index; i++) {
const currentItemSize = itemSize(i);
measuredDataMap[i] = { size: currentItemSize, offset };
offset += currentItemSize;
}
measuredData.LastMeasuredItemIndex = index;
}
return measuredDataMap[index];
};
有了 startIndex 后,再结合虚拟列表的高度,使用一个 while 循环就能计算出 endIndex。
const getEndIndex = (props: VariableSizeListProps, startIndex: number): number => {
// height: 虚拟列表的高度;itemCount: 虚拟列表的元素数量
const { height, itemCount } = props;
const startItem = getItemMetaData(props, startIndex);
const maxOffset = startItem.offset + height;
let offset = startItem.offset + startItem.size;
let endIndex = startIndex;
while (offset <= maxOffset && endIndex < itemCount - 1) {
endIndex++;
const currentItem = getItemMetaData(props, endIndex);
offset += currentItem.size;
}
return endIndex;
};
确定了 startIndex 和 endIndex 后,痛点2就解决了,还剩下痛点1,即外层容器的高度如何确定。
有以下两种做法:
- 遍历所有 item,计算出高度总和。
- 使用“预测”高度。
在做法二中,容器高度 = 已经出现过的元素的高度 + 未出现过的元素的高度。
我们之前维护了滚动最下方的元素的信息,取它的 size + offset,即为第一部分。
第二部分呢,给每一个 item 一个预测高度,用(虚拟列表长度 - 最下方元素的索引)* 预测高度。
const estimatedHeight = (defaultEstimatedItemSize = 50, itemCount: number): number => {
let measuredHeight = 0;
const { measuredDataMap, LastMeasuredItemIndex } = measuredData;
if (LastMeasuredItemIndex >= 0) {
const lastMeasuredItem = measuredDataMap[LastMeasuredItemIndex];
measuredHeight = lastMeasuredItem.offset + lastMeasuredItem.size;
}
const unMeasuredItemsCount = itemCount - measuredData.LastMeasuredItemIndex - 1;
const totalEstimatedHeight = measuredHeight + unMeasuredItemsCount * defaultEstimatedItemSize;
return totalEstimatedHeight;
};
不过方法二有一定缺陷,当预测高度和实际元素的高度偏差较大时,第二部分的值会有较大误差,会导致鼠标拖动滚动条的时候出现偏移现象。
完整代码如下:
import React from 'react';
import { useState, CSSProperties, ReactElement } from 'react';
import styles from './index.module.scss';
interface ItemMetaData {
size: number;
offset: number;
}
interface MeasuredData {
measuredDataMap: { [key: number]: ItemMetaData };
LastMeasuredItemIndex: number;
}
interface VariableSizeListProps {
height: number;
width: number;
itemCount: number;
itemSize: (index: number) => number;
itemEstimatedSize?: number;
className?: string;
children: React.FC<RowProps>;
}
interface RowProps {
index: number;
style: CSSProperties;
}
// 元数据
const measuredData: MeasuredData = {
measuredDataMap: {},
LastMeasuredItemIndex: -1,
};
const estimatedHeight = (defaultEstimatedItemSize = 50, itemCount: number): number => {
let measuredHeight = 0;
const { measuredDataMap, LastMeasuredItemIndex } = measuredData;
if (LastMeasuredItemIndex >= 0) {
const lastMeasuredItem = measuredDataMap[LastMeasuredItemIndex];
measuredHeight = lastMeasuredItem.offset + lastMeasuredItem.size;
}
const unMeasuredItemsCount = itemCount - measuredData.LastMeasuredItemIndex - 1;
const totalEstimatedHeight = measuredHeight + unMeasuredItemsCount * defaultEstimatedItemSize;
return totalEstimatedHeight;
};
const getItemMetaData = (props: VariableSizeListProps, index: number): ItemMetaData => {
const { itemSize } = props;
const { measuredDataMap, LastMeasuredItemIndex } = measuredData;
if (index > LastMeasuredItemIndex) {
let offset = 0;
if (LastMeasuredItemIndex >= 0) {
const lastMeasuredItem = measuredDataMap[LastMeasuredItemIndex];
offset += lastMeasuredItem.offset + lastMeasuredItem.size;
}
for (let i = LastMeasuredItemIndex + 1; i <= index; i++) {
const currentItemSize = itemSize(i);
measuredDataMap[i] = { size: currentItemSize, offset };
offset += currentItemSize;
}
measuredData.LastMeasuredItemIndex = index;
}
return measuredDataMap[index];
};
const getStartIndex = (props: VariableSizeListProps, scrollOffset: number): number => {
const { LastMeasuredItemIndex } = measuredData;
// 处理边界情况:如果没有测量过的项,从0开始
if (LastMeasuredItemIndex < 0) {
return 0;
}
// 如果scrollOffset小于等于第一个元素的offset,直接返回0
const firstOffset = getItemMetaData(props, 0).offset;
if (scrollOffset <= firstOffset) {
return 0;
}
// 二分查找
let low = 0;
let high = LastMeasuredItemIndex;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const currentOffset = getItemMetaData(props, mid).offset;
if (currentOffset === scrollOffset) {
return mid;
} else if (currentOffset < scrollOffset) {
if (mid === LastMeasuredItemIndex || getItemMetaData(props, mid + 1).offset > scrollOffset) {
return mid + 1;
}
low = mid + 1;
} else {
high = mid - 1;
}
}
return low;
};
const getEndIndex = (props: VariableSizeListProps, startIndex: number): number => {
const { height, itemCount } = props;
const startItem = getItemMetaData(props, startIndex);
const maxOffset = startItem.offset + height;
let offset = startItem.offset + startItem.size;
let endIndex = startIndex;
while (offset <= maxOffset && endIndex < itemCount - 1) {
endIndex++;
const currentItem = getItemMetaData(props, endIndex);
offset += currentItem.size;
}
return endIndex;
};
const getRangeToRender = (props: VariableSizeListProps, scrollOffset: number): [number, number, number, number] => {
const { itemCount } = props;
const startIndex = getStartIndex(props, scrollOffset);
const endIndex = getEndIndex(props, startIndex);
return [Math.max(0, startIndex - 2), Math.min(itemCount - 1, endIndex + 2), startIndex, endIndex];
};
const VariableSizeList: React.FC<VariableSizeListProps> = props => {
const { height, width, itemCount, itemEstimatedSize = 50, children: Child, className } = props;
const [scrollOffset, setScrollOffset] = useState(0);
const containerStyle: CSSProperties = {
position: 'relative',
width,
height,
overflow: 'auto',
willChange: 'transform',
};
const contentStyle: CSSProperties = {
height: estimatedHeight(itemEstimatedSize, itemCount),
width: '100%',
};
const getCurrentChildren = () => {
const [startIndex, endIndex] = getRangeToRender(props, scrollOffset);
const items: ReactElement[] = [];
for (let i = startIndex; i <= endIndex; i++) {
const item = getItemMetaData(props, i);
const itemStyle: CSSProperties = {
position: 'absolute',
height: item.size,
width: '100%',
top: item.offset,
};
items.push(<Child key={i} index={i} style={itemStyle} />);
}
return items;
};
const scrollHandle = (event: React.UIEvent<HTMLDivElement>) => {
const { scrollTop } = event.currentTarget;
setScrollOffset(scrollTop);
};
return (
<div className={`${styles.container} ${className || ''}`} style={containerStyle} onScroll={scrollHandle}>
<div style={contentStyle}>{getCurrentChildren()}</div>
</div>
);
};
const rowSizes = new Array(1000).fill(true).map(() => 25 + Math.round(Math.random() * 55));
const getItemSize = (index: number): number => rowSizes[index];
const Row = ({ index, style }: RowProps): ReactElement => {
return (
<div className={`${styles.row} ${index % 2 ? styles.odd : styles.even}`} style={style}>
Row {index}
</div>
);
};
const App: React.FC = () => {
return (
<VariableSizeList className={styles.list} height={600} width={300} itemSize={getItemSize} itemCount={1000}>
{Row}
</VariableSizeList>
);
};
export default App;
react-window
在工作中一般使用现成的第三方库,例如 react-window
,该库提供了虚拟列表相关的组件,在此留个 demo 作为记录。
以 FixedSizeList(元素高度固定的虚拟列表)为例,需传入:
- 可视区域高度/宽度
- 列表项长度
- 每个列表项的高度
实现列表 Item 组件,作为 children 传入:
<FixedSizeList
id="__virtual_list__"
height={document.body.clientHeight} // 列表可视区域的高度
itemCount={Math.ceil(list.length / 4)} // 列表数据长度
itemSize={100} // 列表行高
width={400} // 列表可视区域的宽度
>
{Item}
</FixedSizeList>
// 可以获取 index, style
const Item = ({ index, style }) => {
// 该示例中,每行渲染四个元素
const list = list.slice(index * 4, (index + 1) * 4);
return (
<div
style={{
...style,
height: 100,
display: 'grid',
gridTemplateColumns: 'repeat(4, 100px)',
}}
>
{renderItemList(list)} // 渲染四个元素
</div>
);
};