虚拟列表
问题
服务端,一次性给前端 50000 条数据?????
现象
- 简单模拟一下
<script setup lang="js">
import { nextTick, reactive } from 'vue';
const data = reactive([])
const loadData = () => {
data.length = 0 // 这里可以看到 key 的作用
console.time('start')
console.time('render')
for(let i = 0; i < 50000; i++){
data.push(i)
}
console.timeEnd('start')
nextTick(() => {
console.timeEnd('render')
})
// debugger
}
</script>
<template>
<button class="butten" @click="loadData">加载数据</button>
<div class="list-item" v-for="item in data" :key="item">{{ item }}</div>
</template>
<style scoped>
.list-item {
background: gray;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
}
</style>
- 可以看到数据 push 用时 89ms,但是渲染数据要 1.07s,不管什么时候, 你要相信js 永远比dom 快的多。
- 会发现页面空白时间较长,这是不可以接受的
解决方法
1. 分片加载
- 每 500 个渲染
- 修改代码
<script setup lang="js">
import { nextTick, reactive } from 'vue';
let data = reactive([])
const loadData = async () => {
data.length = 0 // 这里可以看到 key 的作用
let cachData = []
let num = 0
let disance = 500
console.time('start')
console.time('render')
for (let i = 0; i < 50000; i++) {
cachData.push(i)
if (cachData.length % disance === 0) {
await new Promise((resolve) => {
setTimeout(() => {
data.push(...cachData.slice(num * disance, num * disance + disance + 1))
num++
resolve()
})
})
}
}
console.log('last-data::', data)
console.timeEnd('start')
nextTick(() => {
console.timeEnd('render')
})
// debugger
}
</script>
<template>
<button class="butten" @click="loadData">加载数据</button>
<div class="list-item" v-for="item in data" :key="item">{{ item }}</div>
</template>
<style scoped>
.list-item {
background: gray;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
}
</style>
- 看效果
- 解决了一开始页面空白的问题
- 但是定时器较多,用户进来不可能一下子全部看完
- await 使用过多会导致性能问题
- 使用 Promise.all进行优化
<script setup lang="js">
import { nextTick, reactive } from 'vue';
let data = reactive([])
const loadData = async () => {
data.length = 0 // 这里可以看到 key 的作用
let cacheData = []
let num = 0
let distance = 500
console.time('start')
console.time('render')
const promises = [];
for (let i = 0; i < 50000; i++) {
cacheData.push(i);
if (cacheData.length % distance === 0) {
const promise = new Promise((resolve) => {
setTimeout(() => {
data.push(...cacheData.slice(num * distance, num * distance + distance + 1));
num++;
resolve();
});
});
promises.push(promise);
}
}
// 使用 Promise.all 来等待所有异步任务完成
await Promise.all(promises);
console.log('last-data::', data)
console.timeEnd('start')
nextTick(() => {
console.timeEnd('render')
})
// debugger
}
</script>
<template>
<button class="butten" @click="loadData">加载数据</button>
<div class="list-item" v-for="item in data" :key="item">{{ item }}</div>
</template>
<style scoped>
.list-item {
background: gray;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
}
</style>
2. 虚拟列表
Vue 虚拟列表原理解析
在前端开发中,处理大型数据集合的列表渲染是一项常见的挑战。虚拟列表是一种优化手段,通过只渲染当前视窗可见部分的数据,降低了性能开销。本文将深入探讨虚拟列表的原理,特别是在 Vue 中的实现方式。
基本原理
虚拟列表的基本原理是根据用户滚动的位置,动态计算当前可见区域的数据,并只渲染这一部分数据。具体步骤如下:
计算可见区域
根据滚动条的偏移量,计算出当前可见区域应该从哪一个数据项开始渲染,这个位置也称为起始索引。
startIndex = Math.floor(scrollTop / itemHeight);
计算结束索引
根据起始索引、渲染数量和缓冲数量等信息,计算出当前可见区域的结束索引。
endIndex = Math.min(startIndex + renderCount + bufferCount, data.length);
提取可见数据
将起始索引到结束索引之间的数据项提取出来,更新列表的渲染数据。
visibleData = data.slice(startIndex, endIndex);
设置偏移
通过设置容器元素的 transform 属性,实现将列表项向上或向下偏移的效果,确保只显示当前可见的数据。
listHeight.style.transform = `translateY(${startIndex * itemHeight}px)`;
如果不加 listHeight.value.style.transform 这一行,可能会导致虚拟滚动列表在滚动时不会正确更新显示的数据。这行代码的作用是根据当前滚动位置计算应该渲染哪一部分的数据,并通过设置 transform 属性实现虚拟滚动的效果。
具体而言,这行代码实现了以下几个功能:
- 计算起始索引: 根据滚动条的偏移量,计算出当前可见区域应该从哪一个数据项开始渲染。
- 计算结束索引: 根据起始索引、渲染数量和缓冲数量等信息,计算出当前可见区域的结束索引。
- 更新可见数据: 将起始索引到结束索引之间的数据项提取出来,更新
visibleData的值,从而确保只有当前可见的数据项会被渲染。 - 设置偏移: 通过设置
listHeight元素的transform属性,实现将列表项向上或向下偏移的效果,从而显示正确的数据区域。
如果不执行这些操作,可能会导致滚动时列表显示混乱,用户无法正确看到预期的数据。
看效果
完整代码
<template>
<div class="list-box" ref="listBox" @scroll="scrollHandler">
<div
class="list-height"
ref="listHeight"
:style="{ height: `${totalHeight}px` }"
>
<div
class="list-item"
v-for="item in visibleData"
:key="item.id"
:style="{
height: `${itemHeight}px`,
transform: `translateY(${item.offsetTop}px)`,
}"
>
{{ item.value }}
</div>
</div>
</div>
</template>
<script setup lang="js">
import { ref, onMounted } from "vue";
const totalHeight = ref(0);
const listHeight = ref('');
const listBox = ref('')
const itemHeight = 40; // 每个项的高度
const renderCount = 10; // 渲染的项数
const bufferCount = 5; // 缓冲的项数
const startIndex = ref(0); // 起始索引
const endIndex = ref(0); // 结束索引
const visibleData = ref([]); // 当前可见的数据
const data = ref([]); // 模拟的列表数据
const initData = () => {
// 模拟数据
for (let i = 0; i < 50000; i++) {
data.value.push({ id: i, value: `Item ${i + 1}` });
}
};
const calculateTotalHeight = () => {
// 计算容器总高度
totalHeight.value = data.value.length * itemHeight;
};
const updateVisibleData = () => {
// 滚懂条的偏移量
const scrollTop = listBox.value.scrollTop;
// 滚动的位置距离顶部的距离能容纳多少 item
startIndex.value = Math.floor(scrollTop / itemHeight);
endIndex.value = Math.min(
Math.ceil(scrollTop / itemHeight + renderCount + bufferCount),
data.value.length
);
// 更新可视区域的数据
visibleData.value = data.value.slice(startIndex.value, endIndex.value);
listHeight.value.style.transform = `translateY(${
startIndex.value * itemHeight
}px)`;
};
const scrollHandler = () => {
updateVisibleData();
};
onMounted(() => {
initData();
calculateTotalHeight();
updateVisibleData();
});
</script>
<style scoped>
.list-item {
background: #f0f0f0;
border-bottom: 1px solid #ddd;
line-height: 40px;
text-align: center;
}
.list-box {
height: 300px;
overflow-y: auto;
}
.list-height {
position: relative;
will-change: transform;
}
</style>