痛点:
我们从数据库获取数据时一般会进行分页,只需要渲染几十条数据,但是在某些场景下需要渲染几千上万条数据,这样就会造成页面卡顿,这时候虚拟化列表就发挥作用了。
vue3的element-plus已经有此功能,可直接引用。vue2的jym可以学习参考本文提供的思路。
虚拟化列表
虚拟化列表就是把数据存在内存中并不渲染,只在有限的区域内渲染有限的数据,简单说就是屏幕能看到那个区域再渲染。
实现思路:
- 将整体数据存在一个数组中,需要时再截取部分数据渲染。
- 监听盒子的滚动事件,使用
event.deltaY获取滚动距离,根据滚动距离与每一行的行高计算显示数据的起始index,根据起始index与区域可承载的数量(显示区域高度/单条数据高度)从整体数据中截取渲染。 - 监听鼠标拖动滚动条事件,根据滚动条的偏移量占比计算出起始index,而后渲染。
- 将滚动条与表格index限制在区域内。
重点在第2点跟第3点。
滚动条与起始index的关系示意图
监听盒子的滚动事件
<template>
<div class="VirtualTable">
<div class="table" :style="{ height: `${tableHeight}px` }" @wheel="scrollTable">
<ul :style="{ height: `${tableHeight}px`, top: listTop }">
<li v-for="(item, index) in tableData" :key="index">
<div>{{ startIndex + index + 1 }}</div>
<div>{{ item.date }}</div>
<div>{{ item.name }}</div>
<div>{{ item.address }}</div>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
name: "VirtualTable",
data() {
return {
totalCount: 1000,
itemHeight: 50,
tableHeight: 500,
tableData: [],
totalTableData: [],
startIndex: 0,
listTop: 0,
};
},
computed: {
visibleCount() {
// 可视区域显示的条数 = 可视区域高度 / 每一条的高度
return Math.ceil(this.tableHeight / this.itemHeight);
},
listHeight() {
// 列表实际的高度 = 总条数 * 每一条的高度
return this.totalTableData.length * this.itemHeight;
},
// 结束索引 = 开始索引 + 可视区域显示的条数
endIndex() {
return this.startIndex + this.visibleCount;
},
},
mounted() {
this.setTotalTableData();
},
methods: {
// 滚动列表事件
scrollTable(event) {
// 计算开始索引 = 滚动的距离 / 每一条的高度 + 开始索引
this.startIndex = Math.floor(event.deltaY / this.itemHeight) + this.startIndex;
if (this.isExceedStartIndex()) {
return;
}
this.setTableData();
},
// 设置列表显示的数据
setTableData() {
// 列表显示的数据 = 总数据.slice(开始索引, 结束索引)
this.tableData = this.totalTableData.slice(this.startIndex, this.endIndex);
},
// 判断开始索引是否超出范围
isExceedStartIndex() {
let flag = true;
// 开始索引的最大值 = 总条数 - 可视区域显示的条数
const maxStartIndex = this.totalTableData.length - this.visibleCount;
if (this.startIndex < 0) {
this.startIndex = 0;
} else if (this.startIndex > maxStartIndex) {
this.startIndex = maxStartIndex;
} else {
flag = false;
}
return flag;
},
// 获取全部数据
async setTotalTableData() {
for (let index = 0; index < this.totalCount; index++) {
this.totalTableData.push({
address: "浙江省 湖州市",
date: "1999-05-11",
name: "王小虎" + index,
});
}
this.setTableData();
},
},
};
</script>
<style lang="less" scoped>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.VirtualTable {
overflow: hidden;
.table {
width: 800px;
margin: 80px auto;
border: 1px solid pink;
position: relative;
ul {
position: absolute;
left: 0;
width: 100%;
padding-right: 17px;
li {
display: flex;
justify-content: space-around;
height: 50px;
line-height: 50px;
border-bottom: 1px solid #ccc;
> div {
width: 25%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
}
}
}
}
</style>
加入滚动条与完整代码
<template>
<div class="VirtualTable">
<div v-loading="loading" class="table" :style="{ height: `${tableHeight}px` }" @wheel="scrollTable">
<ul :style="{ height: `${tableHeight}px`, top: listTop }">
<li v-for="(item, index) in tableData" :key="index">
<div>{{ startIndex + index + 1 }}</div>
<div>{{ item.date }}</div>
<div>{{ item.name }}</div>
<div>{{ item.address }}</div>
</li>
</ul>
<div ref="scrollBar" class="scroll_bar">
<div
class="scroll_thumb"
:style="{ height: `${thumbHeight}px`, transform: `translateY(${thumbOffsetY}px)` }"
@mousedown="mousedown"
></div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "VirtualTable",
data() {
return {
totalCount: 1000,
loading: false,
minThumbHeight: 40,
itemHeight: 50,
tableHeight: 500,
tableData: [],
totalTableData: [],
startIndex: 0,
listTop: 0,
scrollBarRect: {},
thumbOffsetY: 0,
mouseOffsetY: 0,
};
},
computed: {
visibleCount() {
// 可视区域显示的条数 = 可视区域高度 / 每一条的高度
return Math.ceil(this.tableHeight / this.itemHeight);
},
listHeight() {
// 列表实际的高度 = 总条数 * 每一条的高度
return this.totalTableData.length * this.itemHeight;
},
// 结束索引 = 开始索引 + 可视区域显示的条数
endIndex() {
return this.startIndex + this.visibleCount;
},
thumbHeight() {
// 滚动条的高度 = 可视区域高度 / 列表实际的高度 * 可视区域高度
const height = (this.tableHeight / this.listHeight) * this.tableHeight;
return Math.max(height, this.minThumbHeight);
},
},
mounted() {
this.setTotalTableData();
window.addEventListener("resize", this.setBarRect);
this.setBarRect();
},
beforeDestroy() {
window.removeEventListener("resize", this.setBarRect);
this.removeMouseEvent();
},
methods: {
refresh() {
this.startIndex = 0;
this.thumbOffsetY = 0;
this.setTotalTableData();
},
// 设置滚动条位置信息
setBarRect() {
this.scrollBarRect = this.$refs.scrollBar.getBoundingClientRect();
},
mousemove(event) {
event.preventDefault();
event.stopPropagation();
// 计算thumb的偏移量 = 鼠标相对文档的位置 - 鼠标点击在thumb的相对位置 - 滚动条相对文档的位置
this.thumbOffsetY = event.clientY - this.mouseOffsetY - this.scrollBarRect.top;
this.setThumbOffsetLimit();
const length = this.totalTableData.length - this.visibleCount;
// 比率 = thumb偏移量 / (滚动条高度-thumb高度)
const rate = this.thumbOffsetY / (this.scrollBarRect.height - this.thumbHeight);
this.startIndex = Math.round(rate * length);
this.isExceedStartIndex();
this.setTableData();
},
// 限制thumb的偏移量的最大值和最小值
setThumbOffsetLimit() {
// 滚动条最大偏移量 = 滚动条的高度 - thumb的高度
const maxOffsetY = this.scrollBarRect.height - this.thumbHeight;
if (this.thumbOffsetY < 0) {
this.thumbOffsetY = 0;
} else if (this.thumbOffsetY > maxOffsetY) {
this.thumbOffsetY = maxOffsetY;
}
},
mousedown(event) {
// 判断是否是鼠标左键
if (event.button !== 0) return;
this.setBarRect();
// 记录鼠标点击在thumb的相对位置
this.mouseOffsetY = event.offsetY;
document.addEventListener("mousemove", this.mousemove);
document.addEventListener("mouseup", this.mouseup);
},
mouseup() {
this.removeMouseEvent();
},
removeMouseEvent() {
document.removeEventListener("mousemove", this.mousemove);
document.removeEventListener("mouseup", this.mouseup);
},
// 滚动列表事件
scrollTable(event) {
// 计算开始索引 = 滚动的距离 / 每一条的高度 + 开始索引
this.startIndex = Math.floor(event.deltaY / this.itemHeight) + this.startIndex;
this.setThumbOffsetY();
if (this.isExceedStartIndex()) {
return;
}
this.setTableData();
},
// 设置thumb偏移量
setThumbOffsetY() {
// 列表可滚动的列数
const length = this.totalTableData.length - this.visibleCount;
// 起始index的比率
const rate = this.startIndex / length;
// thumb的偏移量 = 起始index比率 * 滚动条可滚动的高度
const offsetY = rate * (this.scrollBarRect.height - this.thumbHeight);
this.thumbOffsetY = offsetY || 0;
this.setThumbOffsetLimit();
},
// 判断开始索引是否超出范围
isExceedStartIndex() {
let flag = true;
// 开始索引的最大值 = 总条数 - 可视区域显示的条数
const maxStartIndex = this.totalTableData.length - this.visibleCount;
if (this.startIndex < 0) {
this.startIndex = 0;
} else if (this.startIndex > maxStartIndex) {
this.startIndex = maxStartIndex;
} else {
flag = false;
}
return flag;
},
// 设置列表显示的数据
setTableData() {
// 列表显示的数据 = 总数据.slice(开始索引, 结束索引)
this.tableData = this.totalTableData.slice(this.startIndex, this.endIndex);
},
// 获取全部数据
setTotalTableData() {
for (let index = 0; index < this.totalCount; index++) {
this.totalTableData.push({
address: "浙江省 湖州市",
date: "1999-05-11",
name: "王小虎" + index,
});
}
this.setTableData();
},
},
};
</script>
<style lang="less" scoped>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.VirtualTable {
overflow: hidden;
.table {
position: relative;
width: 800px;
margin: 80px auto;
border: 1px solid pink;
ul {
position: absolute;
left: 0;
width: 100%;
padding-right: 17px;
li {
display: flex;
justify-content: space-around;
height: 50px;
line-height: 50px;
border-bottom: 1px solid #ccc;
> div {
width: 25%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
}
}
.scroll_bar {
position: absolute;
width: 17px;
height: 100%;
right: 0;
background-color: #ccc;
.scroll_thumb {
position: absolute;
width: 100%;
min-height: 40px;
background-color: #999;
cursor: pointer;
}
}
}
}
</style>
效果图
总结
实现效果较为粗糙,现在只能使用固定的行高,后面将加入动态高度功能。And 如有bug或者建议欢迎评论区指正。