兼备高性能、大数据量的长列表渲染(虚拟列表)

3,842 阅读6分钟

在工作中,有时会遇到需要一些不能使用分页方式来加载列表数据的业务情况,对于此,我们称这种列表叫做长列表。比如,在一些外汇交易系统中,前端会实时的展示用户的持仓情况(收益、亏损、手数等),此时对于用户的持仓列表一般是不能分页的。

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

为什么需要使用虚拟列表
假设我们的长列表需要展示10000条记录,我们同时将10000条记录渲染到页面中,先来看看需要花费多长时间:

<button id="button">button</button><br>
<ul id="container"></ul>  
document.getElementById('button').addEventListener('click',function(){
    // 记录任务开始时间
    let now = Date.now();
    // 插入一万条数据
    const total = 10000;
    // 获取容器
    let ul = document.getElementById('container');
    // 将数据插入容器中
    for (let i = 0; i < total; i++) {
        let li = document.createElement('li');
        li.innerText = ~~(Math.random() * total)
        ul.appendChild(li);
    }
    console.log('JS运行时间:',Date.now() - now);
    setTimeout(()=>{
      console.log('总运行时间:',Date.now() - now);
    },0)
 
    // print JS运行时间: 38
    // print 总运行时间: 957 
  })

当我们点击按钮,会同时向页面中加入一万条记录,通过控制台的输出,我们可以粗略的统计到,JS的运行时间为38ms,但渲染完成后的总时间为957ms。

简单说明一下,为何两次console.log的结果时间差异巨大,并且是如何简单来统计JS运行时间和总渲染时间:

在 JS 的Event Loop中,当JS引擎所管理的执行栈中的事件以及所有微任务事件全部执行完后,才会触发渲染线程对页面进行渲染
第一个console.log的触发时间是在页面进行渲染之前,此时得到的间隔时间为JS运行所需要的时间
第二个console.log是放到 setTimeout 中的,它的触发时间是在渲染完成,在下一次Event Loop中执行的
关于Event Loop的详细内容请参见这篇文章-->

然后,我们通过Chrome的Performance工具来详细的分析这段代码的性能瓶颈在哪里:

image.png

从Performance可以看出,代码从执行到渲染结束,共消耗了960.8ms,其中的主要时间消耗如下:

Event(click) : 40.84ms
Recalculate Style : 105.08ms
Layout : 731.56ms
Update Layer Tree : 58.87ms
Paint : 15.32ms
从这里我们可以看出,我们的代码的执行过程中,消耗时间最多的两个阶段是Recalculate Style和Layout。

Recalculate Style:样式计算,浏览器根据css选择器计算哪些元素应该应用哪些规则,确定每个元素具体的样式。
Layout:布局,知道元素应用哪些规则之后,浏览器开始计算它要占据的空间大小及其在屏幕的位置。
在实际的工作中,列表项必然不会像例子中仅仅只由一个li标签组成,必然是由复杂DOM节点组成的。

那么可以想象的是,当列表项数过多并且列表项结构复杂的时候,同时渲染时,会在Recalculate Style和Layout阶段消耗大量的时间。

而虚拟列表就是解决这一问题的一种实现。

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

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

image.png

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

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

image.png

实现

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

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

image.png

由于只是对可视区域内的列表项进行渲染,所以为了保持列表容器的高度并可正常的触发滚动,将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-container的scroll事件,获取滚动位置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);
最终的简易代码如下:

virtualList.vue 组件

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

<script>
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() {
      // 当前滚动位置
      const scrollTop = this.$refs.list.scrollTop
      // 此时的开始索引
      this.start = Math.floor(scrollTop / this.itemSize)
      // 此时的结束索引
      this.end = this.start + this.visibleCount
      // 此时的偏移量
      this.startOffset = scrollTop - (scrollTop % this.itemSize)
    }
  }
}
</script>

<style scoped>
.infinite-list-container {
  height: 100%;
  overflow: auto;
  position: relative;
  -webkit-overflow-scrolling: touch;
}

.infinite-list-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.infinite-list {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
  text-align: center;
}

.infinite-list-item {
  padding: 10px;
  color: #555;
  box-sizing: border-box;
  border-bottom: 1px solid #999;
}
</style>

组件调试代码

<template>
  <div id="app">
    <VirtualList :listData="data" :itemSize="100" />
  </div>
</template>

<script>
import VirtualList from '@/components/VirtualList'

const d = []
for (let i = 0; i < 1000; i += 1) {
  d.push({ id: i, value: i })
}

export default {
  name: 'App',
  data() {
    return {
      data: d
    }
  },
  components: {
    VirtualList
  }
}
</script>

<style>
html {
  height: 100%;
}
body {
  height: 100%;
  margin: 0;
}
#app {
  height: 100%;
}
</style>

vue3实现

实现无限滚动的组件

<script setup>
import { ref, computed, nextTick, reactive, watchEffect, onUnmounted, defineProps } from 'vue'

const props = defineProps({
    listData: Array
})
// 列表HTMLElementDom
const ulRef = ref(null)

// 屏幕高度
const screenH = document.documentElement.clientHeight

const data = reactive({
    // 列表第一项的高度(起始高度)
    initH: 0,

    // 一行的高度
    unitH: 0,

    // 屏幕范围内能显示个数
    displayCount: 1,

    // 列表起始值
    startIdx: 0
})

const listData = computed(() => {
    console.log('23232');
    let endIdx = data.startIdx + data.displayCount
    if (endIdx >= props.listData.length) endIdx = props.listData.length

    return props.listData.slice(data.startIdx, endIdx).map((v, k) => {
        v.idx = data.startIdx + k + 1
        return v
    })
})

function scrollHandler() {
    // 当前滚动高度
    const curScrollTop = document.documentElement.scrollTop
    if (curScrollTop > data.initH) {
        const addCount = Math.floor((curScrollTop - data.initH) / data.unitH)
        console.log('=====', addCount);
        ulRef.value.style.setProperty('padding-top', `${addCount * data.unitH}px`)
        data.startIdx = addCount
    } else {
        ulRef.value.style.setProperty('padding-top', '0px')
        data.startIdx = 0
    }
}

watchEffect(() => {
    if (props.listData.length > 0) {
        nextTick(() => {
            // 列表距离顶部距离
            data.initH = ulRef.value.getBoundingClientRect().top + document.documentElement.scrollTop
            // 计算每行高度
            data.unitH = ulRef.value.children[0].offsetHeight
            // 计算屏幕内能显示的行数
            data.displayCount = Math.ceil(screenH / data.unitH)
            // 设置列表总高度 = 一行高度 * 行数
            const listH = data.unitH * props.listData.length
            ulRef.value.style.setProperty('height', `${listH}px`)

            window.removeEventListener('scroll', scrollHandler)
            window.addEventListener('scroll', scrollHandler)
        })
    }
})

onUnmounted(() => {
    window.removeEventListener('scroll', scrollHandler)
})
</script>

<template>
  <ul ref="ulRef">
    <li v-for="(listItem, listIndex) in listData" :key="`list-${listIndex}`" :data-idx="listItem.idx">
      <slot :listItem="listItem"></slot>
    </li>
  </ul>
</template>

父组件

<script setup>
import { ref, onMounted } from 'vue';
import InfiniteList from './InfillScroll.vue'
const songs = ref([])
onMounted(() => {
    for(let i = 0; i < 500; i++) {
        songs.value.push({
            title: `item${i+1}`
        })
    }
})
</script>

<template>
  <infinite-list :listData="songs">
    <template #default="{ listItem }">
      <div class="item">{{ listItem.title }}</div>
    </template>
  </infinite-list>
</template>
<style lang="css">
.item{
  height: 42px;
  line-height: 42px;
  text-align: center;
  border-bottom: 1px solid #eee;
}
</style>

滚动条的实现:
1、初始化时就算好了内容区容器的高度(每条数据的高度 * 总数据条数)
2、记录滚动数据(scrollTop),设置内容区容器的padding-top值,且计算出渲染数据的 startIndexendIndex
3、从总数据数组中取当前要渲染的数据(总数据.slice(startIndex, endIndex)
4、渲染完成