长列表的优化方案--虚拟滚动

虚拟滚动的核心思想是将可滚动区域分割为固定大小的视窗(通常是屏幕高度),只在当前视窗内渲染DOM元素。当用户滚动到新的视窗区域时,虚拟滚动会根据需要动态加载新的DOM元素,并同时卸载离开视窗的DOM元素。 说白了,虚拟滚动实现了在页面滚动过程中,只渲染在视窗内可见的DOM元素,而不是全部渲染。

下面引用了其他 blog 的虚拟滚动性能对比

  • 在不使用虚拟滚动的情况下,渲染10万个文本节点:

  • 使用虚拟滚动的情况后:

使用虚拟滚动使性能提升了 78%

实现的核心要点

  • 计算容器能承载的DOM元素容量
  • 模拟滚动高度
  • 实时计算显示的元素

承载容量

承载容量表示容器内实际渲染的DOM元素数量。这里我们考虑了缓冲区,实际数量包括了缓冲数,计算公式:承载容量 = 视窗容纳DOM元素数 + 预留的缓冲数。代码如下:

tsx 复制代码
useLayoutEffect(() => {
	// 元素高度
	ELEMENT_HEIGHT = outerHeight(itemRef.current);
	// 视窗的高度
	const containerHeight = containerRef.current?.clientHeight ?? 0;
	// 视窗容纳的元素数量
	VISIBLE_COUNT = Math.ceil(containerHeight / ELEMENT_HEIGHT);
	// 最后一个元素的索引
	setLastItem(VISIBLE_COUNT + BUFFER_SIZE);
}, []);

模拟滚动高度

由于实际渲染的DOM元素数量不足以撑起列表的滚动高度,下面给出几种模拟滚动的方案:

  1. 使用 div 在DOM元素列表的上下进行填充,在初始化和滚动时设置高度,以撑起滚动区域的高度
  2. 通过设置容器的padding属性填充高度来实现滚动效果
  3. 添加一个额外的 div 元素,并设置其 translateY 的值,同样可以实现容器的滚动效果

这里推荐第三种方式,理由是:浏览器的重排与重绘(性能优化),实现简单

tsx 复制代码
<div 
	onScroll={scroll} 
	ref={containerRef} 
	className={styles.container}>
	{/* 撑起滚动容器高度 */}
	<div 
		className={styles.sentry} 
		style={{ transform: `translateY(${scrollHeight}px)` }}>
	</div>
	{/* 实际渲染的DOM元素列表 */}
	{
		visibleList.map((item, idx) =>
			<div 
				key={item.id ?? idx} 
				style={{transform: `translateY(${item.scrollY}px)`}} 
				className={styles.wrapItem}>
				<Item ref={itemRef} item={item} />
			</div>
		)
	}
</div>

list 发生时,更新 scrollHeight 的值:

tsx 复制代码
useLayoutEffect(() => {
	// list变化时,更新scrollHeight的值
	setScrollHeight(list.length * ELEMENT_HEIGHT);
}, [list]);

实时计算显示的元素

正确显示当前已滚动高度所对应的元素是整个虚拟滚动技术的核心 。在固定高度的情况下,其实在加载 list 的时候,已经确定每个元素的位置,只要像刚才使用额外的 div 元素模拟高度一样,设置 translateY 的值就可以了。同时设置一个 visibleList 变量来过滤需要渲染的DOM元素,这个变量依赖上方所提到的 firstItemlastItem 以及 list

tsx 复制代码
useLayoutEffect(() => {
	// 设置元素的滚动高度
	list.forEach((item, idx) => {
		item.scrollY = idx * ELEMENT_HEIGHT;
	});
	setVisibleList(list.slice(firstItem, lastItem));
}, [firstItem, lastItem, list]);

最后,我们需要处理滚动事件,计算出哪些数据是需要被展示的。由于列表的渲染依赖 visibleList 变量,而 visbleList 依赖于 firstItemlastItem,而 lastItem 又依赖于 firstItem,所以滚动仅需要计算 firstItem。下面是滚动事件的代码逻辑:

tsx 复制代码
/** 
  * 这里我们用到了锚点对象,有两个属性index和offset
  * 
  * index 指向的是第一个可视元素的index
  * offset表示偏移量,容器的 scrollTop 和 第一个可视元素距顶高度差
  * 
  * 使用锚点解决2个方面的问题:
  * 1. visibleList 的频繁更新
  * 2. 列表上方的缓冲区判定,这是因为滚动到顶部的时候是没有缓冲区的
*/
const updateAnchorItem = useCallback((container) => {
	const index = Math.floor(container.scrollTop / ELEMENT_HEIGHT);
	const offset = container.scrollTop - ELEMENT_HEIGHT * index;
	anchorItem.current = {
		index,
		offset,
	};
}, []);

const scroll = useCallback((event) => {
	const container = event.target;
	// 用来判断滚动方向
	const delta = container.scrollTop - lastScrollTop.current;
	lastScrollTop.current = container.scrollTop;
	const isPositive = delta >= 0;
	anchorItem.current.offset += delta;
	let tempFirst = firstItem;
	if (isPositive) {
		if (anchorItem.current.offset >= ELEMENT_HEIGHT) {
			updateAnchorItem(container);
		}
		if (anchorItem.current.index - tempFirst >= BUFFER_SIZE) {
			tempFirst = Math.min(list.length - VISIBLE_COUNT, anchorItem.current.index - BUFFER_SIZE);
			setFirstItem(tempFirst);
		}
	} else {
		if (container.scrollTop <= 0) {
			anchorItem.current = { index:0, offset: 0 };
		} else if (anchorItem.current.offset < 0) {
			updateAnchorItem(container);
		}
		if (anchorItem.current.index - firstItem < BUFFER_SIZE) {
			tempFirst = Math.max(0, anchorItem.current.index - BUFFER_SIZE);
			setFirstItem(tempFirst);
		}
	}
	setLastItem(Math.min(tempFirst + VISIBLE_COUNT + BUFFER_SIZE * 2, list.length));
	// 滚动到底部加载更多数据
	if (container.scrollTop + container.clientHeight >= container.scrollHeight - 10) {
		setList([...list, ...generateItems()]);
	}
}, [list, updateAnchorItem, firstItem]);

演示地址在这里:codesandbox.io/p/github/ch...

那么对于动态高度的DOM元素,虚拟滚动是怎么实现呢?简单来说就是需要考虑实时计算元素高度、元素位移和滚动高度模拟等,具体实现可以在参考里找到相关blog链接。

实际开发中如何使用

对于少量数据的场景下,使用传统的加载方式会优于虚拟漆动,虚拟滚动的核心是监听滚动事件去进行复杂的逻辑计算,两者衡量之下,少量数据的DOM消耗要远小于虚拟滚动的计算。

而数据量过大(成千上万级别)的场景,通过使用虚拟滚动,可以将DOM元素的数量维持在一个固定的范围内,即使数据量很大,也能保持良好的性能。这是因为渲染整个数据集的DOM元素通常会导致性能下降,特别是在移动设备上。

当我们需要使用虚拟列表的时候(以 ant 为例),查看 ant 的官方文档,可以找到列表组件是不支持虚拟滚动,但是提供了第三方的解决方案:

  • antd

结合 rc-virtual-list 实现滚动加载无限长列表,能够提高数据量大时候长列表的性能。

  • antd mobile

List 本身不会支持虚拟滚动,可以结合 react-virtualized 实现。

参考

相关推荐
Martin -Tang12 分钟前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发13 分钟前
解锁微前端的优秀库
前端
王解1 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
我不当帕鲁谁当帕鲁1 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂1 小时前
工程化实战内功修炼测试题
前端·javascript
放逐者-保持本心,方可放逐2 小时前
微信小程序=》基础=》常见问题=》性能总结
前端·微信小程序·小程序·前端框架
毋若成4 小时前
前端三大组件之CSS,三大选择器,游戏网页仿写
前端·css
红中马喽4 小时前
JS学习日记(webAPI—DOM)
开发语言·前端·javascript·笔记·vscode·学习
Black蜡笔小新5 小时前
网页直播/点播播放器EasyPlayer.js播放器OffscreenCanvas这个特性是否需要特殊的环境和硬件支持
前端·javascript·html
秦jh_6 小时前
【Linux】多线程(概念,控制)
linux·运维·前端