怎么使用vue3实现一个优雅的不定高虚拟列表

打印 上一主题 下一主题

主题 1522|帖子 1522|积分 4566

前言

很多同砚将虚拟列表当做亮点写在简历上面,但是却不知道如何手写,那么这个就不是加分项而是减分项了。实际项目中更多的是不定高虚拟列表,这篇文章来教你不定高如何实现。
什么是不定高虚拟列表

不定高的意思很简单,就是不知道每一项item的详细高度,如下图:

如今我们有个问题,在不定高的情况下我们就不能根据当前滚动条的scrollTop去计算可视区域里面实际渲染的第一个item的index位置,也就是start的值。
没有start,那么就无法实如今滚动的时候只渲染可视区域的那几个item了。
预估高度

既然我们不知道每个item的高度,那么就接纳预估高度的方式去实现。比如如许:
  1. const { listData, itemSize } = defineProps({
  2.   // 列表数据
  3.   listData: {
  4.     type: Array,
  5.     default: () => [],
  6.   },
  7.   // 预估item高度,不是真实item高度
  8.   itemSize: {
  9.     type: Number,
  10.     default: 300,
  11.   },
  12. });
复制代码
还是和上一篇一样的套路,计算出当前可视区域的高度containerHeight,然后结合预估的itemSize就可以得到当前可视区域里面渲染的item数量。代码如下:
  1. const renderCount = computed(() => Math.ceil(containerHeight.value / itemSize));
复制代码
注意:由于我们是预估的高度,所以这个renderCount的数量是不准的。
如果预估的高度比实际高太多,那么实际渲染的item数量就会不敷,导致页面下方出现白屏的情况。
如果预估的高度太小,那么这里的item数量就会渲染的太多了,性能又没之前那么好。
所以预估item高度需要根据实际业务去给一个适当的值,理论上是宁愿预估小点,也不预估的大了(大了会出现白屏)。
start初始值为0,而且算出了renderCount,此时我们也就知道了可视区域渲染的末了一个end的值。如下:
  1. const end = computed(() => start.value + renderCount.value);
复制代码
和上一篇一样计算end时在下方多渲染了一个item,第一个item有一部分滚出可视区域的情况时,如果不多渲染大概就会出现白屏的情况。
有了start和end,那么就知道了可视区域渲染的renderList,代码如下:
  1. const renderList = computed(() => listData.slice(start.value, end.value + 1));
复制代码
如许我们就知道了,初始化时可视区域应该渲染哪些item了,但是因为我们之前是给每个item预估高度,所以我们应该将这些高度的值改正过来。
更新高度

为了记录不定高的list里面的每个item的高度,所以我们需要一个数组来存每个item的高度。所以我们需要定义一个positions数组来存这些值。
既然都存了每个item的高度,那么同样可以使用top、bottom这两个字段去记录每个item在列表中的开始位置和竣事位置。注意bottom - top的值肯定等于height的值。
另有一个index字段记录每个item的index的值。positions定义如下:
  1. const positions = ref<
  2.   {
  3.     index: number;
  4.     height: number;
  5.     top: number;
  6.     bottom: number;
  7.   }[]
  8. >([]);
复制代码
positions的初始化值为空数组,那么什么时候给这个数组赋值呢?
答案很简单,虚拟列表渲染的是props传入进来的listData。所以我们watch监听listData,加上immediate: true。如许就可以实现初始化时给positions赋值,代码如下:
  1. watch(() => listData, initPosition, {
  2.   immediate: true,
  3. });
  4. function initPosition() {
  5.   positions.value = [];
  6.   listData.forEach((_item, index) => {
  7.     positions.value.push({
  8.       index,
  9.       height: itemSize,
  10.       top: index * itemSize,
  11.       bottom: (index + 1) * itemSize,
  12.     });
  13.   });
  14. }
复制代码
遍历listData结合预估的itemSize,我们就可以得出每一个item里面的height、top、bottom这几个字段的值。
另有一个问题,我们需要一个元素来撑开滚动条。在定高的虚拟列表中我们是通过itemSize * listData.length得到的。显然这里不能那样做了,由于positions数组中存的是所有item的位置,那么末了一个item的bottom的值就是列表的真实高度。前面也是不准的,会随着我们改正positions中的值后他就是越来越准的了。
所以列表的真实高度为:
  1. const listHeight = computed(
  2.   () => positions.value[positions.value.length - 1].bottom
  3. );
复制代码
此时positions数组中就已经记录了每个item的详细位置,固然这个位置是错的。接下来我们就需要将这些错误的值改正过来,如何改正呢?
答案很简单,使用Vue的onUpdated钩子函数,这个钩子函数会在响应式状态变更而更新其 DOM 树之后调用。也就是会在renderList渲染成DOM后触发!
此时这些item已经渲染成了DOM节点,那么我们就可以遍历这些item的DOM节点拿到每个item的真实高度。都知道每个item的真实高度了,那么也就可以或许更新里面所有item的top和bottom了。代码如下:
  1. <template>
  2.   <div ref="container" class="container" @scroll="handleScroll($event)">
  3.     <div class="placeholder" :style="{ height: listHeight + 'px' }"></div>
  4.     <div class="list-wrapper" :style="{ transform: getTransform }">
  5.       <div
  6.         class="card-item"
  7.         v-for="item in renderList"
  8.         :key="item.index"
  9.         ref="itemRefs"
  10.         :data-index="item.index"
  11.       >
  12.         <span style="color: red"
  13.           >{{ item.index }}
  14.           <img width="200" :src="item.imgUrl" alt="" />
  15.         </span>
  16.         {{ item.value }}
  17.       </div>
  18.     </div>
  19.   </div>
  20. </template>
  21. <script setup>
  22. onUpdated(() => {
  23.   updatePosition();
  24. });
  25. function updatePosition() {
  26.   itemRefs.value.forEach((el) => {
  27.     const index = +el.getAttribute("data-index");
  28.     const realHeight = el.getBoundingClientRect().height;
  29.     let diffVal = positions.value[index].height - realHeight;
  30.     const curItem = positions.value[index];
  31.     if (diffVal !== 0) {
  32.       // 说明item的高度不等于预估值
  33.       curItem.height = realHeight;
  34.       curItem.bottom = curItem.bottom - diffVal;
  35.       for (let i = index + 1; i < positions.value.length - 1; i++) {
  36.         positions.value[i].top = positions.value[i].top - diffVal;
  37.         positions.value[i].bottom = positions.value[i].bottom - diffVal;
  38.       }
  39.     }
  40.   });
  41. }
  42. </script>
复制代码
使用:data-index="item.index"将index绑定到item上面,更新时就可以通过+el.getAttribute(“data-index”)拿到对应item的index。
itemRefs中存的是所有item的DOM元素,遍历他就可以拿到每一个item,然后拿到每个item在长列表中的index和真实高度realHeight。
diffVal的值是预估的高度比实际的高度大多少,如果diffVal的值不等于0,说明预估的高度不准。此时就需要将当前item的高度height更新了,由于高度只会影响bottom的值,所以只需要更新当前item的height和bottom。
由于当前item的高度变了,假如diffVal的值为正值,说明我们预估的高度多了。此时我们需要从当前item的下一个元素开始遍历,直到遍历完备个长列表。我们预估多了,那么只需要将反面的所有item整体都向上移一移,移动的间隔就是预估的差值diffVal。
所以这里需要从index + 1开始遍历,将遍历到的所有元素的top和bottom的值都减去diffVal。
将可视区域渲染的所有item都遍历一遍,将每个item的高度和位置都改正过来,同时会将反面没有渲染到的item的top和bottom都改正过来,如许就实现了高度的更新。理论上从头滚到尾,那么整个长列表里面的所有位置和高度都改正完了。
开始滚动

通过前面我们已经实现了预估高度值的改正,渲染过的item的高度和位置都是改正过后的了。此时我们需要在滚动后如何计算出新的start的位置,以及offset偏移量的值。
还是和定高同样的套路,当滚动条在item中间滚动时复用浏览器的滚动条,从一个item滚到另外一个item时才需要更新start的值以及offset偏移量的值。
此时应该如何计算最新的start值呢?
很简单!在positions中存了两个字段分别是top和bottom,分别表示当前item的开始位置和竣事位置。如果当前滚动条的scrollTop刚好在top和bottom之间,也就是scrollTop >= top && scrollTop < bottom,那么是不是就说明当前刚好滚到这个item的位置呢。
而且由于在positions数组中bottom的值是递增的,那么问题不就酿成了查找第一个item的scrollTop < bottom。所以我们得出:
  1. function getStart(scrollTop) {
  2.   return positions.value.findIndex((item) => scrollTop < item.bottom);
  3. }
复制代码
每次scroll滚动都会触发一次这个查找,那么我们可以优化上面的算法吗?
positions数组中的bottom字段是递增的,这很符合二分查找的规律。不相识二分查找的同砚可以看看leetcode上面的这道题: https://leetcode.cn/problems/search-insert-position/description/
所以上面的代码可以优化成如许:
  1. function getStart(scrollTop) {
  2.   let left = 0;
  3.   let right = positions.value.length - 1;
  4.   while (left <= right) {
  5.     const mid = Math.floor((left + right) / 2);
  6.     if (positions.value[mid].bottom === scrollTop) {
  7.       return mid + 1;
  8.     } else if (positions.value[mid].bottom < scrollTop) {
  9.       left = mid + 1;
  10.     } else {
  11.       right = mid - 1;
  12.     }
  13.   }
  14.   return left;
  15. }
复制代码
和定高的虚拟列表一样,当在start的item中滚动时直接复用浏览器的滚动,无需做任何事情。所以此时的offset偏移量就应该等于当前start的item的top值,也就是start的item前面的所有item加起来的高度。所以得出offset的值为:
  1. offset.value = positions.value[start.value].top;
复制代码
大概有的小同伴会迷惑,在start的item中的滚动值为什么不算到offset偏移中去呢?
因为在start的item范围内滚动时都是直接使用的浏览器滚动,已经有了scrollTop,所以无需加到offset偏移中去。
所以我们得出当scroll变乱触发期间码如下:
  1. function handleScroll(e) {  const scrollTop = e.target.scrollTop;  start.value = getStart(scrollTop);  offset.value = positions.value[start.value].top;
  2. }
复制代码
同样offset偏移值使用translate3d应用到可视区域的div上面,代码如下:
  1. <template>
  2.   <div ref="container" class="container" @scroll="handleScroll($event)">
  3.     <div class="placeholder" :style="{ height: listHeight + 'px' }"></div>
  4.     <div class="list-wrapper" :style="{ transform: getTransform }">
  5.       ...省略
  6.     </div>
  7.   </div>
  8. </template>
  9. <script setup>
  10. const props = defineProps({
  11.   offset: {
  12.     type: Number,
  13.     default: 0,
  14.   },
  15. });
  16. const getTransform = computed(() => `translate3d(0,${props.offset}px,0)`);
  17. </script>
复制代码
完备的父组件代码如下:
  1. <template>
  2.   <div style="height: 100vh; width: 100vw">
  3.     <VirtualList :listData="data" :itemSize="50" />
  4.   </div>
  5. </template>
  6. <script setup>
  7. import VirtualList from "./dynamic.vue";
  8. import { faker } from "@faker-js/faker";
  9. import { ref } from "vue";
  10. const data = ref([]);
  11. for (let i = 0; i < 1000; i++) {
  12.   data.value.push({
  13.     index: i,
  14.     value: faker.lorem.sentences(),
  15.   });
  16. }
  17. </script>
  18. <style>
  19. html {
  20.   height: 100%;
  21. }
  22. body {
  23.   height: 100%;
  24.   margin: 0;
  25. }
  26. #app {
  27.   height: 100%;
  28. }
  29. </style>
复制代码
完备的虚拟列表子组件代码如下:
  1. <template>  <div ref="container" class="container" @scroll="handleScroll($event)">    <div class="placeholder" :style="{ height: listHeight + 'px' }"></div>    <div class="list-wrapper" :style="{ transform: getTransform }">      <div        class="card-item"        v-for="item in renderList"        :key="item.index"        ref="itemRefs"        :data-index="item.index"      >        <span style="color: red"          >{{ item.index }}          <img width="200" :src="item.imgUrl" alt="" />        </span>        {{ item.value }}      </div>    </div>  </div></template><script setup lang="ts">import { ref, computed, watch, onMounted, onUpdated } from "vue";const { listData, itemSize } = defineProps({
  2.   // 列表数据
  3.   listData: {
  4.     type: Array,
  5.     default: () => [],
  6.   },
  7.   // 预估item高度,不是真实item高度
  8.   itemSize: {
  9.     type: Number,
  10.     default: 300,
  11.   },
  12. });
  13. const container = ref(null);const containerHeight = ref(0);const start = ref(0);const offset = ref(0);const itemRefs = ref();const positions = ref<
  14.   {
  15.     index: number;
  16.     height: number;
  17.     top: number;
  18.     bottom: number;
  19.   }[]
  20. >([]);
  21. const end = computed(() => start.value + renderCount.value);
  22. const renderList = computed(() => listData.slice(start.value, end.value + 1));
  23. const renderCount = computed(() => Math.ceil(containerHeight.value / itemSize));
  24. const listHeight = computed(
  25.   () => positions.value[positions.value.length - 1].bottom
  26. );
  27. const getTransform = computed(() => `translate3d(0,${offset.value}px,0)`);watch(() => listData, initPosition, {  immediate: true,});function handleScroll(e) {  const scrollTop = e.target.scrollTop;  start.value = getStart(scrollTop);  offset.value = positions.value[start.value].top;
  28. }function getStart(scrollTop) {
  29.   let left = 0;
  30.   let right = positions.value.length - 1;
  31.   while (left <= right) {
  32.     const mid = Math.floor((left + right) / 2);
  33.     if (positions.value[mid].bottom === scrollTop) {
  34.       return mid + 1;
  35.     } else if (positions.value[mid].bottom < scrollTop) {
  36.       left = mid + 1;
  37.     } else {
  38.       right = mid - 1;
  39.     }
  40.   }
  41.   return left;
  42. }
  43. function initPosition() {  positions.value = [];  listData.forEach((_item, index) => {    positions.value.push({      index,      height: itemSize,      top: index * itemSize,      bottom: (index + 1) * itemSize,    });  });}function updatePosition() {  itemRefs.value.forEach((el) => {    const index = +el.getAttribute("data-index");    const realHeight = el.getBoundingClientRect().height;    let diffVal = positions.value[index].height - realHeight;    const curItem = positions.value[index];    if (diffVal !== 0) {      // 说明item的高度不等于预估值      curItem.height = realHeight;      curItem.bottom = curItem.bottom - diffVal;      for (let i = index + 1; i < positions.value.length - 1; i++) {        positions.value[i].top = positions.value[i].top - diffVal;        positions.value[i].bottom = positions.value[i].bottom - diffVal;      }    }  });}onMounted(() => {  containerHeight.value = container.value.clientHeight;});onUpdated(() => {  updatePosition();});</script><style scoped>.container {  height: 100%;  overflow: auto;  position: relative;}.placeholder {  position: absolute;  left: 0;  top: 0;  right: 0;  z-index: -1;}.card-item {  padding: 10px;  color: #777;  box-sizing: border-box;  border-bottom: 1px solid #e1e1e1;}</style>
复制代码
这篇文章我们讲了不定高的虚拟列表如何实现,首先给每个item设置一个预估高度itemSize。然后根据传入的长列表数据listData初始化一个positions数组,数组中的top、bottom、height等属性表示每个item的位置。然后根据可视区域的高度加上itemSize算出可视区域内可以渲染多少renderCount个item。接着就是在onUpdated钩子函数中根据每个item的实际高度去修正positions数组中的值。
在滚动时查找第一个item的bottom大于scrollTop,这个item就是start的值。offset偏移的值为start的top属性。
值得一提的是如果不定高的列表中有图片就不能在onUpdated钩子函数中修正positions数组中的值,而是应该监听图片加载完成后再去修正positions数组。可以使用 ResizeObserver 去监听渲染的这一堆item,注意ResizeObserver的回调会触发两次,第一次为渲染item的时候,第二次为item中的图片加载完成后。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

慢吞云雾缓吐愁

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表