为什么需要虚拟化列表?
先来看看,正常情况下一次性渲染10万行数据,浏览器需要消耗的时间:
渲染阶段耗时超过了2秒,这还只是最简单的文字渲染,在真实业务中往往会更加复杂,如果不进行优化,这将是无法接受的。
什么是虚拟化?
我也是偶然接触到这个概念的,第一次接触是在 vscode.dev ,闲来无事我按了F12,想膜拜一下大佬风范,结果就发现了神奇的一幕:编辑器内容并不是全部渲染的,而是根据我滚动的位置,显示特定位置的内容,就好像编辑器右边有一个进度展示,其中高亮的区域就是编辑器当前展示的区域,也是编辑器渲染的内容,对于超出部分不进行渲染,难怪vscode可以秒开大文件还不卡,我想很大一部分原因在这里了。第二次是在看 Element Plus 的文档的时候,注意到多出了一个虚拟化select组件和虚拟化table组件。
由此可见,虚拟化其实就是按需渲染的一种实现手段,只对可见区域及缓冲区域的数据进行渲染,对不可见区域的数据不进行渲染。
代码实现
通过以上对虚拟化的认识,我们只需要渲染可见区域内的数据,所以将html设计为如下结构:
<div class="container">
<div class="block"></div>
<ul class="render">
<li>item 1 ...</li>
<li>item 2 ...</li>
<li>item 3 ...</li>
<li>...</li>
<li>item n ...</li>
</ul>
</div>
container
为列表容器,用于包裹整个列表。block
仅用于高度填充,使滚动条能够正常工作。render
用于包裹当前渲染的数据。
实现方案为
计算列表总高度
item的高度 * item的数量
计算当前的起始index和结束index并截取相应的数据进行渲染。
滚动的距离 / item的高度 = 开始的index
可视区域的高度 / item的高度 = 可视的item数量
开始的index + 可视的item数量 = 结束的index
计算渲染容器的offset,防止跳跃闪烁
滚动的距离 - (滚动的距离 % item的高度) 取余是为了防止闪烁
监听容器的滚动事件,滚动发生时重新计算
代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Virtualization List</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
.container { width: 400px; margin: auto; margin-top: 100px; overflow: auto; position: relative; border: 1px solid gray; }
.container >.block { position: absolute; z-index: -1; top: 0; left: 0; width: 100%; }
.container >ul { position: absolute; top: 0; left: 0; width: 100%; }
.container >ul >li { background: #fff; border: 1px solid #eee; }
</style>
</head>
<!-- 解决闪烁的问题 -->
<body style="visibility: hidden;">
<div id="app">
<div class="container" :style="{height: `${renderHeight}px`}" @scroll="scrollEvent($event)">
<div class="block" :style="{height: `${listHeight}px`}"></div>
<ul class="render" :style="{transform: `translateY(${offsetDistance}px)`}">
<li v-for="content in visibleData">{{content}}</li>
</ul>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp, ref, onMounted, shallowRef, reactive, computed } = Vue;
createApp({
setup(){
//获取虚拟文字段落 ↓
function getMockData(index){
let count = 10;
return 'index: ' + index + ' **** '.repeat(count);
}
//列表数据 ↓
const listData = ref(Array.from({length: 100000}).map((item, index) => getMockData(index)));
//单项高度 ↓
const ceilHeight = ref(44);
//可视区域高度 ↓
const renderHeight = ref(500);
//起始index ↓
const startIndex = ref(0);
//结束index ↓
const endIndex = ref(0);
//偏移量 ↓
const offsetDistance = ref(0);
//列表总高度 ↓
const listHeight = computed(() => listData.value.length * ceilHeight.value);
//可视区item数量 ↓ 向上取整
const visibleCount = computed(() => Math.ceil(renderHeight.value / ceilHeight.value));
//当前可显示的列表数据
const visibleData = computed(() => listData.value.slice(startIndex.value, Math.min(endIndex.value, listData.value.length)));
onMounted(() => {
//解决闪烁的问题
document.body.style.visibility = 'visible';
startIndex.value = 0;
endIndex.value = startIndex.value + visibleCount.value;
})
//滚动事件相关 ↓
function scrollEvent(e) {
//当前滚动的距离 ↓
const scrollTop = e.target.scrollTop;
//计算开始index ↓
startIndex.value = Math.floor(scrollTop / ceilHeight.value);
//计算结束index ↓
endIndex.value = startIndex.value + visibleCount.value;
//计算偏移量(防止闪烁跳跃) ↓
offsetDistance.value = scrollTop - (scrollTop % ceilHeight.value);
}
return {
visibleData,
getMockData,
renderHeight,
listHeight,
offsetDistance,
scrollEvent
}
}
}).mount('#app')
</script>
</body>
</html>