专栏名称: 前端从进阶到入院
我是 ssh,只想用最简单的方式把原理讲明白。wx:sshsunlight,分享前端的前沿趋势和一些有趣的事情。
目录
相关文章推荐
四川大学本科招生  ·  寒假,川大的你,在哪里,有什么故事? ·  昨天  
四川大学本科招生  ·  寒假,川大的你,在哪里,有什么故事? ·  昨天  
兰州大学萃英在线  ·  年味变淡?我们何去何从 ·  2 天前  
浙江大学  ·  大换装!跟浙大一起迎接春天! ·  3 天前  
武汉大学  ·  武大 bot!没上春晚扭秧歌,但和省长握手了 ·  2 天前  
兰州大学萃英在线  ·  一夜鱼龙舞 ·  4 天前  
51好读  ›  专栏  ›  前端从进阶到入院

“不定高”虚拟列表,面试官总考这玩意儿

前端从进阶到入院  · 公众号  ·  · 2024-12-27 09:48

正文

大家好,我是 ssh,我之前找工作的过程中,只要面试官提问到我简历中的虚拟列表,最后总是会加问一句,不定高的虚拟列表怎么解决?搞得我一头雾水,今天我们就来研究一下。


前言

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

什么是不定高虚拟列表

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

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

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

预估高度

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

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

还是和上一篇一样的套路,计算出当前可视区域的高度 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, {
immediatetrue,
});

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 了。代码如下:


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


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;
    } elseif (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上面,代码如下:







请到「今天看啥」查看全文