虚拟滚动的核心思想是将可滚动区域分割为固定大小的视窗(通常是屏幕高度),只在当前视窗内渲染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元素数量不足以撑起列表的滚动高度,下面给出几种模拟滚动的方案:
- 使用
div
在DOM元素列表的上下进行填充,在初始化和滚动时设置高度,以撑起滚动区域的高度 - 通过设置容器的padding属性填充高度来实现滚动效果
- 添加一个额外的
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元素,这个变量依赖上方所提到的 firstItem
,lastItem
以及 list
。
tsx
useLayoutEffect(() => {
// 设置元素的滚动高度
list.forEach((item, idx) => {
item.scrollY = idx * ELEMENT_HEIGHT;
});
setVisibleList(list.slice(firstItem, lastItem));
}, [firstItem, lastItem, list]);
最后,我们需要处理滚动事件,计算出哪些数据是需要被展示的。由于列表的渲染依赖 visibleList
变量,而 visbleList
依赖于 firstItem
和 lastItem
,而 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 实现。