原创侯唐 / 叫叫技术团队
背景
在面对大数据列表的渲染时,我们常常面临一个共同的挑战:随着列表项的增加,渲染速度下降,上下滑动出现明显的卡顿现象。这种情况在移动端尤为明显,影响用户体验,甚至可能使应用变得难以使用。
为了解决这个问题,我们采取了一种高效的优化策略,即引入虚拟列表技术。虚拟列表技术允许我们只渲染可见区域内的列表项,而不是一次性渲染整个大数据列表。通过动态地加载和卸载列表项,我们能够显著提高渲染速度,降低内存占用,同时保持平滑的滑动效果。
基础流程
- 列表渲染的基本数据流
- 数据渲染与回收原理
为了解决大数据列表渲染慢,上下滑动卡顿问题,列表只渲染可见的数据,当上下滚动时,计算并填充渲染范围内变化的部分数据,并回收多余的 DOM 节点。
一、初始化配置
为了计算视口可渲染多少条数据,我们需要初始化配置视口的高度和每一条数据渲染的预估高度。
根据视口高度和每条数据预估高度,我们可以计算出视口的渲染数量,由于渲染数量是正整数,所以我们计算结果处理为向上取整:
const visibleCount = Math.ceil(viewportHeight / estimatedItemHeight);
在不同的场景渲染的数据和最终效果会有不同,所以我们需要自定义配置每一条数据的渲染样式。
const renderItem = (item: any) => {
return (
<div key={item.id}>
<span style={{ color: 'red' }}>序号:{item.id} </span>
<span>{item.text}</span>
</div>
);
};
二、初始化数据
- 每条数据增加唯一 KEY
为了避免数据重复渲染,我们将在每条数据原来的基础上新增一个唯一 key ,数据集合用新的变量 $sourceData 接收:
const $sourceData = useMemo(() => {
return sourceData?.map((item: any, index: number) => ({
key: `${index}_${Math.random()}`,
item
}));
}, [sourceData]);
- 计算数据位置信息
为了确定每条数据渲染的位置,我们需要预先计算出每一条数据的预估高度、顶部、底部的位置。假设每一条数据渲染的预估高度为 50 ,那么第 1 条数据渲染的底部 bottom 位置为 50 ,第 2 条数据渲染的底部 bottom 为前面一条数据渲染的高度加自身的渲染高度等于 100,以此类推,即第 n 条数据渲染的底部 bottom 位置为 n * 50”。
以变量 sourceData 表示渲染数据源,每条数据位置信息计算集合以变量 positions 表示:
// 初始化每一条数据的高度和位置信息
const positions = sourceData?.map((_, index) => ({
index,
height: estimatedItemHeight,
top: index * estimatedItemHeight,
bottom: (index + 1) * estimatedItemHeight
}));
- 渲染索引
当渲染数据初始化或变更时,从数据的索引从 0 开始渲染,且只渲染视口可渲染数量。
// 渲染数据索引开始
const visibleStart = 0;
// 渲染数据索引结束
const visibleEnd = visibleStart + visibleCount;
- 渲染数据
当计算出数据渲染的起始索引,即可从数据集合中截取出可渲染数据:
const visibleData = $sourceData.slice(visibleStart, visibleEnd);
三、计算渲染区域
- 计算当前可见数据渲染高度
在数据渲染过程中,不同数据实际渲染高度不同,那么与我们初始化配置每条数据预估渲染高度有差异,所以需要根据每条数据实际渲染高度,重新计算更新当前及其后面数据的位置信息,以便于列表依次排列渲染。
举个例子,每条数据预估高度为 50 ,当渲染的第 3 条数据高度为 40 ,实际高度比预估高度小 10 ,那么当前这条数据的位置 bottom 则需要在原来的基础上减去 10 ,从第 4 条数据开始,后面的数据的 bottom 都将在原来的基础上减 10 。
// 可见数据渲染DOM节点集合
const nodes = content?.current.childNodes;
nodes.forEach((node: any) => {
const rect = node.getBoundingClientRect();
const realHeight = rect.height;
const index = +node.id.slice(1);
const oldHeight = positions[index].height;
const dValue = oldHeight - realHeight;
// 如果存在差值,就更新当前数据的后面所有数据位置信息
if (dValue) {
positions[index].bottom -= dValue;
positions[index].height = realHeight;
for (let k = index + 1; k < positions.length; k++) {
positions[k].bottom -= dValue;
}
}
});
- 计算虚拟背影高度
我们知道,当内容高度大于视口高度时,才会出现滚动条允许上下滚动渲染数据,由于可见数据渲染的累计高度不足以撑高全部内容高度,会导致滚动效果与实际交互相差甚远,所以需要在内容区域增加一个“虚拟背影”支撑全部内容的高度。
将位置信息集合的最后一条数据的 bottom 值作为“虚拟背影”的总高度,由此可得出“虚拟背景”的高度,即滚动条的高度和位置也就刚刚好。
const phantomHeight = positions[positions.length - 1]?.bottom || 0;
- 上下滚动
上下滚动时,监听父容器 DOM 滚动事件,实时获取当前滚动位置:
// 滚动事件
const scrollEvent = () => {
// 当前滚动高度
const scrollTop = container?.current.scrollTop || 0;
}
由于上下滚动时,触发滚动事件频率较高,从而增加计算频率,所以我们可以采用“节流”方式来优化计算频率:
// 节流方法
const throttle = (func: () => void, delay: number) => {
let t: any;
return function () {
if (!t) {
t = setTimeout(() => {
func();
t = null;
}, delay);
}
};
};
// 滚动事件
const scrollEvent = throttle(() => {
// 当前滚动位置
const scrollTop = container?.current.scrollTop || 0;
}, 100);
通过当前的高度,计算出列表渲染的起始索引:
const getStartIndex = (scrollTop: number) => {
let _start = 0;
let _end = positions.length - 1;
let _tempIndex = null;
while (_start <= _end) {
const midIndex = Math.floor((_start + _end) / 2);
const midValue = positions[midIndex].bottom;
if (midValue === scrollTop) {
return midIndex + 1;
} else if (midValue < scrollTop) {
_start = midIndex + 1;
} else if (midValue > scrollTop) {
if (_tempIndex === null || _tempIndex > midIndex) {
_tempIndex = midIndex;
}
_end -= 1;
}
}
return _tempIndex;
};
- 计算偏移量
当渲染起始索引是 0 时,那么上下滚动偏移量为 0 ,如果渲染起始索引大于等于 1 ,那么从该条数据位置信息中取 bottom 值作为偏移量。
const startOffset = (startIndex: number = visibleStart) => {
return startIndex >= 1 ? positions[startIndex - 1].bottom : 0;
};
React 完整代码
- 组件引用展示:
import React, { useEffect, useState } from 'react';
import VirtualList from './VirtualList/index';
/**
* 测试文本
*/
const testText =
'在古老的波斯,有一个勇敢的年轻人,名叫阿里巴巴。一次,他偶然发现了隐藏在山洞中的宝库,里面堆满了金银财宝。然而,宝库却被四十大盗控制。阿里巴巴机智地利用魔法口令“开门啦”,成功进入宝库,并偷了一些金币回家。大盗发现宝库中的金币减少,怀疑有人闯入。他们找到阿里巴巴家,但是阿里巴巴的聪明伙伴策马出城,躲过了大盗的追捕。随后,阿里巴巴借助木桶和麻袋,将剩余的金币带回家中。他的贪婪的兄弟发现了这一切,想知道宝库的秘密,阿里巴巴不得不将魔法口令告诉他。大盗再次找到宝库,想利用魔法口令进入,却发现阿里巴巴已经改变了口令。最终,大盗被阿里巴巴的智慧所战胜,阿里巴巴成功保护了财富和自己的安全。';
export default () => {
const [sourceData, setSourceData] = useState<any[]>([]);
useEffect(() => {
const min = 50;
const max = testText.length;
setSourceData(
new Array(20000).fill(null).map((_: any, idx: number) => ({
id: idx + 1,
// 随机截取文本长度用于每条数据渲染自适应随机高度
text: testText.slice(0, Math.random() * (max - min + 1) + min)
}))
);
}, []);
/**
* 渲染每一条数据
*/
const renderItem = (item: any) => {
return (
<div key={item.id} style={{ padding: '5px' }}>
<span style={{ color: 'red' }}>序号:{item.id} </span>
<span>{item.text}</span>
</div>
);
};
return (
<div style={{ width: 500, height: 500, border: '1px solid #e1e1e1' }}>
<VirtualList
sourceData={sourceData}
height={'100%'}
estimatedItemHeight={140}
onRenderItem={renderItem}
/>
</div>
);
};
- 虚拟列表组件:VirtualList/index.tsx
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import styles from './styles.less';
interface Props {
sourceData: any[]; // 所有列表数据
height?: string | number;
estimatedItemHeight?: number; // 预估高度
onRenderItem: (data: any) => any; // 列表项渲染
}
/**
* 初始位置
*/
let positions: any[] = [];
/**
* 起始索引
*/
let cacheVisibleStart = 0;
/**
* 渲染延迟定时器
*/
let timer: any = null;
/**
* 节流
*/
const throttle = (func: () => void, delay: number) => {
let t: any;
return function () {
if (!t) {
t = setTimeout(() => {
func();
t = null;
}, delay);
}
};
};
export default ({
sourceData,
onRenderItem,
estimatedItemHeight = 100,
height = '100%'
}: Props) => {
const container: any = useRef(null);
const content: any = useRef(null);
// 可视区域高度
const [viewportHeight, setViewportHeight] = useState<number>(0);
// 可视渲染数据起始索引
const [visibleStart, setVisibleStart] = useState<number>(0);
// 可视渲染数据结束索引
const [visibleEnd, setVisibleEnd] = useState<number>(0);
// 当前偏移量
const [startOffset, setStartOffset] = useState<number>(0);
// 列表支撑高度
const [phantomHeight, setPhantomHeight] = useState<number>(0);
/**
* 列表数据
*/
const $sourceData = useMemo(() => {
return sourceData?.map((item: any, index: number) => ({
key: `${index}_${Math.random()}`,
item
}));
}, [sourceData]);
/**
* 可见列表数量
*/
const visibleCount = useMemo(() => {
return Math.ceil(viewportHeight / estimatedItemHeight);
}, [viewportHeight, estimatedItemHeight]);
/**
* 可见数据
*/
const visibleData = useMemo(() => {
return $sourceData.slice(visibleStart, visibleEnd);
}, [$sourceData, visibleStart, visibleEnd]);
/**
* 初始化每一条数据的高度和位置信息
*/
const initPositions = () => {
positions = sourceData?.map((_, index) => ({
index,
height: estimatedItemHeight,
bottom: (index + 1) * estimatedItemHeight
}));
};
/**
* 获取列表起始索引
*/
const getStartIndex = (scrollTop: number) => {
let _start = 0;
let _end = positions.length - 1;
let _tempIndex = null;
while (_start <= _end) {
const midIndex = Math.floor((_start + _end) / 2);
const midValue = positions[midIndex].bottom;
if (midValue === scrollTop) {
return midIndex + 1;
} else if (midValue < scrollTop) {
_start = midIndex + 1;
} else if (midValue > scrollTop) {
if (_tempIndex === null || _tempIndex > midIndex) {
_tempIndex = midIndex;
}
_end -= 1;
}
}
return _tempIndex;
};
/**
* 更新数据高度
*/
const updateItemsHeight = () => {
// 可见数据渲染DOM节点集合
const nodes = content?.current.childNodes;
nodes.forEach((node: any) => {
const rect = node.getBoundingClientRect();
const realHeight = rect.height;
const index = +node.id.slice(1);
const oldHeight = positions[index].height;
const dValue = oldHeight - realHeight;
// 如果存在差值,就更新当前数据的后面所有数据位置信息
if (dValue) {
positions[index].bottom -= dValue;
positions[index].height = realHeight;
for (let k = index + 1; k < positions.length; k++) {
positions[k].bottom -= dValue;
}
}
});
};
/**
* 更新虚拟背影高度
*/
const updatePhantomHeight = () => {
const _phantomHeight = positions[positions.length - 1]?.bottom || 0;
setPhantomHeight(_phantomHeight);
};
/**
* 更新当前的偏移量
*/
const updateStartOffset = (startIndex: number = visibleStart) => {
const _startOffset = startIndex >= 1 ? positions[startIndex - 1].bottom : 0;
setStartOffset(_startOffset);
};
/**
* 上下滚动
* 由于滚动事件频率较高,可以采用“节流”方式降低计算频率
*/
const scrollEvent = throttle(() => {
// 当前滚动位置
const scrollTop = container?.current.scrollTop || 0;
// 此时的开始索引
cacheVisibleStart = getStartIndex(scrollTop) || 0;
setVisibleStart(cacheVisibleStart);
// 此时的结束索引
const _end = cacheVisibleStart + visibleCount;
setVisibleEnd(_end);
// 此时的偏移量
updateStartOffset(cacheVisibleStart);
}, 100);
useLayoutEffect(() => {
if (content?.current?.childNodes?.length) {
clearTimeout(timer);
timer = setTimeout(() => {
updateItemsHeight();
updatePhantomHeight();
// 更新真实偏移量
updateStartOffset(cacheVisibleStart);
}, 50);
}
return () => clearTimeout(timer);
});
useEffect(() => {
cacheVisibleStart = 0;
setVisibleStart(cacheVisibleStart);
setVisibleEnd(visibleStart + visibleCount);
initPositions();
setViewportHeight(container?.current?.clientHeight || 100);
}, [sourceData]);
return (
// 容器
<div ref={container} className={styles.container} style={{ height }} onScroll={scrollEvent}>
<div className={styles.phantom} style={{ height: `${phantomHeight}px` }} />
{/* 列表内容 */}
<div
ref={content}
style={{ transform: `translate3d(0,${startOffset}px,0)` }}
className={styles.list}>
{visibleData?.map((item: any) => (
<div key={item.key} className={styles.listItem}>
{onRenderItem?.(item.item)}
</div>
))}
</div>
</div>
);
};
- 虚拟列表组件样式:virtualList/index.less
.container {
overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
.phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.list {
left: 0;
right: 0;
top: 0;
position: absolute;
.listItem+.listItem {
border-top: 1px solid #f0f0f0;
}
}
}
渲染效果
虚拟列表 VS 普通列表
以 Vite 初始化的 React 项目为例,创建 20000 条纯文本数据,每条文本长度随机(1 - 300 个文字),比较虚拟列表和普通列表的差别。
| 事项 | 虚拟列表 | 普通列表 |
|---|---|---|
| 首屏加载速度 | 👍 381 ms | 3743 ms(伴随页面卡顿) |
| DOM 列表渲染时间 | 👍 24 ms | 1851 ms(伴随页面卡顿) |
| DOM 列表渲染数量 | 👍 4 | 20000 |
| HTML 内存 | 👍 5 kb | 12068 kb |
优点总结
- 提升渲染性能: 虚拟列表只渲染可见区域内的数据,避免了一次性渲染所有数据,减少了大量的 DOM 操作和页面重绘,从而显著提升了渲染性能。
- 节省内存消耗: 传统方式渲染大数据需要创建大量的 DOM 元素,占用大量内存。虚拟列表只保持可见区域的 DOM 元素,降低了内存占用。
- 流畅滑动体验: 虚拟列表在滑动过程中动态渲染新的数据项,使得滚动更加平滑,避免了卡顿和延迟,提供了更好的用户体验。
- 快速加载速度: 虚拟列表只加载当前可见区域的数据,减少了初始化加载时间,使页面更快地展现给用户。
- 适应不同设备: 虚拟列表适用于不同设备,包括移动端和桌面端。它可以根据设备的屏幕尺寸和性能自动调整渲染策略。
- 降低 CPU 使用率: 虚拟列表的渲染策略降低了对 CPU 的使用,因为只需要处理少量的 DOM 操作,减少了浏览器的工作量。