前言

很多同学将虚拟列表当做亮点写在简历上面,但是却不知道如何手写,那么这个就不是加分项而是减分项了。在上一篇文章欧阳教会你
如何实现一个定高虚拟列表
,但是实际项目中更多的是
不定高虚拟列表
,这篇文章欧阳来教你不定高如何实现。PS:建议先看看欧阳的上一篇
如何实现一个定高虚拟列表
后再来看这篇效果更佳。

欧阳也在找工作,坐标成都求内推!

什么是不定高虚拟列表

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

现在我们有个问题,
在不定高的情况下我们就不能根据当前滚动条的
scrollTop
去计算可视区域里面实际渲染的第一个item的index位置,也就是
start
的值。

没有
start
,那么就无法实现在滚动的时候只渲染可视区域的那几个item了。

预估高度

既然我们不知道每个item的高度,那么就采用
预估高度
的方式去实现。比如这样:

const { listData, itemSize } = defineProps({
  // 列表数据
  listData: {
    type: Array,
    default: () => [],
  },
  // 预估item高度,不是真实item高度
  itemSize: {
    type: Number,
    default: 300,
  },
});

还是和上一篇一样的套路,计算出当前可视区域的高度
containerHeight
,然后结合预估的
itemSize
就可以得到当前可视区域里面渲染的item数量。代码如下:

const renderCount = computed(() => Math.ceil(containerHeight.value / itemSize));

注意:由于我们是预估的高度,所以这个
renderCount
的数量是不准的。

如果预估的高度比实际高太多,那么实际渲染的item数量就会不够,导致页面下方出现白屏的情况。

如果预估的高度太小,那么这里的item数量就会渲染的太多了,性能又没之前那么好。

所以预估item高度需要根据实际业务去给一个适当的值,理论上是宁可预估小点,也不预估的大了(大了会出现白屏)。

start初始值为0,并且算出了
renderCount
,此时我们也就知道了可视区域渲染的最后一个
end
的值。如下:

const end = computed(() => start.value + renderCount.value);

和上一篇一样计算end时在下方多渲染了一个item,第一个item有一部分滚出可视区域的情况时,如果不多渲染可能就会出现白屏的情况。

有了
start

end
,那么就知道了可视区域渲染的
renderList
,代码如下:

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
定义如下:

const positions = ref<
  {
    index: number;
    height: number;
    top: number;
    bottom: number;
  }[]
>([]);

positions
的初始化值为空数组,那么什么时候给这个数组赋值呢?

答案很简单,虚拟列表渲染的是props传入进来的
listData
。所以我们watch监听
listData
,加上
immediate: true
。这样就可以实现初始化时给
positions
赋值,代码如下:

watch(() => listData, initPosition, {
  immediate: true,
});

function initPosition() {
  positions.value = [];
  listData.forEach((_item, index) => {
    positions.value.push({
      index,
      height: itemSize,
      top: index * itemSize,
      bottom: (index + 1) * itemSize,
    });
  });
}

遍历
listData
结合预估的
itemSize
,我们就可以得出每一个item里面的
height

top

bottom
这几个字段的值。

还有一个问题,我们需要一个元素来撑开滚动条。在定高的虚拟列表中我们是通过
itemSize * listData.length
得到的。显然这里不能那样做了,由于
positions
数组中存的是所有item的位置,
那么最后一个item的bottom的值就是列表的真实高度
。前面也是不准的,会随着我们纠正
positions
中的值后他就是越来越准的了。

所以列表的真实高度为:

const listHeight = computed(
  () => positions.value[positions.value.length - 1].bottom
);

此时
positions
数组中就已经记录了每个item的具体位置,虽然这个位置是错的。接下来我们就需要将这些错误的值纠正过来,如何纠正呢?

答案很简单,使用Vue的
onUpdated
钩子函数,这个钩子函数会在
响应式状态变更而更新其 DOM 树之后调用。
也就是会在
renderList
渲染成DOM后触发!

此时这些item已经渲染成了DOM节点,那么我们就可以遍历这些item的DOM节点拿到每个item的真实高度。都知道每个item的真实高度了,那么也就能够更新里面所有item的
top

bottom
了。代码如下:

<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>
onUpdated(() => {
  updatePosition();
});

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;
      }
    }
  });
}
</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
。所以我们得出:

function getStart(scrollTop) {
  return positions.value.findIndex((item) => scrollTop < item.bottom);
}

每次scroll滚动都会触发一次这个查找,那么我们可以优化上面的算法吗?

positions
数组中的
bottom
字段是递增的,这很符合
二分查找
的规律。不了解二分查找的同学可以看看leetcode上面的这道题:
https://leetcode.cn/problems/search-insert-position/description/

所以上面的代码可以优化成这样:

function getStart(scrollTop) {
  let left = 0;
  let right = positions.value.length - 1;
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    if (positions.value[mid].bottom === scrollTop) {
      return mid + 1;
    } else if (positions.value[mid].bottom < scrollTop) {
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }
  return left;
}

和定高的虚拟列表一样,当在
start
的item中滚动时直接复用浏览器的滚动,无需做任何事情。所以此时的
offset
偏移量就应该等于当前
start
的item的
top
值,也就是
start
的item前面的所有item加起来的高度。所以得出
offset
的值为:

offset.value = positions.value[start.value].top;

可能有的小伙伴会迷惑,在
start
的item中的滚动值为什么不算到
offset
偏移中去呢?

因为在
start
的item范围内滚动时都是直接使用的浏览器滚动,已经有了scrollTop,所以无需加到
offset
偏移中去。

所以我们得出当scroll事件触发时代码如下:

function handleScroll(e) {
  const scrollTop = e.target.scrollTop;
  start.value = getStart(scrollTop);
  offset.value = positions.value[start.value].top;
}

同样
offset
偏移值使用
translate3d
应用到可视区域的div上面,代码如下:

<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>
  </div>
</template>

<script setup>
const props = defineProps({
  offset: {
    type: Number,
    default: 0,
  },
});
const getTransform = computed(() => `translate3d(0,${props.offset}px,0)`);
</script>

这个是最终的运行效果图:
demo

完整的父组件代码如下:

<template>
  <div style="height: 100vh; width: 100vw">
    <VirtualList :listData="data" :itemSize="50" />
  </div>
</template>

<script setup>
import VirtualList from "./dynamic.vue";
import { faker } from "@faker-js/faker";
import { ref } from "vue";

const data = ref([]);
for (let i = 0; i < 1000; i++) {
  data.value.push({
    index: i,
    value: faker.lorem.sentences(),
  });
}
</script>

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

完整的虚拟列表子组件代码如下:

<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({
  // 列表数据
  listData: {
    type: Array,
    default: () => [],
  },
  // 预估item高度,不是真实item高度
  itemSize: {
    type: Number,
    default: 300,
  },
});

const container = ref(null);
const containerHeight = ref(0);
const start = ref(0);
const offset = ref(0);
const itemRefs = ref();
const positions = ref<
  {
    index: number;
    height: number;
    top: number;
    bottom: number;
  }[]
>([]);

const end = computed(() => start.value + renderCount.value);
const renderList = computed(() => listData.slice(start.value, end.value + 1));
const renderCount = computed(() => Math.ceil(containerHeight.value / itemSize));
const listHeight = computed(
  () => positions.value[positions.value.length - 1].bottom
);
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;
}

function getStart(scrollTop) {
  let left = 0;
  let right = positions.value.length - 1;
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    if (positions.value[mid].bottom === scrollTop) {
      return mid + 1;
    } else if (positions.value[mid].bottom < scrollTop) {
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }
  return left;
}

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中的图片加载完成后。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

另外欧阳写了一本开源电子书
vue3编译原理揭秘
,看完这本书可以让你对vue编译的认知有质的提升。这本书初、中级前端能看懂,完全免费,只求一个star。

标签: none

添加新评论