Vue3中实现虚拟列表、长列表性能优化与实践

513 阅读6分钟

🐳 引言


在开发过程中,我们有时会遇到数据量较大的情况,这会导致大量数据同时加载到页面,从而生成过多的 DOM 元素。这种情况不仅会导致页面卡顿,甚至可能导致浏览器直接崩溃。给用户体验带来极大的负面影响。为了解决这一问题,我们可以采用虚拟列表技术,通过只渲染可视区域内的元素,显著提升页面的性能和用户体验。

现在网上有许多现成的虚拟列表第三方插件库,我们可以直接使用这些库。然而,这边我打算自己动手去实现虚拟列表功能。在之前的 Vue 2 项目中,我已经实现过类似的功能,这次我打算利用 Vue 3 来重新实现,并将其封装成一个公共组件。

🐳 虚拟列表的基本原理


虚拟列表通过只渲染当前可视区域内的列表项,从而提高长列表加载到页面的性能。

  1. 设置子数据项高度:确定子数据项的具体高度。以确定当前区域内需要渲染的列表项。
  2. 计算可视区域高度:确定当前可视区域内可渲染多少条子数据项,计算起始下标、结束下标。避免渲染整个列表。
  3. 渲染可视区域:保持渲染的DOM节点数量始终在一个较小的范围内,通过动态调整渲染内容的位置,保持列表高度完整且滚动条能正常滚动。
  4. 滚动监听:监听容器的滚动事件,实时获取滚动位置,通过滚动位置实时更新可视区域范围,动态渲染对应列表项。
  5. 设置缓冲列表项:在可视区域的上下各增加一定数量的缓冲列表项,提前加载即将进入可视区域的列表项,避免滚动时出现空白以及卡顿的情况。

好的!接下来,我们将通过代码一步步实现上述功能,完整呈现虚拟列表的核心逻辑和效果。

🐳 代码实现


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 来撑起容器的高度。此外,也可以使用 transformposition 来实现这一效果。代码如下:

<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>  

🐳 文章小尾巴


感谢你看到最后,最后再说两点~
①如果你持有不同的看法,欢迎你在文章下方进行留言、评论。
②如果对你有帮助,或者你认可的话,欢迎给个小点赞,支持一下~
我是程序员张张,一个热爱编程也爱生活的程序员

(文章内容仅供学习参考,如有侵权,非常抱歉,请立即联系作者删除。)