🐳 引言
在开发过程中,我们有时会遇到数据量较大的情况,这会导致大量数据同时加载到页面,从而生成过多的 DOM 元素。这种情况不仅会导致页面卡顿,甚至可能导致浏览器直接崩溃。给用户体验带来极大的负面影响。为了解决这一问题,我们可以采用虚拟列表技术,通过只渲染可视区域内的元素,显著提升页面的性能和用户体验。
现在网上有许多现成的虚拟列表第三方插件库,我们可以直接使用这些库。然而,这边我打算自己动手去实现虚拟列表功能。在之前的 Vue 2 项目中,我已经实现过类似的功能,这次我打算利用 Vue 3 来重新实现,并将其封装成一个公共组件。
🐳 虚拟列表的基本原理
虚拟列表通过只渲染当前可视区域内的列表项,从而提高长列表加载到页面的性能。
- 设置子数据项高度:确定子数据项的具体高度。以确定当前区域内需要渲染的列表项。
- 计算可视区域高度:确定当前可视区域内可渲染多少条子数据项,计算起始下标、结束下标。避免渲染整个列表。
- 渲染可视区域:保持渲染的DOM节点数量始终在一个较小的范围内,通过动态调整渲染内容的位置,保持列表高度完整且滚动条能正常滚动。
- 滚动监听:监听容器的滚动事件,实时获取滚动位置,通过滚动位置实时更新可视区域范围,动态渲染对应列表项。
- 设置缓冲列表项:在可视区域的上下各增加一定数量的缓冲列表项,提前加载即将进入可视区域的列表项,避免滚动时出现空白以及卡顿的情况。
好的!接下来,我们将通过代码一步步实现上述功能,完整呈现虚拟列表的核心逻辑和效果。
🐳 代码实现
1、设置子数据项的高度
子数据项的高度是固定值,所以这里就定义了个变量。(注:子数据项的高度与css中的高度保持一致)代码如下:
<script lang="ts" setup>
// 子数据项高度
const itemHeight = 40
</script>
2、计算可视区域高度、起始下标、结束下标
因为下面会通过滚动条的高度去计算详细的值。所以这里我们的起始下标和结束下标使用计算属性去定义。代码如下:
<script lang="ts" setup>
// 可视区域的高度
const viewHeight = ref(0)
// ref虚拟列表容器dom
const virtualContainer = ref<HTMLElement | null>(null)
// 在dom加载完成后,通过ref去获取可视区域的高度
onMounted(() => {
nextTick(() => {
viewHeight.value = virtualContainer.value?.clientHeight ?? 0
})
})
// 虚拟列表真实展示数据:起始下标
const start = computed(() => {
return 0
})
// 虚拟列表真实展示数据:结束下标
const end = computed(() => {
return viewHeight.value / itemHeight
})
</script>
3、渲染可视区域
paddingAttr
的目的是保持列表的高度完整,并确保滚动条能够正常滚动。由于实际渲染的 DOM 元素较少,可能导致滚动条位置异常,因此需要通过设置 padding
来撑起容器的高度。此外,也可以使用 transform
和 position
来实现这一效果。代码如下:
<div ref="virtualContainer" @scroll="onScroll" class="virtual-container">
<div class="virtual-list">
<div class="virtual-item" v-for="item in virtualData" :key="item.id">
<div class="item">{{ item.title }}</div>
</div>
</div>
</div>
<script lang="ts" setup>
// 大数据数组
const dataList = reactive<any[]>([])
for (let i = 0; i < 100000; i++) {
dataList.push({ id: i, title: `标题${i}` })
}
// 计算虚拟列表的padding(保持列表高度完整且滚动条能正常滚动)
const paddingAttr = computed(() => {
const paddingTop = start.value * itemHeight
const paddingBottom = (dataList.length - over.value) * itemHeight
return `${paddingTop}px 0 ${paddingBottom}px`
})
// 虚拟列表真实展示数据
const virtualData = computed(() => {
return dataList.slice(start.value, over.value)
})
</script>
<style lang="scss" scoped>
.virtual-container {
overflow-y: auto;
height: 100%;
.virtual-list {
padding: v-bind(paddingAttr);
.virtual-item {
text-align: center;
height: 30px;
line-height: 30px;
background: #84bbfc;
margin-bottom: 10px;
}
}
}
</style>
4、滚动监听
上面我们初步的定义了起始下标、结束下标,但那并不满足我们的需求,这边我们通过监听滚动事件,获取到滚动条位置,通过滚动条位置去重新计算起始下标、结束下标。代码如下:
<script lang="ts" setup>
// 滚动条距离顶部距离
const scrollTop = ref(0)
// 虚拟列表真实展示数据:起始下标
const start = computed(() => {
const s = Math.floor(scrollTop.value / itemHeight)
return Math.max(0, s)
})
// 虚拟列表真实展示数据:结束下标
const over = computed(() => {
const o = Math.floor((scrollTop.value + viewHeight.value + 1) / itemHeight)
return Math.min(dataList.length, o)
})
// 监听滚动条距离顶部距离,实时更新
const onScroll = () => {
scrollTop.value = virtualContainer.value?.scrollTop ?? 0
}
</script>
5、设置缓冲列表项
这里给起始下标和结束下标,各自加减一个固定值,我这边设置的值是5,这边可以设置成其他值,但不能太大会影响性能。太小的话滚动会卡顿和出现白屏问题。代码如下:
<script lang="ts" setup>
// 虚拟列表真实展示数据:起始下标
const start = computed(() => {
const s = Math.floor(scrollTop.value / itemHeight - 5)
return Math.max(0, s)
})
// 虚拟列表真实展示数据:结束下标
const over = computed(() => {
const o = Math.floor((scrollTop.value + viewHeight.value + 1) / itemHeight + 5)
return Math.min(dataList.length, o)
})
</script>
好了,下面是虚拟列表的完整的代码:
<template>
<div ref="virtualContainer" @scroll="onScroll" class="virtual-container">
<div class="virtual-list">
<div class="virtual-item" v-for="item in virtualData" :key="item.id">
<div class="item">{{ item.title }}</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref, reactive } from 'vue'
/**
* 虚拟列表的每一项的高度
*/
const itemHeight = 40
const dataList = reactive<any[]>([])
for (let i = 0; i < 100000; i++) {
dataList.push({ id: i, title: `标题${i}` })
}
/**
* 滚动条距离顶部距离
*/
const scrollTop = ref(0)
/**
* ref虚拟列表容器dom
*/
const virtualContainer = ref<HTMLElement | null>(null)
/**
* 可视区域的高度
*/
const viewHeight = ref(0)
// 在dom加载完成后,获取可视区域的高度
onMounted(() => {
nextTick(() => {
viewHeight.value = virtualContainer.value?.clientHeight ?? 0
})
})
/**
* 虚拟列表真实展示数据:起始下标
*/
const start = computed(() => {
const s = Math.floor(scrollTop.value / itemHeight)
return Math.max(0, s)
})
/**
* 虚拟列表真实展示数据:结束下标
*/
const over = computed(() => {
const o = Math.floor((scrollTop.value + viewHeight.value + 1) / itemHeight)
return Math.min(dataList.length, o)
})
/**
* 计算虚拟列表的padding(保持列表高度完整且滚动条能正常滚动)
*/
const paddingAttr = computed(() => {
const paddingTop = start.value * itemHeight
const paddingBottom = (dataList.length - over.value) * itemHeight
return `${paddingTop}px 0 ${paddingBottom}px`
})
/**
* 虚拟列表真实展示数据
*/
const virtualData = computed(() => {
return dataList.slice(start.value, over.value)
})
/**
* 监听滚动条距离顶部距离,实时更新
*/
const onScroll = () => {
scrollTop.value = virtualContainer.value?.scrollTop ?? 0
}
</script>
<style lang="scss" scoped>
.virtual-container {
overflow-y: auto;
height: 100%;
.virtual-list {
padding: v-bind(paddingAttr);
.virtual-item {
text-align: center;
height: 30px;
line-height: 30px;
background: #84bbfc;
margin-bottom: 10px;
}
}
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
background: #ffffff;
border-radius: 6px;
}
::-webkit-scrollbar-thumb {
background: #00a6ff;
border-radius: 6px;
}
</style>
示例:
🐳 组件封装
上面我们完成了虚拟列表的功能实现,但是呢,在现实的开发中我们会遇到不止一个长列表的需求,每一个都这么写,会有很多冗余的代码,而且很麻烦。所以在这里我们将其封装成一个公共的组件。以简化我们日常开发的代码量和时间成本。
这边封装组件的逻辑和上面基本一致,我就不多赘述了,直接上代码:
<template>
<div ref="virtualContainer" @scroll="onScroll" class="virtual-container">
<div class="virtual-list">
<slot v-if="slotDefault" name="default" :dataList="virtualData"></slot>
<template v-else>
<div
class="virtual-item"
v-for="item in virtualData"
:key="item[keyField]"
:style="{ height: itemHeight + 'px', lineHeight: itemHeight + 'px' }"
>
<slot name="item" :item="item"></slot>
</div>
</template>
</div>
</div>
</template>
<script lang="ts" setup name="VirtualList">
import { withDefaults, defineProps, computed, nextTick, onMounted, ref, useSlots } from 'vue'
/**
* 虚拟列表defineProps接口(类型约束)
* @param dataList 数据列表
* @param keyField 每一项的唯一标识key
* @param itemHeight 每一项的高度
* @param containerHeight 容器高度
*/
interface virtualProps {
dataList: any[]
keyField?: string
itemHeight?: number
containerHeight?: string
}
/**
* 父组件传入的值
* withDefaults 为props设置默认值
*/
const { dataList, keyField, itemHeight, containerHeight } = withDefaults(defineProps<virtualProps>(), {
keyField: 'id',
itemHeight: 40,
containerHeight: '100%'
})
/**
* 滚动条距离顶部距离
*/
const scrollTop = ref(0)
/**
* ref虚拟列表容器dom
*/
const virtualContainer = ref<HTMLElement | null>(null)
/**
* 可视区域的高度
*/
const viewHeight = ref(0)
onMounted(() => {
nextTick(() => {
viewHeight.value = virtualContainer.value?.clientHeight ?? 0
})
})
/**
* 虚拟列表真实展示数据:起始下标
*/
const start = computed(() => {
const s = Math.floor(scrollTop.value / itemHeight - 5)
return Math.max(0, s)
})
/**
* 虚拟列表真实展示数据:结束下标
*/
const over = computed(() => {
const o = Math.floor((scrollTop.value + viewHeight.value + 1) / itemHeight + 5)
return Math.min(dataList.length, o)
})
/**
* 计算虚拟列表的padding(保持列表高度完整且滚动条能正常滚动)
*/
const paddingAttr = computed(() => {
const paddingTop = start.value * itemHeight
const paddingBottom = (dataList.length - over.value) * itemHeight
return `${paddingTop}px 0 ${paddingBottom}px`
})
/**
* 虚拟列表真实展示数据
*/
const virtualData = computed(() => {
return dataList.slice(start.value, over.value)
})
/**
* 监听滚动条距离顶部距离,实时更新
*/
const onScroll = () => {
scrollTop.value = virtualContainer.value?.scrollTop ?? 0
}
/**
* 获取默认插槽
*/
const slotDefault = useSlots().default
</script>
<style lang="scss" scoped>
.virtual-container {
overflow-y: auto;
height: v-bind(containerHeight);
.virtual-list {
padding: v-bind(paddingAttr);
.virtual-item {
text-align: center;
border: 1px solid orangered;
}
}
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
background: #ffffff;
border-radius: 6px;
}
::-webkit-scrollbar-thumb {
background: #00a6ff;
border-radius: 6px;
}
</style>
这边我们的代码里面定义了两个插槽,default
插槽是为了满足element-ui
中的下拉框长列表问题。
代码如下:
<template>
<div style="height: 100%">
<div style="width: 240px; height: 100%">
<el-select multiple v-model="activeName" @visible-change="visibleChange">
<VirtualList v-if="visibleState" :data-list="data" :item-height="34" container-height="194px">
<template #default="{ dataList }">
<el-option v-for="i in dataList" :label="i.title" :value="i.id" :key="i.id" />
</template>
</VirtualList>
</el-select>
</div>
</div>
</template>
<script lang="ts" setup>
import VirtualList from '@/components/VirtualList/index.vue'
import { reactive, ref } from 'vue'
const data = reactive<any[]>([])
for (let i = 0; i < 100000; i++) {
data.push({ id: i, title: `标题${i}` })
}
const activeName = ref('')
const visibleState = ref(false)
const visibleChange = (val: boolean) => {
visibleState.value = val
}
</script>
🐳 文章小尾巴
感谢你看到最后,最后再说两点~
①如果你持有不同的看法,欢迎你在文章下方进行留言、评论。
②如果对你有帮助,或者你认可的话,欢迎给个小点赞,支持一下~
我是程序员张张,一个热爱编程也爱生活的程序员
(文章内容仅供学习参考,如有侵权,非常抱歉,请立即联系作者删除。)