开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第5天,点击查看活动详情
某天在一个风和日丽的下午,张三,接到一个需求:有一个数据列表,不要采取分页,直接展示数据。那不简单,把分页去了直接加载数据不就好了,然后加载的时候就转啊转,终于加载完了,滑动的时候,又卡的一顿一顿的,这样可不行,会被fire掉的,赶紧找解决方案
分析原因
为什么会卡顿?
数据量大,后台一次性返回了大量数据,前端需要生成大量的dom节点来渲染页面,耗费大量资源,造成渲染卡顿。
如何解决卡顿?
屏幕的高度(列表的高度)是固定的,一次性能看到的数据也就是固定的,如果能每次只使用部分数据生成固定的dom节点用来渲染页面,再通过滚动的方式控制渲染,那就可以解决了。
需要解决那些问题?
-
容器的高度(列表的高度)
-
列表项的高度
-
可视区域展示多少条数据
-
可视区域展示哪部分数据
-
让可视区域可以一直滚动
逐个分析上面的问题
-
容器的高度(列表的高度):这个可以自己设置一个高度
-
列表项的高度,也可以自己设置一个高度
-
可视区域展示多少条数据:可以通过计算得到,公式为:
容器的高度/列表项的高度
-
可视区域展示哪部分数据:我们可以通过设置
开始下标
和结束下标
,来截取数据假设初始时,
开始下标
是0,那结束下标
就是开始下标+可视区域的条数
,结束下标
是随开始下标
和可视区域的条数
变化而变化的;然后随着滚动,开始下标
要发生变化,只要更新开始下标
,结束下标
就能计算出来,那我们怎么去更新开始下标
呢?开始下标
怎么计算?如果我们知道被滚动条卷进去了多少个
列表项
,那就能知道现在的开始下标
是多少了;我们知道,列表项的高度,如果能再知道滚动条卷进去了多少高度
,用卷进去的高度/列表项高度
就可以得到卷进去多少个了;scrollTop
属性刚好可以得到滚动条卷入的高度 -
让可视区域可以一直滚动:我们是通过滚动去改变可视区域的数据,而不是增加数据,所以我们需要想办法让可视区域可以滚动,直到没有数据了才停止滚动;可以
padding
进行占位,比如我们有1000条数据,每条数据占40px高度,可视区域显示20条数据,那我们初始的时候,padding-top
为0,padding-bottom
为(1000-20)*40;随着滚动,上下padding
也会发生变化,当padding-top
为0代表在滚动到顶部了,padding-bottom
为0代表滚动到底部了。pading-top
随着开始下标变大而变大(开始下标乘以40),开始下标为0,则padding-top
为0;padding-bottom
随着结束下标变大而减小((数据总条数-结束下标)乘以40),数据总条数等于结束下标,则padding-bottom
为0;也就是让可视区域随着滚动条一起移动
写成代码
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
const containHeight = ref<number>(800) // 容器高度
const listContainerRef = ref<HTMLElement>() // 容器引用
const listRef = ref<HTMLElement>() // 列表引用
const itemHeight = ref<number>(40) // 列表项高度
const dataList = reactive<Array<string>>([]) // 所有数据
let startIndex = ref<number>(0) // 开始下标
const containHeightpx = computed<string>(() => { //容器高度,用于样式
return containHeight.value + 'px'
})
const itemHeightpx = computed<string>(() => { //列表项高度,用于样式
return itemHeight.value + 'px'
})
// 可视区域数量
const showNum = computed(() => {
return ~~(containHeight.value / itemHeight.value) // ~~转换成数字类型,有向下取整的妙用
})
// 结束下标 = 开始下标 + 可视区域数量
const endIndex = computed(() => {
return startIndex.value + showNum.value
})
// 展示的列表
const showList = computed(() => {
return dataList.slice(startIndex.value, endIndex.value)
})
// 列表的padding
const listStyle = computed(() => {
return {
paddingTop: startIndex.value * itemHeight.value + 'px',
paddingBottom: (dataList.length - endIndex.value) * itemHeight.value + 'px'
}
})
// 初始化加载数据
onMounted(() => {
for (let index = 1; index <= 1000; index++) {
dataList.push(`列表项---${index}`)
}
})
// 监听滚动条的变化
const listContainerScroll = () => {
console.log(startIndex.value, endIndex.value);
// 获取滚动条卷入的高度
let scrollTop = listContainerRef.value!.scrollTop
// 更新开始下标
startIndex.value = Math.floor(scrollTop / itemHeight.value);
// 剩下的会通过计算属性自动变化
}
</script>
<template>
<div ref="listContainerRef" class="listContainerClass" @scroll="listContainerScroll">
<div ref="listRef" :style="listStyle">
<div v-for="(item, index) in showList" :key="index" class="itemClass">
{{ item }}
</div>
</div>
<div>没有更多了....</div>
</div>
</template>
<style lang="scss" scoped>
.listContainerClass {
height: v-bind(containHeightpx);
overflow: auto;
border: 1px solid black;
.itemClass {
height: v-bind(itemHeightpx);
}
}
</style>
封装优化一下
- 把它变成一个公用组件,可以通过传值的方式使用
- 给滚动事件加上防抖功能(加了防抖,所以渲染的数量增大点,防止出现空白)
<script setup lang="ts">
import { debounce } from 'lodash';
import { computed, PropType, ref } from 'vue';
const props = defineProps({
// 容器高度
containHeight: {
type: Number,
default: 800
},
// 列表项高度
itemHeight: {
type: Number,
default: 40
},
dataList: {
type: Object as PropType<any>,
default: () => []
}
})
const listContainerRef = ref<HTMLElement>() // 容器引用
const listRef = ref<HTMLElement>() // 列表引用
let startIndex = ref<number>(0) // 开始下标
const containHeightpx = computed<string>(() => { //容器高度,用于样式
return props.containHeight + 'px'
})
const itemHeightpx = computed<string>(() => { //列表项高度,用于样式
return props.itemHeight + 'px'
})
// 可视区域数量
const showNum = computed(() => {
return ~~(props.containHeight / props.itemHeight) * 2 // 由于加了防抖,所以渲染的数量增大点,防止出现空白
})
// 结束下标 = 开始下标 + 可视区域数量
const endIndex = computed(() => {
return startIndex.value + showNum.value
})
// 展示的列表
const showList = computed(() => {
return props.dataList.slice(startIndex.value, endIndex.value)
})
// 列表的padding
const listStyle = computed(() => {
return {
paddingTop: startIndex.value * props.itemHeight + 'px',
paddingBottom: (props.dataList.length - endIndex.value) * props.itemHeight + 'px'
}
})
// 监听滚动条的变化
const scrollEvent = () => {
console.log(startIndex.value, endIndex.value);
// 获取滚动条卷入的高度
let scrollTop = listContainerRef.value!.scrollTop
// 更新开始下标
startIndex.value = Math.floor(scrollTop / props.itemHeight);
// 剩下的会通过计算属性自动变化
}
const listContainerScroll = debounce(scrollEvent, 20)
</script>
<template>
<div ref="listContainerRef" class="listContainerClass" @scroll="listContainerScroll">
<div ref="listRef" :style="listStyle">
<div v-for="(item, index) in showList" :key="index" class="itemClass">
<slot :item="item"></slot>
</div>
</div>
<div>没有更多了....</div>
</div>
</template>
<style lang="scss" scoped>
.listContainerClass {
height: v-bind(containHeightpx);
overflow: auto;
border: 1px solid black;
.itemClass {
height: v-bind(itemHeightpx);
}
}
</style>
使用
<script setup lang="ts">
import VirtualList from '@/components/virtualList/index.vue'
import { onMounted, reactive } from 'vue';
const dataList = reactive<any>([])
onMounted(() => {
for (let index = 0; index < 1000; index++) {
dataList.push({
id: index,
title: '列表项'
})
}
})
</script>
<template>
<VirtualList :dataList="dataList" :containHeight="600" :itemHeight="30">
<template #default="{ item }">
{{ item.title }}-{{ item.id }}
</template>
</VirtualList>
</template>
<style lang="scss" scoped>
</style>
总结
- 由于Vue是双向绑定的,我们只需要关注数据的变化就好,只要不断的更新可视区域的数据
- 通过计算滚动视窗,每次只渲染可见屏幕部分节点,超出屏幕的不可见范围用内填充 padding 代替
- 虚拟列表是对长列表渲染的一种优化,解决大量数据渲染时,造成的渲染性能瓶颈的问题
- 如果你看到这里了,烦请大佬点个赞,鼓励小弟学习,不胜感激,谢谢