前端虚拟化之长列表

2,408 阅读4分钟

背景:

页面包含大量数据,页面的渲染时间比较长,影响用户体验。 在页面开发中,有时会遇到需要一些不能使用分页方式来加载列表数据或者可以分页,不过当前页面数据量很大(包含很多图片)的业务情况,对于此,我们称这种列表叫做 长列表。比如,一些看板数据或者在一些外汇交易系统中,前端会实时的展示用户的持仓情况(收益、亏损、收入等),此时对于用户的持仓列表一般是不能分页的。

高性能渲染十万条数据(时间分片)一文中,提到了可以使用 时间分片的方式来对长列表进行渲染,但这种方式更适用于列表项的DOM结构十分简单的情况。本文会介绍使用 虚拟列表的方式,来同时加载大量数据

使用场景

  1. 不能使用分页方式来加载列表数据;
  2. 可以分页,不过页面包含大量数据或者包含很多图片数据;
  3. 下拉列表数据量比较大;

实现方案

什么是虚拟列表?

虚拟列表其实是按需显示的一种实现,即只对 可见区域进行渲染,对 非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。

假设有1万条记录需要同时渲染,我们屏幕的 可见区域的高度为 500px,而列表项的高度为 50px,则此时我们在屏幕中最多只能看到10个列表项,那么在首次渲染的时候,我们只需加载10条即可。

说完首次加载,再分析一下当滚动发生时,我们可以通过计算当前滚动值得知此时在屏幕 可见区域应该显示的列表项。

假设滚动发生,滚动条距顶部的位置为 150px,则我们可得知在 可见区域内的列表项为 第4项至`第13项。

虚拟列表的实现,实际上就是在首屏加载的时候,只加载 可视区域内需要的列表项,当滚动发生时,动态通过计算获得 可视区域内的列表项,并将 非可视区域内存在的列表项删除。

  • 计算当前 可视区域起始数据索引( startIndex)
  • 计算当前 可视区域结束数据索引( endIndex)
  • 计算当前 可视区域的数据,并渲染到页面中
  • 计算 startIndex对应的数据在整个列表中的偏移位置 startOffset并设置到列表上

由于只是对 可视区域内的列表项进行渲染,所以为了保持列表容器的高度并可正常的触发滚动,将Html结构设计成如下结构:

<div class="infinite-list-container">
    <div class="infinite-list-phantom"></div>
    <div class="infinite-list">
      <!-- item-1 -->
      <!-- item-2 -->
      <!-- ...... -->
      <!-- item-n -->
    </div>
</div>
  • infinite-list-container可视区域的容器
  • infinite-list-phantom 为容器内的占位,高度为总列表高度,用于形成滚动条
  • infinite-list 为列表项的 渲染区域

接着,监听 infinite-list-containerscroll事件,获取滚动位置 scrollTop

  • 假定 可视区域高度固定,称之为 screenHeight
  • 假定 列表每项高度固定,称之为 itemSize
  • 假定 列表数据称之为 listData
  • 假定 当前滚动位置称之为 scrollTop

则可推算出:

  • 列表总高度 listHeight = listData.length * itemSize
  • 可显示的列表项数 visibleCount = Math.ceil(screenHeight / itemSize)
  • 数据的起始索引 startIndex = Math.floor(scrollTop / itemSize)
  • 数据的结束索引 endIndex = startIndex + visibleCount
  • 列表显示数据为 visibleData = listData.slice(startIndex,endIndex)

当滚动后,由于 渲染区域相对于 可视区域已经发生了偏移,此时我需要获取一个偏移量 startOffset,通过样式控制将 渲染区域偏移至 可视区域中。

  • 偏移量 startOffset = scrollTop - (scrollTop % itemSize);

最终的 简易代码如下:

<template>
  <div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)">
    <div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
    <div class="infinite-list" :style="{ transform: getTransform }">
      <div ref="items"
        class="infinite-list-item"
        v-for="item in visibleData"
        :key="item.id"
        :style="{ height: itemSize + 'px',lineHeight: itemSize + 'px' }"
      >{{ item.value }}</div>
    </div>
  </div>
</template>
export default {
  name:'VirtualList',
  props: {
    //所有列表数据
    listData:{
      type:Array,
      default:()=>[]
    },
    //每项高度
    itemSize: {
      type: Number,
      default:200
    }
  },
  computed:{
    //列表总高度
    listHeight(){
      return this.listData.length * this.itemSize;
    },
    //可显示的列表项数
    visibleCount(){
      return Math.ceil(this.screenHeight / this.itemSize)
    },
    //偏移量对应的style
    getTransform(){
      return `translate3d(0,${this.startOffset}px,0)`;
    },
    //获取真实显示列表数据
    visibleData(){
      return this.listData.slice(this.start, Math.min(this.end,this.listData.length));
    }
  },
  mounted() {
    this.screenHeight = this.$el.clientHeight;
    this.start = 0;
    this.end = this.start + this.visibleCount;
  },
  data() {
    return {
      //可视区域高度
      screenHeight:0,
      //偏移量
      startOffset:0,
      //起始索引
      start:0,
      //结束索引
      end:null,
    };
  },
  methods: {
    scrollEvent() {
      //当前滚动位置
      let scrollTop = this.$refs.list.scrollTop;
      //此时的开始索引
      this.start = Math.floor(scrollTop / this.itemSize);
      //此时的结束索引
      this.end = this.start + this.visibleCount;
      //此时的偏移量
      this.startOffset = scrollTop - (scrollTop % this.itemSize);
    }
  }
};

我们实现了基于 文字内容动态撑高列表项情况下的 虚拟列表,但是我们可能会发现,当滚动过快时,会出现短暂的 白屏现象

为了使页面平滑滚动,我们还需要在 可见区域的上方和下方渲染额外的项目,在滚动时给予一些 缓冲,所以将屏幕分为三个区域:

  • 可视区域上方: above
  • 可视区域: screen
  • 可视区域下方: below

定义组件属性 bufferScale,用于接收 缓冲区数据可视区数据比例

props: {
  //缓冲区比例
  bufferScale:{
    type:Number,
    default:1
  }
}

可视区上方渲染条数 aboveCount获取方式如下:

aboveCount(){
  return Math.min(this.start,this.bufferScale * this.visibleCount)
}

可视区下方渲染条数 belowCount获取方式如下:

belowCount(){
  return Math.min(this.listData.length - this.end,this.bufferScale * this.visibleCount);
}

真实渲染数据 visibleData获取方式如下:

visibleData(){
  let start = this.start - this.aboveCount;
  let end = this.end + this.belowCount;
  return this._listData.slice(start, end);
}

我们也可以使用一些封装好的组件,比如:InfiniteScroll

<InfiniteScroll
    initialLoad={false}
    pageStart={0}
    loadMore={loadFrequency === 0 && this.handleInfiniteOnLoad}
    hasMore={loading === 1 && hasMore}
    useWindow={false}
    pullDownToRefresh
    // loader={<div className="loader" key={0}>Loading ...</div>}
  >

虚拟列表与懒加载有何不同

懒加载与虚拟列表其实都是延时加载的一种实现,原理相同但场景略有不同:

  1. 懒加载的应用场景偏向于网络资源请求,解决网络资源请求过多时,造成的网站响应时间过长的问题;
  2. 虚拟列表是对长列表渲染的一种优化,解决大量数据渲染时,造成的渲染性能瓶颈的问题;

在线Demo及完整代码:

codesandbox.io/s/virtualli…

文档参考: cloud.tencent.com/developer/a…