什么是虚拟列表
虚拟列表
是对 长列表
优化的一种手段,其实就是只对 可见区域
进行渲染,对 不可见区域
则不进行渲染或者设置缓冲区部分渲染,这样就可以实现高效的渲染
为什么需要虚拟列表
有一道非常经典的面试题就是如果后端一次性返回 十万条数据
需要前端来渲染,应该怎么做?我们总不能将这数据全部渲染到页面,那样肯定会造成页面渲染卡顿,甚至直接卡死,所以我们就需要想办法,不一次性渲染那么多,那就 虚拟列表
就可以很好的解决这个问题
实现
定高虚拟列表
图示
初始化状态
滚动后状态
可以得到滚动高度 scrollTop
分析
首先我们先看下样式结构
<div class="virtual-list" ref="virtualList">
<div class="list-phantom"></div>
<div class="list-content">
<!-- item-1 -->
<!-- item-2 -->
......
<!-- item-n -->
</div>
</div>
virtual-list
为可见区域
的容器list-phantom
为容器的占位
设置高度为列表的总高度,用于形成滚动条list-content
为列表渲染区域
思考一下: 因为我们只需要渲染可见区域的元素即可,所以需要截取总数据,得到需要展示的数据,那么截取需要展示的数据,就需要知道开始索引以及结束索引,又怎么得到开始索引以及结束索引呢?
因为我们这里是定高的,所以我们是知道 默认高度
,这样就可以得到 列表的总高度
。可以根据滚动的高度得到开始索引
,结束索引
可以根据 开始索引
加上 可见区域的元素数量
得到,怎么得到 可见区域的元素数量
呢?很简单用 可见区域的高度
除于 元素默认高度
可以得到 可见区域的元素数量
。
- 默认的高度:
itemHeight
- 列表的总高度:
totalHeight = itemHeight * data.length
- 可见区域高度:
visibleHeight = $refs.virtualList.clientHeight
- 可见区域元素数量:
visibleCount = Math.ceil(visibleHeight / itemHeight)
- 开始索引:
startIndex = Math.floor(scrollTop / itemHeight)
- 结束索引:
endIndex = Math.min(startIndex + visibleCount, data.length)
- 偏移量:
offsetY = scrollTop - (scrollTop % itemHeight)
一定需要减去取模值
,不然看到的效果相当于直接替换元素,没有滚动效果
实现
<template>
<div class="container">
<div class="virtual-list" ref="virtualList" @scroll="updateVisibleItems">
<div class="list-phantom" :style="{ height: totalHeight + 'px' }"></div>
<div
class="list-content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div v-for="item in visibleItems" :key="item.id" class="card">
<img :src="item.image" :alt="item.title" />
<div class="content">
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
data: [], // 模拟总数据
scrollTop: 0, // 滚动条位置
totalHeight: 0, // 列表总高度
visibleHeight: 0, // 可见区域的高度
itemHeight: 140, // 默认每个项目的高度
};
},
created() {
this.data = this.generateData(1000); // 模拟数据
},
mounted() {
this.totalHeight = this.data.length * this.itemHeight; // 获取列表总高度
this.visibleHeight = this.$refs.virtualList.clientHeight; // 获取可见区域的高度
this.updateVisibleItems(); // 初始化可见项目
},
computed: {
// 计算可见区域的数量
visibleCount() {
return Math.ceil(this.visibleHeight / this.itemHeight);
},
// 计算起始索引
startIndex() {
return Math.floor(this.scrollTop / this.itemHeight);
},
// 计算结束索引
endIndex() {
return Math.min(this.startIndex + this.visibleCount, this.data.length);
},
// 获取可见项目
visibleItems() {
return this.data.slice(this.startIndex, this.endIndex);
},
// 计算偏移量
offsetY() {
return this.scrollTop - (this.scrollTop % this.itemHeight); // 减去取模值非常重要!!!
},
},
methods: {
generateData(count) {
return Array.from({ length: count }, (_, index) => ({
id: index,
title: `标题 ${index + 1}`,
description: `这是第 ${index + 1} 个项目的描述文本。`,
image: `https://picsum.photos/200/200?random=${index}`,
}));
},
updateVisibleItems() {
this.scrollTop = this.$refs.virtualList.scrollTop;
},
},
};
</script>
<style scoped>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.virtual-list {
height: 600px;
overflow: auto;
position: relative;
}
.list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.list-content {
position: absolute;
left: 0;
right: 0;
top: 0;
}
.card {
display: flex;
padding: 15px;
border: 1px solid #eee;
border-radius: 8px;
background: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 10px;
}
.card img {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 4px;
margin-right: 15px;
}
.content {
flex: 1;
}
.content h3 {
margin-bottom: 8px;
color: #333;
}
.content p {
color: #666;
font-size: 14px;
line-height: 1.6;
}
</style>
效果
动态高度虚拟列表
图示
分析
因为每一项的高度都是不确定的,所以就无法计算出 开始索引
,也就意味着 结束索引
你也没法知道,并且不知道每一项高度也就无法计算出 总高度
以及可见区域高度
。
我们先给一个每一项初始高度
,便于预估出总高度
,出现滚动条,然后当元素渲染出来后获取真实高度并记录下来。
定义 itemPositons
记录每一项的信息
itemPositons() {
return this.data.map((_, index) => ({
id: index,
height: this.estimatedItemSize,
top: index * this.estimatedItemSize,
bottom: (index + 1) * this.estimatedItemSize
}))
}
这样就可以得到 列表总高度
// 最后一项的 bottom 的位置就是总高度,当然也可以使用 `reduce` 方法计算出总高度
totalHeight = itemPositons[itemPositons.length - 1].bottom
动态高度获取 开始索引
就不一样了,也可以说更简单些
getStartIndex() {
// 找到第一个元素bottom大于滚动高度的元素索引,表示出现在可见区域第一个
return itemPositons.findIndex((item) => item.bottom > scrollTop)
}
增加上下缓冲区域,计算缓冲区域数量
beforeBuffer() {
return Math.min(this.startIndex, this.buffer)
}
afterBuffer() {
return Math.min(this.data.length - this.endIndex, this.buffer)
}
得到需要渲染到的列表
visibleItems() {
return this.data.slice(
this.startIndex - this.beforeBuffer,
Math.min(this.endIndex + this.afterBuffer, this.data.length)
)
}
增加缓冲区域后需要重新计算偏移量
setOffsetY() {
if (this.startIndex === 0) return 0
// 计算缓冲的高度
const size =
this.itemPositons[this.startIndex].top -
(this.itemPositons[this.startIndex - this.beforeBuffer]
? this.itemPositons[this.startIndex - this.beforeBuffer].top
: 0)
// 偏移量需要减去缓冲的高度
this.offsetY = this.itemPositons[this.startIndex].top - size
}
并且我们这里使用了 IntersectionObserver
来代替了 scroll
事件,解决了滚动事件频繁触发的问题,造成很多没必要的计算。IntersectionObserver
可以监听目标元素是否出现在可视区域内,在监听的回调事件中执行可视区域数据的更新。
还使用了 ResizeObserver
来优化在 updated钩子函数
中频繁重新计算位置信息,而 ResizeObserver
只有当目标元素尺寸变化后才会重新计算元素位置信息
实现
<template>
<div class="container">
<div class="virtual-list" ref="virtualList">
<div class="list-phantom" :style="{ height: totalHeight + 'px' }"></div>
<div class="list-content" :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="item in visibleItems"
:key="item.id"
class="card"
ref="itemRefs"
:id="`item-${item.id}`"
>
<img :src="item.image" :alt="item.title" />
<div class="content">
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
data: this.generateData(100),
estimatedItemSize: 120, // 预估每行高度
buffer: 3, // 缓冲个数
totalHeight: 0,
visibleHeight: 0,
scrollTop: 0,
startIndex: 0,
endIndex: 0,
offsetY: 0,
intersectionObserve: null,
resizeObserver: null
}
},
computed: {
itemPositons() {
return this.data.map((_, index) => ({
id: index,
height: this.estimatedItemSize,
top: index * this.estimatedItemSize,
bottom: (index + 1) * this.estimatedItemSize
}))
},
visibleCounts() {
return Math.ceil(this.visibleHeight / this.estimatedItemSize)
},
beforeBuffer() {
return Math.min(this.startIndex, this.buffer)
},
afterBuffer() {
return Math.min(this.data.length - this.endIndex, this.buffer)
},
visibleItems() {
return this.data.slice(
this.startIndex - this.beforeBuffer,
Math.min(this.endIndex + this.afterBuffer, this.data.length)
)
}
},
mounted() {
this.visibleHeight = this.$refs.virtualList.clientHeight
this.totalHeight = this.itemPositons[this.itemPositons.length - 1].bottom
this.startIndex = 0
this.endIndex = this.startIndex + this.visibleCounts
this.createResizeObserve()
this.createIntersectionObServe()
},
updated() {
this.resizeObserveItems()
this.intersectionObserveItems()
},
methods: {
/**
* 监听元素尺寸变化
* 此函数遍历所有item元素,并使用resizeObserver对其进行观察
*/
resizeObserveItems() {
this.$refs.itemRefs.forEach((node) => {
this.resizeObserver.observe(node)
})
},
/**
* 创建ResizeObserver实例
* 当观察到元素尺寸变化时,调用getRealityDomPositions更新DOM位置信息
*/
createResizeObserve() {
this.resizeObserver = new ResizeObserver(() => {
this.getRealityDomPositions()
})
},
/**
* 监听元素是否在可视范围内
* 此函数遍历所有item元素,并使用intersectionObserve对其进行观察
*/
intersectionObserveItems() {
this.$refs.itemRefs.forEach((node) => {
this.intersectionObserve.observe(node)
})
},
/**
* 创建IntersectionObserver实例
* 当元素进入可视范围时,调用updateVisibleItems更新可见项
* @param {entries} 监听到的元素信息数组
*/
createIntersectionObServe() {
this.intersectionObserve = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// 如果进入可视范围
if (entry.isIntersecting) {
this.updateVisibleItems()
}
})
},
{
root: this.$refs.virtualList, // 监听的根元素
rootMargin: '0px', // 触发回调函数的距离
threshold: 0 // 触发回调函数的比例
}
)
},
/**
* 更新所有元素的实际位置
* 此函数遍历所有item元素,获取其当前高度,并根据高度变化更新位置信息
*/
getRealityDomPositions() {
// 获取所有项的引用
const nodes = this.$refs.itemRefs
// 遍历每一项以更新其位置信息
nodes.forEach((node) => {
// 获取当前项的高度
const height = node.getBoundingClientRect().height
// 提取当前项的ID
const id = +node.id.split('-')[1]
// 查找当前项在位置信息数组中的索引
const index = this.itemPositons.findIndex((item) => item.id === id)
// 获取当前项的旧高度
const oldHeight = this.itemPositons[index].height
// 计算高度差
const diffValue = height - oldHeight
// 如果高度有变化,则更新位置信息
if (diffValue) {
// 更新当前项的高度
this.itemPositons[index].height = height
// 调整当前项的底部位置
this.itemPositons[index].bottom += diffValue
// 更新后续所有项的顶部和底部位置
for (let i = index + 1; i < this.itemPositons.length; i++) {
this.itemPositons[i].top = this.itemPositons[i - 1].bottom
this.itemPositons[i].bottom += diffValue
}
}
})
// 更新总高度为最后一项的底部位置
this.totalHeight = this.itemPositons[this.itemPositons.length - 1].bottom
},
/**
* 更新可见元素信息
* 此函数根据当前滚动位置,计算出可见元素的索引范围,并设置偏移量
*/
updateVisibleItems() {
this.scrollTop = this.$refs.virtualList.scrollTop
this.startIndex = this.getStartIndex()
this.endIndex = this.startIndex + this.visibleCounts
this.setOffsetY()
},
/**
* 计算并设置偏移量
* 此函数根据当前可见元素的起始索引,计算出列表的偏移量
*/
setOffsetY() {
// 如果起始索引为0,则不需要进行后续计算,直接返回0
if (this.startIndex === 0) return 0
// 计算当前起始项与前一个缓冲项之间的高度差
const size =
this.itemPositons[this.startIndex].top -
(this.itemPositons[this.startIndex - this.beforeBuffer]
? this.itemPositons[this.startIndex - this.beforeBuffer].top
: 0)
// 根据计算出的高度差,设置纵轴偏移量,以保证列表滚动的流畅性
this.offsetY = this.itemPositons[this.startIndex].top - size
},
/**
* 获取可见元素的起始索引
* 此函数根据当前滚动位置,找到第一个进入可视范围的元素索引
*/
getStartIndex() {
return this.itemPositons.findIndex((item) => item.bottom > this.scrollTop)
},
generateData(count) {
return Array.from({ length: count }, (_, index) => {
const height = this.getRandomHeight()
return {
id: index,
title: `标题 ${index + 1}`,
description: `这是第 ${index + 1} 个项目的描述文本。`.repeat(
Math.floor(Math.random() * 3) + 1
),
image: `https://picsum.photos/${height}/${height}?random=${index}`
}
})
},
// 获得100-300的随机数
getRandomHeight() {
return Math.floor(Math.random() * 200) + 100
}
}
}
</script>
<style scoped>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.virtual-list {
height: 600px;
overflow: auto;
position: relative;
}
.list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.list-content {
position: absolute;
left: 0;
right: 0;
top: 0;
}
.card {
display: flex;
align-items: center;
padding: 15px;
border: 1px solid #eee;
border-radius: 8px;
background: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 10px;
}
.card img {
object-fit: cover;
border-radius: 4px;
margin-right: 15px;
}
.content {
flex: 1;
}
.content h3 {
margin-bottom: 8px;
color: #333;
}
.content p {
color: #666;
font-size: 14px;
line-height: 1.6;
}
</style>
效果
里面可能存在很多问题,望大佬能够指出,非常感谢