各位靓仔靓女,早上好!我是你们的老朋友,今天咱们来聊点硬核的——Vue 虚拟滚动,而且是Plus版,带动态高度、可变列和无限加载的那种。准备好了吗?系好安全带,发车咯!
一、 虚拟滚动:解决大数据量渲染难题的瑞士军刀
首先,我们要搞清楚,为啥要用虚拟滚动?想象一下,你要展示10万条数据,直接一股脑丢给浏览器,那画面太美我不敢看。浏览器直接卡成PPT,用户体验瞬间跌入谷底。
虚拟滚动的核心思想是:只渲染可见区域的内容。就好比你逛书店,你只会看到书架上你视线范围内的书,而不是把整个图书馆的书都搬出来。
简单来说,就是根据滚动条的位置,计算出当前应该显示哪些数据,然后动态更新DOM。这样,无论数据量有多大,页面上始终只渲染有限的几个元素,性能自然就杠杠的。
二、 动态高度:让每一行都拥有独特的灵魂
传统的虚拟滚动,通常假设每一行的高度都是固定的。但现实总是残酷的,总有一些奇葩数据,比如超长的文本、复杂的图片等等,导致每一行的高度都不一样。
怎么办?动态高度就派上用场了。我们需要记录每一行的高度,然后根据这些高度来计算滚动条位置和可见区域。
1. 记录行高:埋下成功的种子
首先,我们需要一个地方来存放每一行的高度。我们可以用一个数组来存储,数组的索引对应数据的索引。
data() {
return {
rowHeights: [], // 存储每一行的高度
// ...其他数据
}
},
2. 获取行高:抓住问题的关键
我们需要在每一行渲染完成后,获取它的实际高度,并更新rowHeights
数组。这里我们可以使用$nextTick
来确保DOM已经渲染完成。
<template>
<div class="virtual-list" ref="virtualList">
<div
v-for="(item, index) in visibleData"
:key="item.id"
:style="{ height: rowHeights[index] + 'px' }"
ref="rowElements"
>
{{ item.content }}
</div>
</div>
</template>
<script>
export default {
mounted() {
this.observeRows(); // 监听行高变化
},
methods: {
observeRows() {
const observer = new ResizeObserver(entries => {
entries.forEach(entry => {
const index = Array.from(this.$refs.rowElements).indexOf(entry.target);
if (index !== -1) {
this.rowHeights[index] = entry.contentRect.height;
this.$forceUpdate();
}
});
});
this.$nextTick(() => {
Array.from(this.$refs.rowElements).forEach(el => {
observer.observe(el);
});
});
}
}
}
</script>
这里使用了 ResizeObserver
来监听每一个 rowElements
的大小变化,可以更准确地获取高度,避免在复杂情况下获取高度不准确的问题。
3. 计算滚动条高度:掌握全局
滚动条的总高度,需要根据所有行的高度之和来计算。
computed: {
scrollHeight() {
return this.rowHeights.reduce((acc, cur) => acc + cur, 0);
},
// ...其他计算属性
},
4. 计算可见区域:有的放矢
根据滚动条的位置,计算出可见区域的起始索引和结束索引。
computed: {
visibleData() {
const startIndex = this.getStartIndex();
const endIndex = this.getEndIndex(startIndex);
return this.data.slice(startIndex, endIndex + 1);
},
// ...其他计算属性
},
methods: {
getStartIndex() {
let scrollTop = this.$refs.virtualList.scrollTop;
let sumHeight = 0;
for (let i = 0; i < this.rowHeights.length; i++) {
sumHeight += this.rowHeights[i];
if (sumHeight >= scrollTop) {
return i;
}
}
return 0;
},
getEndIndex(startIndex) {
let sumHeight = 0;
for (let i = startIndex; i < this.rowHeights.length; i++) {
sumHeight += this.rowHeights[i];
if (sumHeight >= this.$refs.virtualList.clientHeight) {
return i;
}
}
return this.data.length - 1;
}
}
三、 可变列:让表格不再单调
有时候,我们需要展示表格数据,而且每一列的宽度还不一样。这就需要我们动态计算每一列的宽度,并应用到对应的单元格上。
1. 定义列配置:心中有数
首先,我们需要定义一个列配置对象,包含每一列的宽度、标题等信息。
data() {
return {
columns: [
{ key: 'name', title: '姓名', width: 100 },
{ key: 'age', title: '年龄', width: 80 },
{ key: 'address', title: '地址', width: 200 },
// ...更多列
],
// ...其他数据
}
},
2. 动态设置列宽:灵活应变
在渲染单元格时,根据列配置对象,动态设置每一列的宽度。
<template>
<div class="virtual-table">
<div class="table-header">
<div
v-for="column in columns"
:key="column.key"
:style="{ width: column.width + 'px' }"
>
{{ column.title }}
</div>
</div>
<div class="table-body" ref="virtualList">
<div
v-for="(item, index) in visibleData"
:key="item.id"
class="table-row"
:style="{ height: rowHeights[index] + 'px' }"
>
<div
v-for="column in columns"
:key="column.key"
:style="{ width: column.width + 'px' }"
>
{{ item[column.key] }}
</div>
</div>
</div>
</div>
</template>
3. 自适应列宽:锦上添花
如果希望列宽能够自适应内容,可以使用CSS的table-layout: auto
属性。但是,这种方式可能会导致性能问题,特别是当表格内容非常复杂时。
另一种方式是,在渲染完成后,动态计算每一列的最大宽度,然后应用到对应的单元格上。
mounted() {
this.$nextTick(() => {
this.calculateColumnWidths();
});
},
methods: {
calculateColumnWidths() {
const headerCells = document.querySelectorAll('.table-header > div');
const bodyCells = document.querySelectorAll('.table-body .table-row > div');
const columnWidths = this.columns.map(() => 0);
headerCells.forEach((cell, index) => {
columnWidths[index] = Math.max(columnWidths[index], cell.offsetWidth);
});
bodyCells.forEach((cell, index) => {
const columnIndex = index % this.columns.length;
columnWidths[columnIndex] = Math.max(columnWidths[columnIndex], cell.offsetWidth);
});
this.columns.forEach((column, index) => {
column.width = columnWidths[index];
});
this.$forceUpdate();
},
}
四、 无限加载:永无止境的数据流
当数据量非常庞大时,一次性加载所有数据显然是不现实的。我们需要实现无限加载,即当滚动条滚动到底部时,自动加载更多数据。
1. 监听滚动事件:时刻准备着
我们需要监听滚动事件,判断滚动条是否滚动到底部。
mounted() {
this.$refs.virtualList.addEventListener('scroll', this.handleScroll);
},
beforeDestroy() {
this.$refs.virtualList.removeEventListener('scroll', this.handleScroll);
},
methods: {
handleScroll() {
const scrollTop = this.$refs.virtualList.scrollTop;
const clientHeight = this.$refs.virtualList.clientHeight;
const scrollHeight = this.scrollHeight;
if (scrollTop + clientHeight >= scrollHeight - 20) { // 提前20像素加载
this.loadMoreData();
}
},
// ...其他方法
}
2. 加载更多数据:添砖加瓦
当滚动条滚动到底部时,调用loadMoreData
方法,加载更多数据。
methods: {
loadMoreData() {
if (this.isLoading || this.isAllLoaded) {
return;
}
this.isLoading = true;
setTimeout(() => { // 模拟异步加载
const newData = this.generateData(this.data.length, 20);
this.data = this.data.concat(newData);
this.isLoading = false;
if (this.data.length >= 1000) {
this.isAllLoaded = true;
}
}, 500);
},
// ...其他方法
}
3. 加载状态:让用户知道发生了什么
在加载数据时,我们需要显示加载状态,让用户知道正在加载更多数据。
<template>
<div class="virtual-list" ref="virtualList">
<div
v-for="(item, index) in visibleData"
:key="item.id"
:style="{ height: rowHeights[index] + 'px' }"
>
{{ item.content }}
</div>
<div class="loading-indicator" v-if="isLoading">
Loading...
</div>
<div class="no-more-data" v-if="isAllLoaded">
No more data
</div>
</div>
</template>
五、 性能优化:精益求精
虚拟滚动虽然已经很强大了,但我们还可以进一步优化性能。
1. 减少不必要的渲染:避免无效劳动
尽量避免不必要的渲染。比如,只有当可见区域发生变化时,才需要更新DOM。可以使用shouldComponentUpdate
或PureComponent
来避免不必要的渲染。在Vue中可以使用computed
的getter/setter
结合watch
来实现类似的功能。
2. 使用缓存:空间换时间
可以使用缓存来存储已经计算过的行高、滚动条位置等信息,避免重复计算。
3. 使用Web Workers:释放主线程
如果计算量非常大,可以使用Web Workers将计算任务放到后台线程中执行,避免阻塞主线程。
六、 代码示例:让理论落地
下面是一个完整的代码示例,包含了动态高度、可变列和无限加载功能。
<template>
<div class="virtual-table" ref="virtualList">
<div class="table-header">
<div
v-for="column in columns"
:key="column.key"
:style="{ width: column.width + 'px' }"
>
{{ column.title }}
</div>
</div>
<div class="table-body" @scroll="handleScroll">
<div
v-for="(item, index) in visibleData"
:key="item.id"
class="table-row"
:style="{ height: rowHeights[getVisibleIndex(index)] + 'px', top: getOffset(getVisibleIndex(index)) + 'px' }"
ref="rowElements"
>
<div
v-for="column in columns"
:key="column.key"
:style="{ width: column.width + 'px' }"
>
{{ item[column.key] }}
</div>
</div>
</div>
<div class="loading-indicator" v-if="isLoading">Loading...</div>
<div class="no-more-data" v-if="isAllLoaded">No more data</div>
</div>
</template>
<script>
export default {
data() {
return {
columns: [
{ key: 'id', title: 'ID', width: 50 },
{ key: 'name', title: '姓名', width: 100 },
{ key: 'age', title: '年龄', width: 80 },
{ key: 'address', title: '地址', width: 200 },
{ key: 'description', title: '描述', width: 300 },
],
data: [],
rowHeights: [],
visibleDataCount: 20, // 可见区域的行数
isLoading: false,
isAllLoaded: false,
startIndex: 0, //起始索引
};
},
computed: {
visibleData() {
return this.data.slice(this.startIndex, this.startIndex + this.visibleDataCount);
},
},
mounted() {
this.loadInitialData();
},
methods: {
loadInitialData() {
this.isLoading = true;
setTimeout(() => {
this.data = this.generateData(0, 50);
this.rowHeights = new Array(this.data.length).fill(50); // 初始高度
this.$nextTick(() => {
this.observeRows();
this.isLoading = false;
});
}, 500);
},
observeRows() {
const observer = new ResizeObserver(entries => {
entries.forEach(entry => {
const index = Array.from(this.$refs.rowElements).indexOf(entry.target);
if (index !== -1) {
this.rowHeights[this.startIndex + index] = entry.contentRect.height;
}
});
});
this.$nextTick(() => {
Array.from(this.$refs.rowElements).forEach(el => {
observer.observe(el);
});
});
},
handleScroll() {
const scrollTop = this.$refs.virtualList.scrollTop;
const clientHeight = this.$refs.virtualList.clientHeight;
const scrollHeight = this.$refs.virtualList.scrollHeight;
// 计算新的startIndex
let newStartIndex = 0;
let sumHeight = 0;
for (let i = 0; i < this.data.length; i++) {
sumHeight += this.rowHeights[i];
if (sumHeight >= scrollTop) {
newStartIndex = Math.max(0, i - 5); // 提前渲染5行
break;
}
}
if (newStartIndex !== this.startIndex) {
this.startIndex = newStartIndex;
}
if (scrollTop + clientHeight >= scrollHeight - 20 && !this.isLoading && !this.isAllLoaded) {
this.loadMoreData();
}
},
loadMoreData() {
if (this.isLoading || this.isAllLoaded) {
return;
}
this.isLoading = true;
setTimeout(() => {
const newData = this.generateData(this.data.length, 50);
this.data = this.data.concat(newData);
this.rowHeights = this.rowHeights.concat(new Array(newData.length).fill(50)); // 初始高度
this.$nextTick(() => {
this.observeRows();
this.isLoading = false;
if (this.data.length >= 500) {
this.isAllLoaded = true;
}
});
}, 500);
},
generateData(start, count) {
const data = [];
for (let i = start; i < start + count; i++) {
data.push({
id: i,
name: `姓名${i}`,
age: Math.floor(Math.random() * 50) + 20,
address: `地址${i}`,
description: `这是一段很长的描述${i},用于测试动态高度。内容可能比较长,也可能比较短,总之就是为了测试高度不一致的情况。`,
});
}
return data;
},
getVisibleIndex(index) {
return index;
},
getOffset(index) {
let offset = 0;
for (let i = 0; i < this.startIndex + index; i++) {
offset += this.rowHeights[i];
}
return offset;
},
},
};
</script>
<style scoped>
.virtual-table {
width: 800px;
height: 400px;
border: 1px solid #ccc;
overflow-y: auto;
position: relative;
}
.table-header {
display: flex;
background-color: #f0f0f0;
position: sticky;
top: 0;
z-index: 1;
}
.table-header > div {
padding: 8px;
border-right: 1px solid #ccc;
box-sizing: border-box; /* 确保 padding 不会增加元素的总宽度 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.table-body {
position: relative;
}
.table-row {
display: flex;
position: absolute;
width: 100%;
box-sizing: border-box;
}
.table-row > div {
padding: 8px;
border-right: 1px solid #ccc;
border-bottom: 1px solid #ccc;
box-sizing: border-box; /* 确保 padding 不会增加元素的总宽度 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.loading-indicator,
.no-more-data {
text-align: center;
padding: 10px;
}
</style>
七、 总结:掌握虚拟滚动的真谛
今天我们一起学习了Vue虚拟滚动的高级用法,包括动态高度、可变列和无限加载。希望通过今天的学习,大家能够掌握虚拟滚动的真谛,并在实际项目中灵活运用。
记住,虚拟滚动不是银弹,它也有自己的局限性。在选择使用虚拟滚动时,需要根据实际情况进行权衡。
好了,今天的讲座就到这里。感谢大家的聆听,下次再见!