『问题探究』虚拟列表的实现

101 阅读7分钟

虚拟列表(Virtual List)是一种前端性能优化技术,用于高效渲染大量数据列表。其核心思想是只渲染当前可视区域内的元素,而非一次性渲染所有数据项,从而大幅减少 DOM 节点数量和内存占用,提升页面性能。

实现虚拟列表

虚拟列表的原理如图所示,它维护了一个可视区,同时为了确保滚动平滑,在可视区域的上下侧还维护了两个缓冲区。

image.png

当虚拟列表向下滚动,某个元素离开可视范围,它就会加入上缓冲区域,如果上缓冲区顶部的元素超过缓冲区的长度,就会被移除。

image.png

元素高度固定

当元素高度固定时,可以根据滚动位置(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>
));

元素高度不固定

每个元素高度不固定的时候,会有以下痛点:

  1. 撑开容器的高度,不能用之前的方法简单乘算。
  2. 不能直接根据 scrollTop 算出 startIndex 和 endIndex。

image.png

需要维护:

  • 曾计算过(出现在可视区)的所有 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,即外层容器的高度如何确定。

有以下两种做法:

  1. 遍历所有 item,计算出高度总和。
  2. 使用“预测”高度。

在做法二中,容器高度 = 已经出现过的元素的高度 + 未出现过的元素的高度。

我们之前维护了滚动最下方的元素的信息,取它的 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(元素高度固定的虚拟列表)为例,需传入:

  1. 可视区域高度/宽度
  2. 列表项长度
  3. 每个列表项的高度

实现列表 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>
  );
};

参考资料

三种虚拟列表原理与实现