一、前言
在前端需求开发的过程中,我们经常会遇到大数据量内容展示或处理的场景,当页面存在大数据量内容的时候,我们经常会听到用户吐槽"页面太卡了"、页面太慢了"和"页面没反应"等等,如何定位并解决前端大数据量场景下的性能问题是我们前端较为头疼且无法回避的一个问题。在《大数据量场景下前端性能优化》一文中,我们整理了一些大数据量场景下常见的前端性能解决方案,其中虚拟列表技术是讨论解决大数据量内容卡顿中最常被提到的一种技术方案。本文围绕虚拟列表展开,首先介绍了虚拟列表的通用原理,然后介绍了"固定虚拟列表"和"动态虚拟列表"的实现原理,最后还对"固定虚拟列表"和"动态虚拟列表"的基础实现进一步介绍了一些优化的方法。
二、虚拟列表原理
虚拟列表的核心概念就是对可视区域内的元素进行渲染,对非可视区域内的元素不渲染或仅部分渲染,从而避免直接渲染大量元素导致页面发生卡顿,其过程简单来说就是:
- 根据滚动偏移量(一般初始为0)、可视区域尺寸和列表项尺寸等计算出初始渲染元素的索引,并计算的索引渲染列表项内容并更新视图;
- 当页面发生滚动时,重新根据滚动偏移量、可视区域尺寸和列表项尺寸等计算渲染元素的索引,并重新根据索引结果渲染列表项更新视图;
2.1 视图结构
【结构】虚拟列表视图结构一般由三部分组成:
- 可视区域:我们所能看到有内容显示的区域;
- 滚动区域:全量内容渲染占位以形成滚动条;
- 渲染区域:实际渲染内容元素的区域(渲染区域 >= 可视区域);
【关键】整个虚拟列表结构主要基于两个关键点:
- 需要一个元素撑起整个列表,表示渲染全量内容时所占据的区域,并让可视区域能够滚动起来;
- 在用户滚动列表的时候,需要不断调整渲染元素的位置能得渲染元素能在可视区域内进行展示;
【示例】基于上述结构和关键点,Vue 模版有非常多的结构可供选择,以下提供 3 种示例:
html
<!-- vueuc/VVirtualList 库结构 -->
<template>
<!-- 可视区域 -->
<div class="vl-visible" style="height: 500px; width: 100%; overflow: auto">
<!-- 滚动区域 -->
<div class="vl-scroll" style="height: 10000px; width: 100%;">
<!-- 渲染区域 -->
<div class="vl-items" style="transform: translateY(0px)">
<div class="vl-item" style="height: 50px">item1</div>
<div class="vl-item" style="height: 50px">item1</div>
......
</div>
</div>
</div>
</template>
html
<!-- vue-virtual-scroller 库结构 -->
<template>
<!-- 可视区域 -->
<div class="vl-visible" style="height: 500px; width: 100%; overflow: auto;">
<!-- 滚动区域 -->
<div class="vl-scroll" style="height: 10000px; width: 100%; position: relative">
<!-- 渲染区域 -->
<div class="vl-item" style="height: 50px; position: absolute; top: 0; left: 0; transform: translateY(0px)">item1</div>
<div class="vl-item" style="height: 50px; position: absolute; top: 0; left: 0; transform: translateY(50px)">item2</div>
......
</div>
</div>
</template>
html
<!-- element-plus 虚拟列表结构 -->
<template>
<!-- 可视区域 -->
<div class="vl-visible" style="height: 500px; width: 100%; overflow: auto;">
<!-- 滚动区域 -->
<div class="vl-scroll" style="height: 10000px; width: 100%; position: relative">
<!-- 渲染区域 -->
<div class="vl-item" style="height: 50px; position: absolute; top: 0; left: 0;">item1</div>
<div class="vl-item" style="height: 50px; position: absolute; top: 50px; left: 0;">item2</div>
......
</div>
</div>
</template>
2.2 数据存储
虚拟列表的实现主要基于以下数据内容:
- scrollOffset:列表滚动的距离;
- startIndex:渲染的第一个元素的索引;
- endIndex:渲染的最后一个元素的索引;
- visibleSize:可视区域的尺寸;
- itemSize:列表项的尺寸;
- data:列表数据;
- ......
2.3 计算逻辑
整个计算逻辑关键在于计算渲染的元素的索引 startIndex 和 endIndex,相关计算逻辑将在三、固定虚拟列表、四、动态虚拟列表和五、优化虚拟列表中分别详细介绍。
三、固定虚拟列表
固定虚拟列表是我们在开发中最常遇见的使用场景,其列表项尺寸为一个固定数值,固定虚拟列表也是虚拟列表最基础的内容,其基本思路如下:
- 首先,我们需要准备和初始化一些数据:
- 列表项数据,其为一个数组,记为 data;
- 列表项尺寸,其为一个固定数值,记为 itemSize;
- 可视区域尺寸,直接传入或通过 js 获取,记为 visibleSize;
- 列表滚动距离,初始时候为0,记为 scrollOffset;
- 然后,在准备完初始数据后就能进行逻辑计算:
- 滚动区域高度(用于占位形成滚动条):totalSize = data.length * itemSize
- 渲染区域的偏移量:offset = scrollOffset - (scrollOffset % itemSize)
- 渲染的第一个元素的索引:startIndex = Math.floor(scrollOffset / itemSize),
- 渲染的最后一个元素的索引:endIndex = Math.floor((scrollOffset +visibleSize) / itemSize)
- 渲染的列表项数据:renderData = data.slice(startIndex, endIndex)
- 最后,在列表发生滚动时,执行以下内容:
- 更新列表滚动距离 scrollOffset
- 重新进行逻辑计算并更新视图
⚠️ 区别列表滚动距离 scrollOffset 和渲染区域偏移量 offset。列表滚动距离 scrollOffset 即滚动条滚动的距离,只要滚动一定发生变化;渲染区域偏移量 offset 只有在渲染元素索引发生变化渲染区域重新渲染才会改变,此时表示当前渲染区域偏移量下中有列表项完全离开可视区域,需要通过样式将渲染区域偏移至可视区域中。
示例运行如下图所示,完整代码请移步 👉 codesandbox:
四、动态虚拟列表
列表项尺寸固定的虚拟列表的实现相对较为简单,我们很容易就能计算列表的整体高度以及渲染的元素的索引。然而,有时候列表项并不是固定尺寸的,比如展示的文本内容有长有短、区域内存在不同尺寸的图片等等均会导致列表项的尺寸互不相同。在这种不定尺寸的情况下,我们就需要使用动态虚拟列表。动态虚拟列表中根据列表项尺寸的获取方式不同大致可以分为两类:
- 逻辑动态:列表项尺寸无需渲染得到,可以预先通过数组或函数来获取(伪动态);
- 渲染动态:列表项尺寸只有在执行完内容渲染后才能知道其实际的高度(真动态);
4.1 逻辑动态虚拟列表
逻辑动态虚拟列表中列表项尺寸为非固定的动态内容,因此我们需要一个数组 positions 来记录所有列表项的位置属性,位置属性包括以下内容:
- size:当前列表项的尺寸;
- offset:当前列表项的偏移,其等于前面所有列表项的尺寸之和。
假设列表项尺寸是通过函数来获取的,即 itemSize 传入为一个函数(index: number) => number,在初始化时直接获取位置属性数组 positions:
tsx
const positions = ref<PositionT[]>([]);
const initPositions = () => {
const { itemSize, data = [] } = props;
const total = data.length;
const curPositions = [];
let tmpOffset = 0;
for (let i = 0; i < total; i++) {
const curItemSize = itemSize?.(i) || 0;
curPositions.push({
size: curItemSize,
offset: tmpOffset,
});
tmpOffset += curItemSize;
}
positions.value = curPositions;
};
在有了基于位置属性数组 positions 后,结合滚动偏移量 scrollOffset 和可视区域尺寸 visibleSize,我们很容易就能计算出起始索引和结束索引:
tsx
const getStartIndexForOffset = () => {
return positions.value.findIndex((i) => i.offset + i.size > scrollOffset);
};
const getEndIndexForOffset = () => {
return positions.value.findIndex((i) => i.offset + i.size >= scrollOffset + visibleSize);
};
我们以列表项尺寸通过函数来获取为例,示例运行如下图所示,完整代码请移步 👉 codesandbox:
4.2 渲染动态虚拟列表
与逻辑动态虚拟列表一样,列表项尺寸为非固定的动态内容,需要使用数组 positions 记录所有列表项的位置属性,位置属性包括以下内容:
- size:当前列表项的尺寸;
- offset:当前列表项的偏移,即前面所有列表项的尺寸之和;
同时由于初始时并非全量渲染内容,对于未渲染的内容我们无法得到其具体的尺寸数据,因此我们会先统一设置一个预估值 estimatedItemSize,在列表项完成渲染后再来更新其尺寸内容以及影响元素的位置信息。
tsx
const initPositions = () => {
positions.value = (props.data || []).map((item, index) => {
return {
size: props.estimatedItemSize,
offset: index * props.estimatedItemSize,
};
});
};
const updatePosition = (index: number, size: number) => {
positions.value[index].size = size;
// 向下更新
for (let k = index + 1; k < (props.data || []).length; k++) {
positions.value[k].offset = positions.value[k - 1].offset + positions.value[k - 1].size;
}
};
渲染动态虚拟列表的列表项尺寸在执行完内容渲染后才能知道其实际的高度,最为关键的就是如何在获取渲染完成的内容尺寸。我们将每个列表项内容套在一个内容盒内,通过 ResizeObserver 接口来监听内容盒尺寸的变化,当尺寸发生变化的时候就通知更新位置属性。
html
<template>
<div class="dynamic-virtual-list-item" ref="itemRef">
<!-- 渲染列表项内容 -->
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from "vue";
const props = defineProps<{
index: number;
}>();
const emit = defineEmits<{
(e: 'size-change', data: { index: number; size: number }): void;
}>();
const itemRef = ref();
const updateSize = () => {
const { height: size } = itemRef.value?.getBoundingClientRect() || {};
emit("size-change", { index: props.index, size });
};
onMounted(() => {
updateSize();
const resizeObserver = new ResizeObserver(() => {
updateSize();
});
resizeObserver.observe(itemRef.value);
});
</script>
示例运行如下图所示,完整代码请移步 👉 codesandbox:
五、优化虚拟列表
在三、固定虚拟列表和四、动态虚拟列表章节中描述的内容和提供的示例都是最基础的虚拟列表实现,在实际使用中我们还有许多需要进一步优化的地方,例如:
- 如何尽可能避免滚动过快产生的白屏?
- 如何尽可能减少滚动中重新计算索引?
- ......
5.1 渲染元素优化
5.1.1 预渲染
滚动过快产生白屏的解决方法之一就是预渲染,在可视区域内之外额外渲染一定数量的元素,这样滚动的时候能提前准备好需要展示的内容。但是,如何设定预渲染的数量是一个难题,如果设置数量过多,依旧会导致性能变差,在实际应用中需要不断尝试和调整。示例详见 👉 codesandbox
5.1.2 骨架屏
在 5.1.1 预渲染中预渲染元素是列表项内容,如果列表项是一个非常复杂内容,那依旧可能会导致最终的性能不佳,此时我们可以考虑将全部或部分预渲染内容换成骨架屏来缓解渲染实际列表项的压力。示例详见 👉 codesandbox。骨架屏社区库:
5.1.3 滚动方向
在预渲染或骨架屏的使用过程中,一般都是在渲染元素的前后都设置缓存区进行预渲染,我们可以考虑进一步减少渲染内容,只在滚动方向上进行预渲染或骨架屏,从而节省了非滚动方向上用户不会看到内容的渲染。示例详见 👉 codesandbox
5.2 动态虚拟列表计算优化
5.2.1 逻辑动态计算尺寸优化
在 4.1 逻辑动态虚拟列表中通过函数来获取列表项尺寸,如果函数的计算过程非常复杂,在初始化的时候一次性计算全量列表项的尺寸无疑无疑会对性能造成非常严重的影响。针对这个情况,我们会先统一设置一个预估值 estimatedItemSize ,在列表项需要渲染的时候后再来计算其尺寸内容以及更新影响元素的位置信息,同时可以借助一个字段 lastVisitedIndex 来记录已计算过尺寸的索引,只有渲染大于该索引值的列表项的时候才需要执行函数来计算列表项尺寸。示例详见 👉 codesandbox
tsx
const getAndUpdateItemSize = (index: number): PositionT => {
if (index > state.lastVisitedIndex) {
let offset = 0;
if (state.lastVisitedIndex >= 0) {
const lastItem = positions.value[state.lastVisitedIndex];
offset = lastItem.offset + lastItem.size;
}
// 更新 lastVisitedIndex 到 idx 之间的尺寸记录
for (let i = state.lastVisitedIndex + 1; i <= index; i++) {
const size = props.itemSize(i);
positions.value[i] = {
offset,
size,
};
offset += size;
}
state.lastVisitedIndex = index;
}
return positions.value?.[index];
};
5.2.2 渲染索引计算优化
在虚拟列表的实现中,最频繁计算的内容就是渲染元素的索引,在固定虚拟列表中由于列表项尺寸都是固定的,直接通过除法计算就能得到,相对较为简单,而在动态虚拟列表中,索引的获取是基于位置属性数组 positions 查找得到的,最基础的方式就是直接遍历查找,我们也可以利用一些查找算法进行优化,例如二分查找等。
5.3 滚动监听优化
虚拟列表通用模型中最为关键的一环是滚动页面从而触发计算,因此我们需要监听获取这样的滚动行为。最常用的解决方案就是监听 scroll 事件,在回调函数中获取偏移量然后计算相应的索引结果。然而 scroll 事件在滚动过程中密集触发,计算量巨大,在一定程度上也会影响性能。如何减少这样的计算又不影响页面展示呢?
- 🚫 最先能想到的就是通过节流函数来减少这样的计算,节流函数使得这样的计算在一定时间内只能执行一次,但这又会导致另一个问题,如何设置节流的时间,此外在节流的这段时间里无法确定当前应该渲染的元素还可能会导致页面出现白屏。
- ✅ 回到监听滚动的根本原因上来,我们需要监听滚动无非是为了获取滚动偏移量而计算出当前应该渲染元素的索引,真正有意义的监听在于计算应该渲染元素的索引发生改变的那一次监听,而渲染元素的起始结束索引发生改变的时机在于某个元素离开或出现在可视区域,因此我们可以借助 Intersection Observer API 来实现这样的监听。
我们在渲染元素的前后设定了两个 Intersection observer 监听的目标元素,当页面发生滚动的时候,如果目标元素出现在可视区域,则表示我们需要重新进行起始/结束元素索引的计算。从下图可以看出相比于通过监听 scroll 事件来频繁计算起始/结束元素索引极大地减少了计算次数。
🚫 但是,通过 Intersection observer 设置目标元素来监听的方式在实践中发现依旧存在问题,滚动过快时没有触发回调函数导致页面出现白屏。
5.4 其他
5.4.1 CSS 优化
我们还能借助一些 CSS 属性来进一步进行一些锦上添花的优化,例如:
- will-change 属性:渲染区域我们是通过 transform 属性来进行位置偏移,因此可以借助 will-change: transform 来进行渲染优化。
- content-visibility:在设置content-visibility: auto;后如果元素不在屏幕上,并且与用户无关,则浏览器不会渲染其后代元素。
5.4.2 控制滚动速度
虚拟列表出现白屏主要是由于用户使用的时候滚动太快导致的,那我们可以禁用浏览器本身的滚动条,使用自定义的滚动条,在自定义滚动条上控制滚动速度,从而缓解滚动过快导致白屏等问题。
5.4.3 web worker
web worker 可以执行一些计算密集型或高延迟的任务,这样不会阻塞或拖慢主线程,可以考虑使用 web worker 来执行计算渲染元素索引等内容 。
六、结语
本文简单介绍了虚拟列表的基础原理,以及固定虚拟列表和动态虚拟列表的实现原理,并列举了部分优化虚拟列表的手段。不管虚拟列表怎么变形,是横向滚动虚拟列表,是网格式虚拟列表,还是瀑布流式虚拟列表等等,一般都适用于虚拟列表原理中介绍的虚拟列表通用模型,其包含视图结构、数据存储和计算逻辑等三部分。例如,横向滚动虚拟列表相比于纵向滚动虚拟列表只需要将高度计算转换为宽度计算并调整视图结构内容横向展示即可,示例请移步 👉 codesandbox。在实际应用中尽管虚拟列表在渲染上是高效的,但虚拟列表永远不是最完美的解决方案,当数据负载过大时,网络和内存容量依旧也会成为瓶颈。请尽可能地与后端同学达成一致,考虑数据分页、过滤器等优化方案,而不是直接前端来处理并渲染全量数据 (:」∠)_ 上文中提供的 codesandbox 内容均为 demo 示例,仅作理解原理使用,未经测试请勿直接用于生产环境,推荐使用社区库:
- vue-virtual-scroller
- vue-virtual-scroll-grid
- vueuc/VVirtualList
- element-plus(虚拟列表组件未在官网上列出,慎用!)
- vxe-table(虚拟列表表格)
- react-virtualized(react 组件,可参考其思路原理)