问题背景: 一个300000条记录的列表怎么展示(不要问我为什么是300000条,因为500000条笔者的电脑跑不出来)?直接无脑v-for? 菜鸟都知道肯定不行。
<template>
<div>
<div class="item" v-for="item in list" :key="item.key">
{{item.text}}
</div>
</div>
</template>
<script>
export default {
data() {
return {
list: [],
forStartTime: 0,
forEndTime: 0,
randerEndTime: 0
}
},
created() {
let list = []
this.forStartTime = +new Date()
console.log('for循环开始时间戳:', this.forStartTime);
for (let i = 0; i < 300000; i++) {
list.push({
key: i,
text: i + 1
})
}
this.forEndTime = +new Date()
console.log('for循环结束时间戳:', this.forEndTime);
console.log('for循环时间:', (this.forEndTime - this.forStartTime).toFixed(0));
this.list = list
},
mounted(){
this.$nextTick(()=>{
this.randerEndTime = +new Date()
console.log('渲染时间:',this.randerEndTime - this.forEndTime);
})
}
}
</script>
<style>
</style>
让我们来看看渲染效果如何呢?
上面这个例子中,我们通过for循环生成一个长度为300000的数组,用户会感受到页面渲染的速度很慢,页面出现了较长时间的白屏,这样的用户体验肯定的不行的。(这里的渲染时间还只是我们可以在代码中拿到真实dom,浏览器还是白屏的情况,没有真实渲染完成,真正的渲染时间实际比这个长的多) 况且这里我们v-for的dom结构非常非常简单,我们稍微新增1个dom节点其他条件保持不变,再试试:
<template>
<div>
<div class="item" v-for="item in list" :key="item.key">
{{item.text}}
<span>1</span>
</div>
</div>
</template>
结果如何呢:
从上面的实验我们不难看出, 在dom结构复杂一点的情况下, 使用长list对于页面的渲染所造成的阻塞几乎是灾难性的。
为了解决上面的问题,无限滚动就来了!!!
原理
永远都只渲染用户当前可视区域之内的dom节点
那么问题来了,开发者怎么样去知道当前用户滚动的位置需要展示哪一些dom呢?
这里大致分为两类来看:
- 所有item的高度固定;
- item的高度不固定;
具体实现
item高度固定
假设我们屏幕的可见区域
的高度为500px
,而列表项的高度为50px
,则此时我们在屏幕中最多只能看到10个列表项,那么在首次渲染的时候,我们只需加载10条即可,在列表滚动时,滚动条距顶部的位置为150px
,则我们可得知在可见区域
内的列表项为第4项
至第13项
,如下图:
变量定义:
- 计算当前可视区域起始数据索引(startIndex)
- 计算当前可视区域结束数据索引(endIndex)
- 计算当前可视区域的数据,并渲染到页面中(domList)
- 计算startIndex对应的数据在整个列表中的偏移位置startOffset并设置到列表上
由于只是对可视区域内的列表项进行渲染,所以为了保持列表容器的高度并可正常的触发滚动,将Html结构设计成如下结构:
<div class="infinite-list-container">
<div class="infinite-list-phantom"></div>
<div class="infinite-list">
<!-- item-1 -->
<!-- item-2 -->
<!-- ...... -->
<!-- item-n -->
</div>
</div>
- nfinite-list-container 为可视区域的容器
- infinite-list-phantom 为容器内的占位,高度为总列表高度,用于形成滚动条
- infinite-list 为列表项的渲染区域
接着,监听infinite-list-container的scroll事件,获取滚动位置scrollTop
- 假定可视区域高度固定,称之为screenHeight
- 假定列表每项高度固定,称之为itemSize
- 假定列表数据称之为list
- 假定当前滚动位置称之为scrollTop
则可推算出:
- 列表总高度realHeight= list.length * itemSize
- 可显示的列表项数domListLen = Math.ceil(screenHeight / itemSize)
- 数据的起始索引startIndex = Math.floor(scrollTop / itemSize)
- 数据的结束索引endIndex = startIndex + domListLen
- 列表显示数据为domList= list.slice(startIndex,endIndex)
当滚动后,由于渲染区域相对于可视区域已经发生了偏移,此时我需要获取一个偏移量startOffset,通过样式控制将渲染区域偏移至可视区域中。
- 偏移量startOffset = scrollTop - (scrollTop % itemSize);
最终代码
VirtualList.vue 虚拟滚动组件(item固定高度版)
<template>
<div class="infinite-list-container" ref="virtualList" @scroll="scroll">
<div class="infinite-list-phantom" :style="{height: realHeight + 'px'}"></div>
<div class="infinite-list" :style="{transform: 'translateY('+ offsetTop +'px)'}">
<div class="item" :style="{height: itemSize + 'px'}" v-for="item in domList" :key="item.key">{{item.text}}</div>
</div>
</div>
</template>
<script>
export default {
name: 'VirtualList',
props: {
list: {
type: Array,
default() {
return []
}
},
itemSize: {
type: Number,
default: 100
},
extraCount: {
type: Number,
default: 5
}
},
computed: {
// list所有节点高度和(用来撑起滚动条)
realHeight() {
return this.list.length * this.itemSize
},
// 真实渲染的dom数据list
domList() {
let startIndex = this.startIndex - this.topExtraCount
let endIndex = this.endIndex + this.bottomExtraCount
return this.list.slice(startIndex, endIndex)
},
domListLen() {
return Math.ceil(this.screenHeight / this.itemSize)
},
topExtraCount() {
return Math.min(this.startIndex, this.extraCount)
},
bottomExtraCount() {
return Math.min(this.list.length - this.endIndex, this.extraCount)
}
},
data() {
return {
screenHeight: 0, // 可视区高度
startIndex: 0, // 可视区首个item索引
endIndex: 0, // 可视区最一个item索引
offsetTop: 0 // 滚动时,为了保持dom块保持在视口,外层盒子移动的举例
}
},
created() {
},
mounted() {
this.screenHeight = this.$refs.virtualList.clientHeight
this.endIndex = this.startIndex + this.domListLen
},
methods: {
scroll(e) {
let scrollTop = e.target.scrollTop
this.startIndex = Math.floor(scrollTop / this.itemSize)
this.endIndex = this.startIndex + this.domListLen
this.offsetTop = Math.max(scrollTop - (scrollTop % this.itemSize) - (this.extraCount * this.itemSize), 0)
// console.log(this.offsetTop)
}
}
}
</script>
<style scoped lang="scss">
.infinite-list-container {
height: 100%;
overflow: auto;
position: relative;
.infinite-list {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.item {
box-sizing: border-box;
border: 1px solid red;
}
}
</style>
使用:
<template>
<div class="test">
<virtualList :list="list" :loadData="loadData">
<template #default="{item}">
<div>{{item}}</div>
</template>
</virtualList>
</div>
</template>
<script>
import virtualList from './components/virtualList-guding'
const generateString = length => Array(length).fill('').map((v) => '文字').join('')
let list = []
for (let i = 0; i < 5000; i++) {
list.push({
key: i,
text: (i + 1) + generateString(Math.ceil(Math.random() * 100))
})
}
export default {
name: 'Test',
components: { virtualList },
data() {
return {
list
}
},
computed: {},
created() {},
methods: {
loadData() {
return new Promise((res, req) => {
let len = this.list.length
// 模拟异步数据
setTimeout(() => {
for (let i = 0; i < 50; i++) {
this.list.push({
key: len + i,
text: (i + 1) + '动态' + generateString(Math.ceil(Math.random() * 100))
})
}
res()
}, 1000)
})
}
}
}
</script>
<style scoped lang="scss"></style>
item高度不固定
如果item高度不固定呢?类似下图:
定义positions数组,用于列表项渲染后存储每一项的高度以及位置信息,每一项的初始高度我们一个高度:estimatedItemSize,positions如下:
this.positions = [
// {
// index: 0, // 索引
// top:0, // 元素顶部距离列表最顶部距离
// bottom:100, // 元素底部距离列表最顶部距离
// height:100 // 元素高度,初始为estimatedItemSize,渲染完之后设置成真实dom高度
// }
];
由于需要当前列表渲染完成之后获取dom高度,这里我们写在updated钩子里
updated() {
if (this.loading) { return }
let nodes = this.$refs.virtualListItems
// 拿到真实dom之后修改positons数组
nodes.forEach(node => {
let rect = node.getBoundingClientRect()
let height = rect.height
let index = parseInt(node.id)
let oldHeight = this.positions[index].height
let dValue = oldHeight - height
// 存在差值
if (dValue) {
this.positions[index].bottom = this.positions[index].bottom - dValue
this.positions[index].height = height
for (let k = index + 1; k < this.positions.length; k++) {
this.positions[k].top = this.positions[k - 1].bottom
this.positions[k].bottom = this.positions[k].bottom - dValue
}
}
})
// 这个避免直接在updated里面修改data
this.$refs.realHeight.style.height =
(this.positions && this.positions.length > 0
? this.positions[this.positions.length - 1].bottom
: 0) + 'px'
this.$refs.list.style.transform = `translate3d(0,${
this.positions[this.start].top
}px,0)`
// this.offsetTop = this.positions[this.start].top
},
另外,在item固定高度时,我们通过高度算出的开始索引。这里我们可以根据positons里的top字段及bottom字段获取:
let startObj = this.positions.find(item => {
return scrollTop >= item.top && scrollTop <= item.bottom
})
this.start = startObj ? startObj.key : 0
最终代码:
<template>
<div class="virtualList" ref="virtualList" @scroll="scroll">
<div class="realHeight" ref="realHeight">
</div>
<div class="list" ref="list">
<div
class="item"
ref="virtualListItems"
:id="item.key"
v-for="item in domList"
:key="item.key"
>
<slot :item="item"></slot>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'VirtualList',
props: {
// list数据
list: {
type: Array,
default() {
return []
}
},
// 分页加载函数,需要返回一个promise,不传则不分页
loadData: {
type: Function
},
// 初始item高度
estimateSize: {
type: Number,
default: 50
},
// 距离底部多少个item是触发加载
lastNumberLoad: {
type: Number,
default: 3
}
},
computed: {
domList() {
return this.list.slice(this.start, this.end)
},
domListLen() {
return Math.ceil(this.caet / this.estimateSize)
}
},
data() {
return {
caet: 0,
start: 0,
end: 0,
offsetTop: 0,
positions: [],
loading: false
}
},
created() {
this.positions = this.list.map((item, index) => ({
key: index,
height: this.estimateSize,
top: index * this.estimateSize,
bottom: (index + 1) * this.estimateSize
}))
},
updated() {
if (this.loading) { return }
let nodes = this.$refs.virtualListItems
nodes.forEach(node => {
let rect = node.getBoundingClientRect()
let height = rect.height
let index = parseInt(node.id)
let oldHeight = this.positions[index].height
let dValue = oldHeight - height
// 存在差值
if (dValue) {
console.log('index', index)
this.positions[index].bottom = this.positions[index].bottom - dValue
this.positions[index].height = height
for (let k = index + 1; k < this.positions.length; k++) {
this.positions[k].top = this.positions[k - 1].bottom
this.positions[k].bottom = this.positions[k].bottom - dValue
}
}
})
// 这个避免直接在updated里面修改data
this.$refs.realHeight.style.height =
(this.positions && this.positions.length > 0
? this.positions[this.positions.length - 1].bottom
: 0) + 'px'
this.$refs.list.style.transform = `translate3d(0,${
this.positions[this.start].top
}px,0)`
// this.offsetTop = this.positions[this.start].top
},
mounted() {
this.caet = this.$refs.virtualList.clientHeight
this.end = this.start + this.domListLen
},
methods: {
async scroll(e) {
let scrollTop = e.target.scrollTop
if (this.loading) {
// 这里loading继续渲染是为了解决,滚动到倒数第lastNumberLoad时,列表下部空白,需要继续滚动才能触发加载
this.updateView(scrollTop)
return
}
// 需要分页 && 滚动距离 + 外层盒子高度 >= 倒数第lastNumberLoad个item的bottom
if (typeof this.loadData === 'function' && scrollTop + this.caet >= this.positions[Math.max(this.positions.length - this.lastNumberLoad, 0)].bottom) {
this.loading = true
await this.loadData()
this.positions = this.list.map((item, index) => {
if (this.positions[index]) {
return this.positions[index]
} else {
return {
key: index,
height: this.estimateSize,
top: index * this.estimateSize,
bottom: index * this.estimateSize
}
}
})
}
this.$nextTick(() => {
this.loading = false
this.updateView(scrollTop)
})
},
updateView(scrollTop) {
let startObj = this.positions.find(item => {
return scrollTop >= item.top && scrollTop <= item.bottom
})
this.start = startObj ? startObj.key : 0
this.end = this.start + this.domListLen
// this.offsetTop = this.positions[this.start].top
this.$refs.list.style.transform = `translate3d(0,${
this.positions[this.start].top
}px,0)`
}
}
}
</script>
<style scoped lang="scss">
.virtualList {
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
position: relative;
.list {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.item {
box-sizing: border-box;
border: 1px solid red;
font-size: 20px;
}
}
</style>
作者: 快落的小海疼
来源: 长列表无限滚动原理分析及其实现