Vue性能优化之虚拟列表

698 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 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 代替
  • 虚拟列表是对长列表渲染的一种优化,解决大量数据渲染时,造成的渲染性能瓶颈的问题
  • 如果你看到这里了,烦请大佬点个赞,鼓励小弟学习,不胜感激,谢谢