前端长列表优化之虚拟滚动vue-virtual-scroller

33,184 阅读6分钟

一、使用背景

业务开发中存在长列表显示需求,项目中一些用户的聊天列表会出现几千条数据的情况,也就意味着要一次性渲染几千条数据,造成明显的卡顿,这时就有必要对聊天列表进行性能优化来提升用户体验。

对于这种需求,可能很多人首先想到的就是使用懒加载进行性能优化,但是对于很长的列表来说懒加载有三个致命的缺点:

  1. 如果一直加载到底, 那么最终还是会出现大量的DOM节点,导致滚动不流畅。
  2. 想要定位到某一个位置的数据会非常困难。
  3. 滚动条无法正确反映操作者当前浏览的信息在全部列表中的位置。而且大量数据加载,一次给我加载十几条,滚到底太慢了。

懒加载无法满足真正的长列表展示,那么如果真正要解决此类问题该怎么办?还有一种思路就是:列表局部渲染,又被称为虚拟列表

二、虚拟滚动

vue-virtual-scroller介绍

当前比较知名的一些第三方库有vue-virtual-scroller、react-tiny-virtual-list、react-virtualized。它们都可以利用局部加载解决列表过长的问题的,vue-virtual-scroller、react-tiny-virtual-list一类的方案只支持虚拟列表,而react-virtualized这种大而全的库则是支持表格、集合、列表等多种情况下的局部加载方案。 ​

下面介绍一下vue-virtual-scroller这个优秀的框架。

像vue-virtual-scroller、react-tiny-virtual-list这种纯虚拟列表的解决方案。它们的实现原理是利用视差和错觉制作一份出一份“虚拟”列表,一个虚拟列表由三部分组成:

  1. 视窗口
  2. 虚拟数据列表(数据展示)
  3. 滚动占位区块(底部滚动区)

image.png

开始使用

// 安装插件
npm install --save vue-virtual-scroller

// 在main.js文件入口文件中引入并注册
import Vue from 'vue'
import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

Vue.use(VueVirtualScroller)

常用的几个组件

主要有 RecycleScroller.vueDynamicScroller.vueDynamicScrollerItem.vue这三个组件,然而RecycleScroller为实现核心

RecycleScroller 和 DynamicScroller 两者之间的区别是什么呢?

在应用上 RecycleScroller 需要item的高度为静态的,也就是列表每个item的高度都是一致的。而 DynamicScroller就可以兼容item的高度为动态的。但是理论上 RecycleScroller也可以实现动态高度的item,只要有方案计算到item的height就可以(DynamicScrollerItem解决的就是这个问题)。

RecycleScroller 和 DynamicScroller常用属性

props属性

items:要在滚动条中显示的项目列表,也就是源数据。 direction(默认值:“vertical”):滚动方向,“vertical(垂直)”或“horizontal(水平)”。 itemSize(默认值:null):以像素为单位显示用于计算滚动大小和位置的项目的高度(或水平模式下的宽度)。如果设置为null(默认值),它将使用可变大小模式。 minItemSize:如果项目的高度(或水平模式下的宽度)未知,则使用的最小大小。 sizeField(默认值:“size”):用于在可变大小模式下获取项目大小的字段。 typeField(默认值:“type”):用于区分列表中不同类型组件的字段。对于每个不同的类型,将创建一个回收项目池。 keyField(默认值:“id”):用于标识项和优化管理渲染视图的字段。 pageMode(默认值:false):启用页面模式。 prerender(默认值:0):为服务器端渲染(SSR)渲染固定数量的项。 buffer(默认值:200):添加到滚动可见区域边缘的像素量,以开始渲染更远的项目。 emitUpdate(默认值:false):每次更新虚拟滚动条内容时都会发出“update”事件(可能会影响性能)。

事件

resize:当滚动条的大小改变时发出。 visible:当滚动条认为自己在页面中可见时发出。 hidden:当滚动条隐藏在页面中时发出。 update(startIndex,endIndex):每次更新视图时发出,仅当emitUpdate prop为true时

默认作用域插槽值

item:在视图中呈现的项。 index:反映每个项目在项目数组中的位置 active:视图是否处于活动状态。活动视图被认为是可见的,并通过定位RecycleScroller。非活动视图不被视为可见,并且对用户隐藏。如果视图处于非活动状态,则应跳过任何与渲染相关的计算。

DynamicScrollerItem常用属性 (该组件只能用于DynamicScroller组件中)

props属性

item(required):滚动器中呈现的项。 active(required):是循环滚轮中激活的保持视图。将防止不必要的大小重新计算。 SizeDependences:可能影响项目大小的值。将监视该值,如果一个值发生变化,则将重新计算大小。建议使用这个而不是watchData。 watchData(默认值:false):深入观察更改项以重新计算大小(不建议,可能影响性能)。 tag(默认值:“div”):用于渲染组件的元素。 emitResize(默认值:false):每次重新计算大小时都会发出调整大小事件(可能影响性能)。

RecycleScroller使用案例

<template>
  <RecycleScroller
    class="scroller"
    :items="list"
    :item-size="32"
    key-field="id"
    v-slot="{ item }">
    <div class="user">
      {{ item.name }}
    </div>
  </RecycleScroller>
</template>
<script>
export default {
  props: {
    list: Array,
  }
}
</script>
<style scoped>
.scroller {
  height: 100%;
}
.user {
  height: 32%;
  padding: 0 12px;
  display: flex;
  align-items: center;
}
</style>

DynamicScroller使用案例

<template>
  <DynamicScroller
    :items="items"
    :min-item-size="54"
    class="scroller">
    <template v-slot="{ item, index, active }">
      <DynamicScrollerItem
        :item="item"
        :active="active"
        :size-dependencies="[item.message]"
        :data-index="index">
        <div class="avatar">
          <img
            :src="item.avatar"
            :key="item.avatar"
            alt="avatar"
            class="image">
        </div>
        <div class="text">{{ item.message }}</div>
      </DynamicScrollerItem>
    </template>
  </DynamicScroller>
</template>
<script>
export default {
  props: {
    items: Array,
  },
}
</script>
<style scoped>
.scroller {
  height: 100%;
}
</style>

三、实际使用过程中遇到的问题

  1. 一开始使用RecycleScroller组件,发现显示出现问题,排查发现是因为聊天列表每一项的高度是通过@media动态控制的,如果缩小尺寸会出现高度显示问题,改为DynamicScroller后问题得到解决。DynamicScroller支持每一项高度和宽度动态变化。
  2. 需求中有一个场景是用户点击聊天列表某一项,该项变成选中状态,当刷新页面后,要定位到刚才选中的那一项。由于虚拟滚动并不是一次性显示所有数据,一开始使用匹配到当前元素并使用选择器选中后定位的方式,这在虚拟滚动中显然是行不通的。现将实现方式改为:找出元素在整个源数据中的位置,然后计算出该位置前面的每一项加起来的高度。将滚动元素的scrollTop改为这个值就行了。

四、源码解读和简单实现

框架实现思路

源码主要逻辑在 RecycleScroller.vue 文件中,主要实现原理是通过 this.updateVisibleItems() 方法计算出startIndex, endIndex 来获取需要渲染的元素数组。此处还会涉及的一个视口的计算,相应值为 scroll: {start: xxx, end: xxx}. 通过视口的可视范围计算出 startIndex,...endIndex 每个元素的对应样式值(偏移值),这里是通过css3的transform控制元素显示。代码是:
style="ready ? { transform: translate${direction === 'vertical' ? 'Y' : 'X'}(${view.position}px) } : null"
大家可能会想,长列表那么多数据,几万条,每个滚动都去遍历一次,那算法复杂度不就很高了吗。所以这里就需要相关的算法知识。在源码的实现中过程中,使用了 二分法 提高程序的执行效率。

分析完 RecycleScroller.vue 接下来就理解DynamicScroller.vue和DynamicScrollerItem.vue的代码。 看代码我们可以发现 DynamicScroller 的实现也依赖了 RecycleScroller.vue。 是通过 DynamicScrollerItem.vue 实现获取每个item的height/width得到数组元素对应的size,再回归到 RecycleScroller.vue 的相应实现。

简单实现

下面代码为手写的虚拟滚动的最简单版本:

<template>
  <div>
    <input type="text" v-model.number="dataLength"><div class="virtual-scroller" @scroll="onScroll" :style="{height: 600 + 'px'}">
      <div class="phantom" :style="{height: this.dataLength * itemHeight + 'px'}">
        <ul :style="{'margin-top': `${scrollTop}px`}">
          <li v-for="item in visibleList" :key="item.brandId" :style="{height: `${itemHeight}px`, 'line-height': `${itemHeight}px`}">
            <div>
              <div>{{item.name}}</div>
            </div>
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: "vue-virtual-scroller",
  data() {
    return {
      itemHeight: 60,
      visibleCount: 10,
      dataLength: 500000, // 总数量
      startIndex: 0,
      endIndex: 10,
      scrollTop: 0
    }
  },
  computed: {
    dataList() {
      const newDataList = [...Array(this.dataLength || 0).keys()].map((v, i) => ({
        brandId: i + 1,
        name: `第${i + 1}项`,
        height: this.itemHeight
      }));
      return newDataList
    },
    visibleList() {
      return this.dataList.slice(this.startIndex, this.endIndex)
    }
  },
  methods: {
    onScroll(e) {
      const scrollTop = e.target.scrollTop
      this.scrollTop = scrollTop
      console.log('scrollTop', scrollTop)
      this.startIndex = Math.floor(scrollTop / this.itemHeight)
      this.endIndex = this.startIndex + 10
    }
  }
}
</script>

<style scoped>
.virtual-scroller {
  border: solid 1px #eee;
  margin-top: 10px;
  height: 600px;
  overflow: auto
}

.phantom {
  overflow: hidden
}

ul {
  background: #ccc;
  list-style: none;
  padding: 0;
  margin: 0;
}

li {
  outline: solid 1px #fff;
}
</style>

参考文档:
github.com/Akryum/vue-…
zhuanlan.zhihu.com/p/164699971
www.geekschool.org/2021/02/20/…

大家以后在实际业务开发中遇到类似的长列表性能优化的时候就可以考虑使用虚拟滚动来做优化了。