虚拟列表——一次性请求大量数据优化

Posted Sofiaღ

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了虚拟列表——一次性请求大量数据优化相关的知识,希望对你有一定的参考价值。

情景:

后端一次性传了10000条数据(假设存在),需要前端展示。若真的一次性全部展示出来性能消耗大,一万条数据不明显,十万条呢,肯定会导致页面卡顿的。

实现思路:
使用虚拟列表实现,其实和分页类似,就是前端自己裁剪数据,一次性值展现固定量的数据。如果使用element-plus可以直接使用他们的虚拟化表格,这里就不过多展示了。
布局:三个div,大的容器,大的放滚动事件,里面放两个小的div,一个用来撑开容器,另一个作为可视区域。
步骤1:监听滚动事件,在里面获取scrollTop,计算开始位置,开始位置其实就是scrollTop/height。
步骤2:按长度切割数组,获取需要渲染的内容。
 
<script setup lang="ts">
import  computed, onMounted, reactive, ref  from "vue";
let start = ref(0),
  size = ref(10),
  height = ref(20),
  scrollTop = ref(0),
  totalList = reactive<string[]>([]);

const virtualListRef = ref(null);
onMounted(() => 
  for (let i = 0; i < 10000; i++) 
    totalList.push(`第$i个`);
  

  virtualListRef.value.style.height = height.value * size.value + "px";
);
// 需要渲染的数组
const renderList = computed(() => 
  return totalList.slice(start.value, start.value + size.value);
);

const handleScroll = () => 
  scrollTop.value = virtualListRef.value.scrollTop;
  // 开始的位置等于滚动的距离除高度
  start.value = scrollTop.value / height.value;
;
</script>

HTML

<template>
  <div class="wrapper">
    <div id="virtualList" ref="virtualListRef" @scroll="handleScroll">
      <!-- 空白div -->
      <div :style=" height: totalList.length * height + \'px\' "></div>
      <!-- 可视区域 -->
      <div class="container" :style=" top: scrollTop + \'px\' ">
        <div
          class="item"
          v-for="item in renderList"
          :style=" height: height + \'px\' "
        >
           item 
        </div>
      </div>
    </div>
  </div>
</template>

CSS

<style scoped>
#virtualList 
  position: relative;
  overflow: auto;
  width: 200px;
  height:200px;
  border: 1px green solid;

.container 
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;

</style>

 效果图

 

虚拟滚动列表和css虚拟滚动有思考

虚拟滚动列表助力性能优化

我所理解的是,虚拟滚动需要一次性获取所有数据,但是只渲染显示屏幕可见范围内的那些。

要做到这些我需要知道:

  • 一行的高度
  • 屏幕范围内能显示的行数
  • 列表在页面中距离网页顶部的位置
  • 滚动条高度

假设一次只需要展示 10 条数据,需要加载的数据是一个数组listData,只需要裁剪数据范围listData.slice(0, 10)

随着滚动条向下,将滚动条高度/一行的高度可以计算出当前行数。
而要模拟滚动条高度就要在页面挂载时就手动设置页面的高度为一行高度*listData.length
最后也是最关键的是保持列表一直保持在当前位置上,手动设置列表容器padding-top等于当前滚动条高度。

<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 lang="ts" setup>
import  ref, computed, nextTick, reactive, watchEffect, onUnmounted  from 'vue'

const props = defineProps<
  listData: Array<any>
>()

// 列表HTMLElementDom
const ulRef = ref<any>(null)

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

const data = reactive<any>(
  // 列表第一项的高度(起始高度)
  initH: 0,
  // 一行的高度
  unitH: 0,
  // 屏幕范围内能显示个数
  displayCount: 10,
  // 列表起始值
  startIdx: 0
)

const listData = computed(() => 
  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)
    ulRef.value.style.setProperty('padding-top', `$addCount * data.unitHpx`)
    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', `$listHpx`)

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

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

如何使用?

<template>
  <infinite-list :listData="songs">
    <template #default=" listItem ">
      <div> listItem.title </div>
      <!-- ... -->
    </template>
  </infinite-list>
</template>
<script lang="ts" setup>
import InfiniteList from './InfiniteList.vue'

const songs = [] // 列表数据
</script>

CSS虚拟滚动

这个概念忘了在哪听到的了。其实我认为它更像是一种内容的懒加载。
他需要用到一个css属性:content-visibility:控制一个元素是否渲染其内容,它允许用户代理(浏览器)潜在地省略大量布局和渲染工作,直到需要它为止。
其值为auto时的作用是,如果该元素不在屏幕上,并且与用户无关,则不会渲染其后代元素。

假如我们有这样一段代码:

<div class="g-wrap">
    <div class="textarea-p">...</div>
    // ...
    <div class="textarea-p">...</div>
</div>

基于这种场景,其实我们非常希望对于仍未看到而且还未滚动到的区域,可以延迟加载,只有到我们需要展示、滚动到该处时,页面内容才进行渲染。

所以,content-visibility: auto 就应运而生了,它允许浏览器对于设置了该属性的元素进行判断,如果该元素当前不处于视口内,则不渲染该元素。

我们基于上述的代码,只需要最小化,添加这样一段代码:

.textarea-p 
    content-visibility: auto;

可以看到,在利用 content-visibility: auto 处理长文本、长列表的时候。在滚动页面的过程中,滚动条一直在抖动,这不是一个好的体验。

当然,这也是许多虚拟列表都会存在的一些问题。(当然,目前大多数人会选择“无视”它)
好在,规范制定者也发现了这个问题。这里我们可以使用另外一个 CSS 属性 —— contain-intrinsic-size来解决这个问题。

contain-intrinsic-size用来控制由 content-visibility 指定的元素的自然大小。
其实就是给需要隐藏的元素一个默认(至少是大概的)高度,让滚动条能够提前知道有这些东西。从而一开始就显示出它应该显示的大小。
比如上面的代码:

.textarea-p 
    content-visibility: auto;
    contain-intrinsic-size: 320px;

一些其它思考

这两天想到了一个场景:后端分页下支持删除功能的前端列表如何维持其length
针对这个问题其实去年想到了一个方案,在学姐的认可下终于试着写出来,非常简单:『滚动列表分片请求,区别于一般的“重新请求这一页甚至是回到第一页”的做法。新开一个存储。每次请求10条。后端新增一个字段给出第11-20数据。如果碰上删除,直接从前端存储里拿新的一条接上,保证了一定有10条还不用多发请求。新的请求后更新存储区』

但是这样终究觉得不够高端。高~ 端 ~
于是就在想怎么能够更流批一点。但是这又有一个问题:目前来说这个虽然很简单的方案已经完全能够解决问题。有了一丝头绪的新方案又没有想到能够支撑的真实的案例。离谱了。

以上是关于虚拟列表——一次性请求大量数据优化的主要内容,如果未能解决你的问题,请参考以下文章

虚拟滚动列表和css虚拟滚动有思考

虚拟滚动列表和css虚拟滚动有思考

虚拟滚动列表和css虚拟滚动有思考

WPF的UI虚拟化

记录一次 MongoDB aggregate的性能优化经历

前端渲染优化 - 大量数据展示