背景
实际开发过程中,我们会遇到长列表的渲染需求,用户想要将所有数据加载过来,用户觉得方便也好快也好,总之翻页、添加加载更多按钮等替代方案,用户都不想要。而那么多数据,不说一下子请求过来耗费时间,浏览器一下子渲染那么多元素,页面也会造成很严重的卡顿,特别是在低配电脑表现更加明显,如果你的用户还只用IE,那岂不是需要很好的耐心。为了优化页面性能,我们必须得用虚拟长列表来优化性能了,如果你也有同样的困扰,可以详看本文浅见,若急需解决针对 vue 已有现成的插件供你使用。若你除了会用还想一探究竟,可以继续看下去。希望你有所收获。
本质
虚拟长列表本质其实是:只展示屏幕可见范围内的列表即可,对屏外内容不渲染。DOM 渲染元素减少,性能就优化了。 我们会有这样疑问:渲染数据减少,滚动条长度也会发生变化,用户如何拖拽滚动条查看下方数据? 答:我们通过计算整体数据滚动条长度,附加给我们一个元素的高度,所以我们需要单独写一个真实数据的滚动条。
列表每一项高度一样
要素
虚拟列表所需要参数:一个列表高度 size 、屏幕内需要展示多少个 remain、列表数据 list
效果
用法
// 使用
// ....
<zr-virtual-list :size="50" :remain="10" :list="virtualList">
<div class="virtual-list" slot-scope="{ item }" :item="{item}" >{{item.item}}</div>
</zr-virtual-list>
// ....
代码
<template>
<div class="zr-virtual-viewport" ref="viewport" :style="viewPortHeight" @scroll="handlerScroll">
<div class="zr-virtual-scroll" :style="scrollHeight"></div>
<div class="zr-virtual-content" ref="content">
<div v-for="(item, index) in virtualList" :key="index">
<slot :item="{item}"></slot>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'zr-virtual-list',
props: {
size: {
type: Number,
default: 0,
},
remain: {
type: Number,
default: 0,
},
list: {
type: Array,
default: () => [],
},
},
computed: {
viewPortHeight() {
return `height: ${this.remain * this.size}px`;
},
// 生成滚动条
scrollHeight() {
return `height: ${this.list.length * this.size}px`;
},
// 真实渲染到页面的列表
virtualList() {
return this.list.slice(this.start, this.end);
},
},
data() {
return {
start: 0,
end: this.remain,
};
},
methods: {
// 滚动条监听
handlerScroll() {
const top = this.$refs.viewport.scrollTop;
this.start = Math.floor(top / this.size); // 计算滚动条滑动了多少个列表
this.end = this.start + this.remain; // 最后列表元素为第几个
//内容发生改变,content 距离头部距离也需要改变
this.$refs.content.style.top = `${this.start * this.size}px`;
},
},
};
</script>
<style lang="scss">
.zr-virtual-viewport {
position: relative;
overflow-y: scroll;
border: 1px solid #efefef;
.zr-virtual-content {
position: absolute;
left: 0;
right: 0;
top: 0;
}
}
</style>
优化
看上面效果图,滑快的化,白屏会出现,为了优化白屏,我们需要预渲染,即除了可视平,头和尾都需要多一部分 remain 数据。 优化代码在 33、38、48、65行
<template>
<div class="zr-virtual-viewport" ref="viewport" :style="viewPortHeight" @scroll="handlerScroll">
<div class="zr-virtual-scroll" :style="scrollHeight">
</div>
<div class="zr-virtual-content" ref="content">
<div v-for="(item, index) in virtualList" :key="index">
<slot :item="{item}"></slot>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'zr-virtual-list',
props: {
size: {
type: Number,
default: 0,
},
remain: {
type: Number,
default: 0,
},
list: {
type: Array,
default: () => [],
},
},
computed: {
// 向前多一屏 即 remain
// 若直接 prev = remain
// 需要考虑 start < remain 的情况,此时 this.list.slice() 会从负数开始,
prev() {
// start > remain ; prev = remain
// start < remain ; prev = start
return Math.min(this.start, this.remain); // 0 10 -> 故取最小
},
next() {
// 当 滑动到最后一个元素的时候,已经没有数据了,所以 next 需要处理;
return Math.min(this.remain, this.list.length - this.end); // 10, 90000
},
viewPortHeight() {
return `height: ${this.remain * this.size}px`;
},
scrollHeight() {
return `height: ${this.list.length * this.size}px`;
},
virtualList() {
const start = this.start - this.prev; // 真实开始 start - 向前预渲染个数
const end = this.end + this.next; // 真实结束是 end + 向后预渲染个数
return this.list.slice(start, end);
},
},
data() {
return {
start: 0,
end: this.remain,
};
},
methods: {
handlerScroll() {
const top = this.$refs.viewport.scrollTop;
this.start = Math.floor(top / this.size);
this.end = this.start + this.remain;
// 真实元素的 start 是 this.start - this.prev
this.$refs.content.style.top = `${(this.start - this.prev) * this.size}px`;
},
},
};
</script>
<style lang="scss">
$-border-color: #efefef;
.zr-virtual-viewport {
position: relative;
overflow-y: scroll;
border: 1px solid #efefef;
.zr-virtual-content {
position: absolute;
left: 0;
right: 0;
top: 0;
}
}
</style>
列表每一项高度不固定
实际上,我们遇到的是元素高度往往是不固定的,我们无法确切知道 size 高度,所以为了解决这种情况,我们需要进一步优化
要素
虚拟列表所需要参数:一个列表高度 size 、屏幕内需要展示多少个 remain、列表数据 list, 还需要 variable 表示需要高度不固定;
效果
### 代码 其主要思想: 通过默认预期给的列表高度,我们需要一个缓存列表,计算出每个元素 top bottom height 具体位置。 82行 已知当前滚动条距离顶部距离,在通过二分查找法找到 start 值(滚动条对应的列表的下标),从而计算出整个容器高度,并渲染出来。90行 页面渲染,即可视数据发生改变时,执行 updated 钩子函数 59行 此时找到可视 DOM 元素,计算出每个可视列表的真实高度,找到每个元素对应的缓存列表进行替换,方便下次二分查找的使用。在计算出滚动条高度,防止错位。<template>
<div class="zr-virtual-viewport" ref="viewport" :style="viewPortHeight" @scroll="handlerScroll">
<div class="zr-virtual-scroll" ref="scroll" :style="scrollHeight">
</div>
<div class="zr-virtual-content" ref="content">
<div v-for="(item, index) in virtualList" ref="list" :key="index" :id="item.id">
<slot :item="{item}"></slot>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'zr-virtual-list',
props: {
size: {
type: Number,
default: 0,
},
remain: {
type: Number,
default: 0,
},
list: {
type: Array,
default: () => [],
},
variable: {
type: Boolean,
default: false,
},
},
computed: {
prev() {
return Math.min(this.start, this.remain); // 0 10 -> 0
},
next() {
return Math.min(this.remain, this.list.length - this.end); // 10, 90000
},
viewPortHeight() {
return `height: ${this.remain * this.size}px`;
},
scrollHeight() {
return `height: ${this.list.length * this.size}px`;
},
virtualList() {
const start = this.start - this.prev;
const end = this.end + this.next;
return this.list.slice(start, end);
},
},
data() {
return {
start: 0,
end: this.remain,
};
},
updated() {
this.$nextTick(() => {
const nodes = this.$refs.list;
if (!(nodes && nodes.length > 0)) {
return;
}
nodes.forEach((item) => {
const { height } = item.getBoundingClientRect();
const id = item.getAttribute('id') - 0;
const oldHeight = this.position[id].height; // 从缓存列表 position 读取,向上滚动,val 差值 为 0
const val = oldHeight - height;
if (val) {
this.position[id].height = height;
this.position[id].bottom = this.position[id].bottom - val;
for (let i = id + 1; i < this.list.length; i++) { // id 后的每一项 top bottom 都会发生改变
this.position[i].top = this.position[i - 1].bottom; // 下一项头 = 上一项的尾
this.position[i].bottom = this.position[i].bottom - val; // 下一项的尾 = 下一项尾 - 差值
}
}
});
this.$refs.scroll.style.height = `${this.position[this.position.length - 1].bottom}px`; // 重新计算滚动条高度
});
},
methods: {
cacheList() {
this.position = this.list.map((item, index) => ({
height: this.size,
top: index * this.size,
bottom: (index + 1) * this.size,
}));
},
// 二分查找 有序列表查找
getStartIndex(top) {
let start = 0;
let end = this.list.length - 1;
let temp = null;
while (start <= end) {
const middleIndex = parseInt((end + start) / 2);
const middleValue = this.position[middleIndex].bottom;
if (middleValue === top) {
return middleIndex + 1;
} else if (middleValue < top) { // 右边
start = middleIndex + 1;
} else if (middleValue > top) { // 左边
if (temp === null || temp > middleIndex) {
temp = middleIndex;
}
end = middleIndex - 1;
}
}
return temp;
},
handlerScroll() {
const top = this.$refs.viewport.scrollTop;
if (this.variable) {
this.start = this.getStartIndex(top);
this.end = this.start + this.remain;
this.$refs.content.style.top = this.position[this.start - this.prev] ? `${this.position[this.start - this.prev].top}px` : 0;
} else {
// 固定高度列表
this.start = Math.floor(top / this.size);
this.end = this.start + this.remain;
this.$refs.content.style.top = `${(this.start - this.prev) * this.size}px`;
}
},
},
mounted() {
this.cacheList();
},
};
</script>
<style lang="scss">
$-border-color: #efefef;
.zr-virtual-viewport {
position: relative;
overflow-y: scroll;
border: 1px solid #efefef;
.zr-virtual-content {
position: absolute;
left: 0;
right: 0;
top: 0;
}
}
</style>
二分查找
虚拟列表所使用的算法为二分查找,来找到当前滚动条对应的第几个元素。抛开上面代码,单独写一个二分查找算法。
实现
function binarySearch(arr, value){
let start = 0;
let end = arr.length - 1;
while(start <= end) {
let mIndex = parseInt(start + (end - start) / 2); // 大数溢出
let mValue = arr[mIndex];
if (mValue === value) {
return mIndex
} else if (mValue < value) {
start = mIndex + 1;
} else if (mValue > value) {
end = mIndex - 1;
} else {
return -1;
}
}
}
// 需要注意二分查找,列表需要是有序的。
const arr = [1,20,22,34,50,60,78,100,120,459]
const index = binarySearch(arr, 34)
console.log(index) // 3
时间复杂度
怎么求二分查找的时间复杂度?
提前高中数学知识:指数与对数之间的转化公式 a^b=N 等价于 loga^N=b
已知数组长度为 n, 第一次为 n/2, 第二次为 n/4,第三次是 n/8, ....可以推算出 n 与 次数 k 之间的关系。
当 n/2k = 1 为 2k=n(1 表示找到找到)的时候,指数对数转化等价于 k = log2^n
故 时间复杂度 O为 log2^n 是效率比较高的查找算法,但是需要列表是 有序的。
function binarySearch(arr, value){
let start = 0;
let end = arr.length - 1;
while(start <= end) {
let mIndex = parseInt(start + (end - start) / 2);
let mValue = arr[mIndex];
if (mValue < value) {
start = mIndex + 1;
} else if (mValue > value) {
end = mIndex - 1;
} else {
// 若m是最后一个元素,则直接返回当前找到的mindex
// 若不是最后一个元素,但mindex下一个元素不跟 value相等,则也直接放回
if (mIndex == arr.length - 1 || (arr[mIndex+1]!= value)) return mIndex
// m 不是最后一个元素,且 mindex 下一个元素跟 value 相等,则start 往后移动,重新比对
else start = mIndex + 1
}
}
return -1;
}
const arr = [1,20,22,34,50,60,78,100,120,459, 459, 1000,1000,100,1000]
const index = binarySearch(arr, 1000)
console.log(index) // 14 下标
虚拟列表二叉树
回归到主题,虚拟列表,通过传入当前滚动条距离顶部距离(极大可能性不为数组中某一项)我们找的是一个范围
function binarySearch(arr, value){
let start = 0;
let end = arr.length - 1;
let temp = null;
while(start <= end) {
let mIndex = parseInt(start + (end - start) / 2);
let mValue = arr[mIndex];
if (mValue < value) {
start = mIndex + 1;
} else if (mValue > value) {
if (temp === null || temp > mIndex){
temp = mIndex
}
end = mIndex - 1;
} else if (mValue === value) {
return mIndex + 1;
}
}
return temp;
}
// 数组中存放列表每一项的底 bottom 距离容器头的距离。
// 此时滚动条高度在 55,数据还在可视范围的是 60元素,即下标值为 6,
// 可以分析找的永远是 会小于当前元素, 故会讲逻辑写到 上面 11 行中
const arr = [1,1, 20,22,34,50,60,78,100,120,459, 459, 1000,1000,1000]
const index = binarySearch(arr, 55)
console.log(index) // 6 第六个元素60 所在下标即会滚动条需要渲染的开始点
节流
监听滚动条,会频繁的进行计算,此时不用节流何时用 深入浅出节流函数
function throttle (fn, wait, opts) {
let args, context;
let previous = 0;
let timer = null;
const laster = () => {
previous = opts.leading === false ? 0 : Date.now(); // leading = false, 则重置 previous 为0,再次开始执行,会走定时器触发执行
fn.apply(context, args);
}
const throttleFn = () => {
args = arguments;
context = this;
const now = Date.now();
if(!previous && opts.leading === false) previous = now; // leading 为 false 且 previous = 0 表示第一次点击,则走 定时器执行方法
const remain = wait - (now - previous)
if (remain <= 0 ) {
if (timer) {
clearTimeout(timer);
timer = null;
}
previous = now;
fn.apply(context, arguments)
} else if(opts.trailing !== false && !timer){
timer = setTimeout(laster, remain)
}
}
return throttleFn;
}
const btn = document.getElementById('button')
btn.addEventListener('click', throttle(btnClick, 1000, { leading: false }));
// leading false 第一次不执行, true 第一次执行
// trailing false 最后一次不执行,true最后一次执行
总结
虚拟长列表,性能优化点实际是不全部渲染所有数据,根据用户滚动条高度,在通过二分查找法,找到列表实际渲染下标,进而动态更新容器高度,滚动条高度。