# 虚拟列表
# 1、简介
在web开发的过程中,或多或少都会遇到大列表渲染的场景,例如全国城市列表、通讯录列表、聊天记录列表等等。当列表数据量为几百条时,依靠浏览器本身的性能基本可以支撑,一般不会出现卡顿的情况。但当列表数量级达到上千,页面渲染或操作就可能会出现卡顿,而当列表数量突破上万甚至十几万时,网页可能会出现严重卡顿甚至直接崩溃。为了解决大列表造成的渲染压力,便出现了虚拟滚动技术。本文主要介绍虚拟滚动的基本原理,以及子项定高的虚拟滚动列表的简单实现。
虽然大列表的数据量很大,但是设备的显示区域是有限的,也就是说在同一时间,用户看到的内容是有限的。利用这一特点,可以将大列表按需渲染。也就是只渲染某一时刻用户看的到的内容,当用户滚动页面时,再通过JS的计算重现调整视窗内的内容,这样可以把列表子项的数量级别从几万降到几十。
借助按需渲染的思想来优化大列表在实现层面可以分成三步,一是确定当前视窗在哪,二是确定当前要真实渲染哪些节点,三是把渲染的节点移动到视窗内。对于问题一,视窗的位置对于长列表来说,其开始位置为列表滚动区域的scrollTop。对于问题二,按照视窗外内容不渲染的思路,则应该渲染数组索引从Math.floor(scrollTop/clientHeight)
开始共Math.ceil(clientHeight/itemHeight)
个元素。对于问题三,有多种实现思路,以下将介绍几种常见虚拟滚动的实现方式。
scrollTop
:列表滚动区域的scrollTopitemHeight
:子节点的高度clientHeight
:视窗的高度
# 2、实现
# 2.1 transform
该方案主要是通过监听滚动区域的滚动事件,动态计算视窗内渲染节点的开始索引以及偏移量,然后重新触发渲染节点的渲染并将内容通过transform属性将该部分内容移动到视窗内。
至于为什么要移到视窗内, 其实显示的内容也可以一直固定在视口内,仅通过滚动距离的计算来更新要显示的元素, 但是这样子会有一个问题就是看不出滚动的效果。
而通过 transform
之后, 内容会跟着滚动,视觉上更自然,只有在滚动距离超过一个子项高度时,才会触发重新渲染, 重新更新数据节点和transform的值。下面的position: absolute
也是一样的道理。
可以查看demo
下面是一个关于原理的图
列表开始渲染的开始值:start = Math.floor(scrollTop / itemHeight)
视口里面需要渲染的个数:count = Math.ceil(clientHeight / itemHeight)
translateY的值:translateY = start * itemHeight
下面是一个简单的Vue2.0的Demo
<template>
<div class="virtual-list-wrapper"
@scroll="onScroll($event)">
<div :style="{height: totalHeight + 'px'}">
<div class="virtual-content"
ref="ContentRef">
<div v-for="item in subList"
:key="item"
class="virtual-item"
:style="{height: itemHeight + 'px', overflow: 'hidden'}">{{item}}</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'VirtualList',
// mixins: [],
// components: {},
props: {
list: {
required: true,
type: Array,
default: []
},
itemHeight: {
required: true,
type: Number,
default: 0
}
},
data() {
return {
start: 0,
count: 0,
totalHeight: 0
};
},
computed: {
subList() {
return this.list.slice(this.start, this.start + this.count);
}
},
// watch:{},
created() {
this.totalHeight = this.itemHeight * this.list.length;
},
mounted() {
this.count = Math.ceil(this.$el.clientHeight / this.itemHeight);
this.ContentEl = this.$refs.ContentRef;
},
// beforeRouteEnter(to, from, next) { next(vm => {}) },
// beforeRouteUpdate(to, from, next) {},
// beforeRouteLeave(to, from, next) {},
// beforeDestroy() {},
// destroyed() {},
methods: {
onScroll(e) {
const scrollTop = e.target.scrollTop;
console.debug(scrollTop);
this.start = Math.floor(scrollTop / this.itemHeight);
this.ContentEl.style.transform = `translate3d(0, ${this.start * this.itemHeight}px, 0)`;
}
}
};
</script>
<style scoped>
.virtual-list-wrapper {
height: 100%;
width: 100%;
overflow-y: auto;
}
.virtual-item {
box-sizing: border-box;
border-bottom: 1px solid #333;
}
</style>
# 2.2 absolute
该方案与transform方案类似,都是通过监听滚动区域的滚动事件,动态的计算要显示的内容。但transform方案显示内容的偏量是动态计算并赋值的,而该方案则是利用absolute属性直接将待渲染的节点定位到其该出现的位置。如果把所有子节点包裹起来的话,父元素的top值为 start * itemHeight,这与视窗位置无关。视窗只决定了要渲染那些子节点,不影响子节点的相对位置。
查看一个Vue2.0的实现demo
<template>
<div class="virtual-list-wrapper"
@scroll="onScroll($event)">
<div :style="{height: totalHeight + 'px'}">
</div>
<div class="virtual-content"
ref="ContentRef"
:style="{
top: start * itemHeight + 'px'
}">
<div v-for="(item) in subList"
:key="item"
class="virtual-item"
:style="{
height: itemHeight + 'px',
overflow: 'hidden'
}">{{item}}</div>
</div>
</div>
</template>
<script>
export default {
name: 'VirtualList',
// mixins: [],
// components: {},
props: {
list: {
required: true,
type: Array,
default: []
},
itemHeight: {
required: true,
type: Number,
default: 0
}
},
data() {
return {
start: 0,
count: 0,
totalHeight: 0
};
},
computed: {
subList() {
return this.list.slice(this.start, this.start + this.count);
}
},
// watch:{},
created() {
this.totalHeight = this.itemHeight * this.list.length;
},
mounted() {
this.count = Math.ceil(this.$el.clientHeight / this.itemHeight);
this.ContentEl = this.$refs.ContentRef;
},
methods: {
onScroll(e) {
const scrollTop = e.target.scrollTop;
console.debug(scrollTop);
const newStart = Math.floor(scrollTop / this.itemHeight);
if (newStart === this.start) {
return;
}
// 只有滚动过一个itemHeight的距离才会触发重新渲染
this.start = newStart;
}
}
};
</script>
<style scoped>
.virtual-list-wrapper {
height: 100%;
width: 100%;
overflow-y: auto;
position: relative;
}
.virtual-content {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
transform: top 1s;
}
.virtual-item {
box-sizing: border-box;
border-bottom: 1px solid #333;
width: 100%;
}
</style>
# 2.3 Padding
该方案与以上两种方案有较大的差别,主要体现在以下两点:一是列表高度撑起的方式不同,以上两种方案的高度是通过设置height = list.length * itemHeight的方式撑起来的,而该方案则是通过paddingTop + paddingBottom + renderHeight的方式来撑起来的。二是列表的重新渲染时机不同,以上两种方案会在Math.floor(scrollTop / itemHeight)值变化时重新渲染,而该方案则是在渲染节点"不够"在视窗内显示时触发。
举个例子,假定视窗一次可以显示10个,同时配置虚拟滚动组件一次渲染50节点,那么当屏幕滚动到第11个时并不需要渲染,因为此时显示的是11-20个节点,而将要显示的21-50已经渲染好了。只有当滚动到第41个的时候才需要重新渲染,因为屏幕外已经没有渲染好的节点了,再滚动就要显示白屏了。根据以上例子进一步的分析临界条件,当前渲染位置为[itemHeight * start, itemHeight * (start + count)],视窗显示的位置为[scrollTop, scrollTop + clientHeight]。
当scrollTop + clientHeight >= itemHeight * (start + count)时,说明视窗显示位置超过了渲染的最大位置,重新触发渲染调整渲染位置,避免底部白屏。
当scrollTop <= itemHeight * start时,说明视窗显示位置不足渲染的最小位置,重新触发渲染调整渲染位置,避免顶部白屏。
这种方式的优点就是可以一次性渲染较多的元素,同时触发重新渲染的次数可以减少
demo
<template>
<div class="virtual-list-wrapper"
@scroll="onScroll($event)">
<div :style="{height: totalHeight + 'px'}">
<div class="virtual-content"
ref="ContentRef"
:style="{
paddingTop: paddingTop + 'px',
}">
<div v-for="(item) in subList"
:key="item"
class="virtual-item"
:style="{
height: itemHeight + 'px',
overflow: 'hidden',
}">
<slot name="listItem" :item="item"></slot>
</div>
</div>
</div>
</div>
</template>
<script>
/* eslint-disable */
export default {
name: 'VirtualList',
// mixins: [],
// components: {},
props: {
list: {
required: true,
type: Array,
default: []
},
itemHeight: {
required: true,
type: Number,
default: 0
},
count: {
required: true,
type: Number,
default: 0
}
},
data() {
return {
start: 0,
totalHeight: 0
};
},
computed: {
subList() {
return this.list.slice(this.start, this.start + this.count);
},
paddingTop() {
return this.itemHeight * this.start;
},
paddingBottom() {
return this.totalHeight - this.itemHeight * this.start - this.clientHeight;
}
},
// watch:{},
created() {
this.totalHeight = this.itemHeight * this.list.length;
this.clientHeight = this.itemHeight * this.count;
},
mounted() {
// this.count = Math.ceil(this.$el.clientHeight / this.itemHeight);
this.ContentEl = this.$refs.ContentRef;
},
// beforeRouteEnter(to, from, next) { next(vm => {}) },
// beforeRouteUpdate(to, from, next) {},
// beforeRouteLeave(to, from, next) {},
// beforeDestroy() {},
// destroyed() {},
methods: {
onScroll(e) {
const { scrollTop, clientHeight } = e.target;
const { itemHeight, start, count } = this;
if (scrollTop + clientHeight >= itemHeight * (start + count) || scrollTop <= itemHeight * start) {
const newStart = Math.floor(scrollTop / itemHeight);
console.debug(newStart);
if (newStart !== this.start) {
this.start = Math.min(newStart, this.list.length - count);
}
}
}
}
};
</script>
<style scoped>
.virtual-list-wrapper {
height: 100%;
width: 100%;
overflow-y: auto;
}
.virtual-content {
position: relative;
}
.virtual-item {
box-sizing: border-box;
border-bottom: 1px solid #333;
}
</style>
用法
<VirtualList3 :list="list"
:itemHeight="60"
:count="100">
<template v-slot:listItem="scope">
<div class="list-item">项目{{scope.item}}</div>
</template>
</VirtualList3>
比较流行的虚拟滚动的库,如果是比较简单的需求,不需要库那种兼容多种方案的情况,可以依据原理自己实现
← 拖拽修改列表顺序-Vue组件 瀑布流布局 →