前端 Vue3 + ElementPlus, 实现虚拟滚动列表
前言
在浏览器的渲染逻辑中,有一个非常基础的性能问题:需要渲染的内容越多,性能越差,这在大列表或者无限滚动场景中尤为突出。解决问题的核心思路就是”减少需要渲染的内容“。常见解法有分页加载、虚拟滚动。这里将分别实现元素等高 / 高度固定 和不等高 / 高度不固定的情况下的虚拟滚动列表。
整体思路
虚拟滚动列表的实现原理是,使用少量的DOM节点显示长列表,即只创建且只显示我们视野中看到 item 节点和缓冲区元素,滚动过程中通过算法运算更新需要渲染的节点。
DOM元素的布局和结构大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| <template> <div class="container" :style="containerStyle"> <div class="content" :style="contentStyle"> <!-- items --> </div> </div> </template> <script setup> const props = { containerHeight: 200, // 最外层盒子高度,即视口高度 containerWeight: 200, // 最外层盒子宽度,即视口宽度 itemSize: 60, // 单个元素高度 itemCount: 10000, // 元素总数 }
// 元素列表初始化 const data = []; for (let i = 0; i < props.itemCount; i++) { data.push({ content: `元素${i}` }) }
// 最外层盒子样式 const containerStyle = { height: `${props.containerHeight}px`, width: `${props.containerWidth}px`, position: 'relative', overflow: 'auto' }
// 第二层盒子样式,高度为元素列表总高度,模拟假设所有元素都存在,用于“撑开”最外层盒子,产生滚动条 const contentStyle = { height: `${props.itemCount * props.itemSize}px` } </script>
|
其中 .container
为确定宽高的可视区域,.content
相当于一个容纳了所有列表元素的盒子,它的高度为所有元素高度之和,但实际我们渲染出来的 items 只有可视区域以及缓冲区域的部分。为.container
设置样式overflow: auto
,生成滚动条。
基于上述给出的基本布局,可以知道元素 items 是从 .content
顶部开始渲染的,当列表向上滚动,.content
会一直“向上走”,与此同时 items 也依然是从 .content
顶部开始渲染,即随之出现在不可视的区域(已经被滚走了。所以要想 items 出现在可视区域范围,需要将他们上下平移到可视窗口的位置。
关于某元素平移距离的计算,中心思想是算出在完整的元素列表中,该元素的正常位置与列表顶部的距离即可,可以用 top
或者 translateY
来实现。
元素等高 / 高度固定
显然列表渲染元素的个数以及向下平移距离这两个数据是根据滚动距离 scrollTop
发生改变的。其中渲染的首个元素的索引通过对 scrollTop / itemSize
的结果向下取整获得。(画图和计算可以更好理解,如果向上取整可视区域会出现空白)
另外,可视窗口元素个数应为对 containerHeight / itemSize
的结果向上取整获得,避免可视窗口有留白。另外,为了优化性能和用户体验,除了在可视窗口外另外再渲染一定数量的元素,作缓冲区。
方案一:可视元素整体向下平移
思路:将可视元素 items 包裹在一个盒子 showArea
中,让 showArea
随滚动高度向下平移,直到可视窗口,这就能让 items 被我们看到啦。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
| <template> <div class="container" :style="containerStyle" @scroll="wrapperScroll($event)"> <div class="content" :style="contentStyle"> <div class="showArea" :style="{position: 'absolute', width: '100%', transform: `translateY(${scrollTopWrapper}px)`}"> <div class="showItem" v-for="(item, index) in showData" :key="index" :style="{height: props.itemSize + 'px', lineHeight: props.itemSize + 'px'}" > {{ item.content }} </div> </div> </div> </div> </template> <script setup> import { ref, computed } from "vue";
const props = { containerHeight: 200, containerWeight: 200, itemSize: 60, // 元素高度 itemCount: 10000, // 元素总数 }
const data = []; for (let i = 0; i < props.itemCount; i++) { data.push({ content: `元素${i}` }) }
const containerStyle = { width: `${props.containerWeight}px`, height: `${props.containerHeight}px`, overflow: 'auto', position: 'relative' }
const contentStyle = { height: `${props.itemCount * props.itemSize}px`, }
let startIdx = ref(0); // 可视窗口起始索引 let visibleCount = ref(Math.ceil(props.containerHeight / props.itemSize)); let endIdx = computed(() => { return Math.min(props.itemCount, startIdx.value + visibleCount.value + 2); }); // 下缓冲区结束索引 let scrollTopWrapper = ref(0);
const wrapperScroll = (e) => { const { scrollTop } = e.target; // 滚动距离 startIdx.value = Math.floor(scrollTop / props.itemSize); scrollTopWrapper.value = scrollTop - (scrollTop % props.itemSize); }
const showData = computed(() => { return [...data.slice(startIdx.value, endIdx.value)]; }) </script>
<style> .showItem:nth-child(odd) { background-color: aqua; } .showItem:nth-child(even) { background-color: burlywood; } </style>
|
在上述代码中,截取列表 data
中的 [startIdx, endIdx) 进行渲染,其中 endIdx
为列表最后一个元素或起始索引 + 可视区域元素个数 + 下缓冲区元素个数
。这里由于是对 items 整体进行平移的,就没设置上缓冲区,下缓冲区元素个数设置为2。将 endIdx
通过计算属性获得,每当 startIdx
随 scrollTop
改变而改变时,都自动更新 endIdx
,就不另外在 wrapperScroll
方法中再手动更新了。
需要注意的是,平移距离 scrollTopWrapper
取值 scrollTop - (scrollTop % props.itemSize);
,而非直接选用滚动距离 scrollTop
。如果平移距离为 scrollTop
,那么.showArea
每次平移后顶部都会到原位,即与 container
顶部对齐,所以每次渲染的第一个元素的顶部都会与container
盒子的顶部对齐,造成一种每次滚动距离都是元素高度整数倍的错觉。一方面不符合其在列表中的“真实位置”,另一方面,滚动到底部时无法正常显示完整最后的元素。设置了scrollTop - (scrollTop % props.itemSize);
之后,相当于.showArea
平移到了此时 .content
中应该出现在可视窗口第一个元素的顶部的高度位置。(画图)
方案二:可视元素单独向下平移
思路与第一种方案大体一致,区别在于为每个元素item
单独设置平移距离,同时上下各增加 2 个缓冲区元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
| <template> <div class="container" :style="containerStyle" @scroll="wrapperScroll($event)"> <div class="content" :style="contentStyle"> <div class="showItem" v-for="(item, index) in showData" :key="index" :style="item.itemStyle"> {{ item.content }} </div> </div> </div> </template>
<script setup> import { ref, computed, watchEffect } from "vue";
const props = { containerHeight: 200, containerWeight: 200, itemSize: 60, // 元素高度 itemCount: 10000, // 元素总数 }
const data = []; for (let i = 0; i < props.itemCount; i++) { data.push({ content: `元素${i}` }) }
const containerStyle = { width: `${props.containerWeight}px`, height: `${props.containerHeight}px`, overflow: 'auto', position: 'relative' }
const contentStyle = { height: `${props.itemCount * props.itemSize}px`, }
// 可视窗口起始索引 let startIdx = ref(0); // 上缓冲区起始索引 let finialStartIdx = computed(() => { return Math.max(0, startIdx.value - 2); }); let visibleCount = ref(Math.ceil(props.containerHeight / props.itemSize)); // 下缓冲区结束索引 let endIdx = computed(() => { return Math.min(props.itemCount, startIdx.value + visibleCount.value + 2); });
const scrollTopWrapper = ref(0); const wrapperScroll = (e) => { const { scrollTop } = e.target; // 滚动距离 scrollTopWrapper.value = scrollTop; }
const showData = ref([]); const getScrollList = () => { startIdx.value = Math.floor(scrollTopWrapper.value / props.itemSize); const tempArr = []; for (let i = finialStartIdx.value; i < endIdx.value; i++) { const itemStyle = { height: `${props.itemSize}px`, lineHeight: `${props.itemSize}px`, transform: `translateY(${props.itemSize * i}px)` } tempArr.push({ content: data[i].content, itemStyle }) } showData.value = tempArr; }
watchEffect(() => { getScrollList(); })
</script>
<style> .showItem { width: 100%; position: absolute; } </style>
|
本方案是让所有渲染的元素挨个设置平移距离。同第一种方案不同,这里的平移距离用了 props.itemSize * i
。其实效果是一样的,像基本思路中提到的一样,都是让要渲染的元素(可视窗口+缓冲区)在 .content
中平移到他们理应出现的位置。
元素不等高 / 高度不固定
如果列表元素的高度并不相同或者是动态变化的,那么在前文的基础上,需要额外思考几个问题:
- 由于每个元素的高度不一样,在最开始的时候无法直接算出撑开
.container
的列表总高度,
- 由于每个元素的高度不一样,每个元素的偏移值不能通过
itemSize * index
直接算出
- 由于每个元素的高度不一样,不能直接通过
scrollTopWrapper / itemSize
计算出已被滚动掉的元素个数,从而获取可视区域的起始索引。
针对第一个问题,我们可以通过遍历所有的元素的高度数据,将它们累加起来就是精确的总高度,但是必要性不大。回归到整体思路中提到的,我们之所以要计算这样一个高度,是为了让 .content
高到2足够撑开 .container
从而形成滚动条。所以我们可以自己假设每一个元素的高度,再乘以个数,就可以实现一个高度是假的但是足够撑开 .container
的总高度。(这种方案会带来一些小bug,但影响不大)。
对于第二个问题和第三个问题,本质都是相同的。由于元素高度不一样,导致无法直接根据滚动距离计算出滚动掉的元素有多少。
这里采用的解决方案是,对于渲染列表(上缓冲区元素 + 可视区域元素 + 下缓冲区元素)中的每一个元素,我们记录好元素的高度和偏移量,每一个元素的偏移量等于前一个元素的偏移量与前一个元素的高度之和。每次计算的时候,我们都记录好下最底下元素的索引(只增不减),表示在这个元素之前的元素,我们都已经计算过相关的量了。当用户进行滚动时,如果是向上滚动,那么可以直接从已经计算好的记录里取相关数据;如果是向下滚动,我们根据上一次记录的最大的索引所对应的元素,不断累加往后元素的高度,直到大于新的滚动距离,此时的索引值就是可视区域的起始索引,这个起始索引对应的偏移量,就是累加的高度。
思路整理:发生滚动事件后,我们根据滚动距离寻找可视区域的起始索引。从列表第一个元素开始找起,找的第一个偏移大于等于滚动距离的元素,就是可视区域的起始元素。每个滚动到的元素,我们都会计算并记录好它的偏移,这个值等于前一个元素的偏移与前一个元素的高度之和(第一个元素的偏移为0),对于已经记录过的元素直接取就好了,这里通过一个记录最新计算元素索引的变量来维护。至于可视区域的末尾元素,从已经找的的可视区域起始元素开始往后,找的第一个偏移大于等于滚动距离+可视窗口高度的元素,就是可视区域的末尾元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163
| <template> <div class="container" :style="containerStyle" @scroll="wrapperScroll($event)"> <div class="content" :style="contentStyle"> <div class="showItem" v-for="(item, index) in showData" :key="index" :style="item.itemStyle"> {{ item.content }} </div> </div> </div> </template>
<script setup> import { reactive, ref, watchEffect } from "vue";
const props = { containerHeight: 200, containerWeight: 200, itemSize: Array.from({ length: 10000 }, () => 25 + Math.round(Math.random() * 55)), // 元素高度 itemCount: 10000, // 元素总数 }
const data = []; for (let i = 0; i < props.itemCount; i++) { data.push({ content: `元素${i}` }) }
const containerStyle = { width: `${props.containerWeight}px`, height: `${props.containerHeight}px`, overflow: 'auto', position: 'relative' }
/** * 元数据列表 */ const measuredData = reactive({ measuredDataMap: [], // item's size and offset lastMeasuredIndex: -1 // 上一个已经测量过偏移的元素索引 })
// 预估 content 列表撑起的高度,盒子预估高度 = 已测量过偏移的元素高度 + 剩余元素预估高度 const getEstimatedHeight = (defaultItemEstimatedSize = 50, itemCount) => { const { measuredDataMap, lastMeasuredIndex } = measuredData; let measuredHeight = 0; // 注意 lastMeasuredIndex 有可能为 -1,此时还没有对任何元素测量过偏移 if (lastMeasuredIndex >= 0) { const lastMeasuredItem = measuredDataMap[lastMeasuredIndex]; measuredHeight += lastMeasuredItem.size + lastMeasuredItem.offset; } // 剩余元素高度统一视作defaultItemEstimatedSize来撑开盒子 const unMeasuredHeight = defaultItemEstimatedSize * (itemCount - lastMeasuredIndex - 1); let totalEstimatedHeight = measuredHeight + unMeasuredHeight; console.log(totalEstimatedHeight); return totalEstimatedHeight; }
const contentStyle = { height: getEstimatedHeight(props.itemEstimatedSize, props.itemCount) + 'px' }
const scrollTopWrapper = ref(0); const wrapperScroll = (e) => { const { scrollTop } = e.target; // 滚动距离 scrollTopWrapper.value = scrollTop; }
// 获取指定索引的元素相关信息,因为不确定该元素是否计算了偏移量,所以另外封装逻辑,当元素尚未计算偏移量,则根据itemSize逐步生成元素的偏移量并保存到元数据中 const getItemMetaData = (index) => { const { measuredDataMap, lastMeasuredIndex } = measuredData; if (index > lastMeasuredIndex) { let offset = 0; if (lastMeasuredIndex >= 0) { // 计算当前已统计的最大偏移 const lastMeasuredItem = measuredDataMap[lastMeasuredIndex]; offset += lastMeasuredItem.size + lastMeasuredItem.offset; } for (let i = lastMeasuredIndex + 1; i <= index; ++i) { measuredDataMap.push({ size: props.itemSize[i], offset: offset }) offset += props.itemSize[i]; } // 更新元数据中最新测量过的元素索引 measuredData.lastMeasuredIndex = index; } return measuredDataMap[index]; }
// 获取可视区域的开始元素的索引 const getStartIndex = (scrollTopWrapper) => { let index = 0; const { itemCount } = props; // 从头开始遍历列表元素的偏移量,直到找到可视区域第一个元素或者找到最后一个元素为止 while (true) { const curOffset = getItemMetaData(index).offset; if (curOffset >= scrollTopWrapper) return index; if (index >= itemCount - 1) return itemCount - 1; index++; } }
// 获取可视区域的结束元素的索引 const getEndIndex = (startIdx) => { const { containerHeight, itemCount } = props; const startItem = getItemMetaData(startIdx); // 计算可视区域的最大偏移量,即当前滚出可视区域的元素的offset值(是有一点偏差的) const maxOffset = containerHeight + startItem.offset; // 预估可视区域最后一个元素的偏移度 let endIdx = startIdx; // curOffset初始化为startIdx下一位元素的偏移 let curOffset = startItem.offset + startItem.size; while(endIdx < (itemCount - 1) && curOffset <= maxOffset) { endIdx++; curOffset += getItemMetaData(endIdx).size; } // 跳出循环时,curOffset是endIdx下一位元素对应的offset return endIdx; }
// 获取渲染列表的起始索引和结束索引(包含了上下缓冲区) const getRangeToRender = (scrollTopWrapper) => { let startIdx = getStartIndex(scrollTopWrapper); // 获取可视区域的起始元素 let endIdx = getEndIndex(startIdx); // 获取可视区域的尾部元素 console.log("real_startIdx:" + startIdx, "real_endIdx:" + endIdx); return { startIdx: Math.max(0, startIdx - 2), // 添加上缓冲区 endIdx: Math.min(props.itemCount - 1, endIdx + 2) // 添加下缓冲区 } }
const showData = ref([]); const getScrollList = () => { const { startIdx, endIdx } = getRangeToRender(scrollTopWrapper.value); // 获取渲染元素的首尾索引,已包含缓冲区元素 const tempArr = []; for (let i = startIdx; i <= endIdx; i++) { const item = getItemMetaData(i); const itemStyle = { height: item.size + 'px', lineHeight: item.size + 'px', transform: `translateY(${item.offset}px)` } tempArr.push({ content: data[i].content, itemStyle }) } showData.value = tempArr; }
watchEffect(() => { getScrollList(); })
</script>
<style> .showItem { width: 100%; position: absolute; } </style>
|