虚拟列表在哈啰商城H5中的实践

avatar
@哈啰

本文作者:马新新

为什么要用虚拟列表

哈啰好物商城中,存在大量的长列表数据,例如下图列出的商品瀑布流、特卖会场列表等。用户滑动到页面底部,则加载新的数据进来,页面上的DOM节点越来越多,容易导致页面卡顿,交互不流畅。针对这种长列表的场景,我们可以采用虚拟列表来做优化。

 

什么是虚拟列表

虚拟列表,顾名思义,并不是真实数据列表的一个体现,而是只截取一部分列表数据用于填充可视区域。

image.png

以品牌会场页面为例,在屏幕可展示范围内可能只有3条卡片数据,而在屏幕可视范围之外,我们可能已经滑动请求了几百上千条数据,这些数据不被我们看见,却在页面DOM中真实存在。我们完全可以只渲染屏幕中间可以被看见的几张卡片,当用户滚动时,根据滚动距离来计算替换这几张卡片里的数据,来达到模拟真实列表滚动的效果。

如何实现一个虚拟列表

我们先写一个简单的Demo,来模拟页面下拉加载数据的场景, 每页加载20条数据,下拉到底部继续加载20条数据,我们可以在开发者工具中看到,DOM节点在持续的增加。

2.gif

现在我们来一步步实现一个虚拟列表。假设我们的滚动可视区域高度为1000px,每个列表项高度为100px, 那么可视区域可以渲染出10条数据。当滚动距离为0时,渲染的列表项数据索引是从0到9( 由于我们用数组的slice方法切割数组时,索引是一个左闭右开区间,因此这里做数据切割时是slice(0, 10))。当滚动了100px之后,相当于把第一个列表项滚动上去了,那实际渲染的列表项数据就可以替换成从1到10(slice(0, 11))。

同理可推,当滚动了scrollTop距离之后,相当于把前面(scrollTop / itemHeight)个列表项滚动上去了,那实际渲染的列表项数据就是从(scrollTop / itemHeight) 到 (scrollTop / itemHeight) + 9。

当然,如果滚动的scrollTop不足一个列表项高度,则当前列表项还在可视区域内,不能替换,所以我们使用scrollTop/itemHeight时,要向下取整。

image.png

定义以下变量:

  • 滚动区域高度记为scrollContainerHeight
  • 每一个列表项的高度是固定的,记为itemHeight
  • 列表可视范围内的列表项数量,记为visibleCount
  • 滚动的距离记为scrollTop
  • 完整的列表数据,记为listData
  • 实际渲染的列表数据,记为visibleList
  • 列表项起始索引,记为startIndex
  • 列表项结束索引,记为endIndex
  • 列表向下偏移量,记为scrollOffset

得出以下计算公式:

  • visibleCount = Math.ceil(scrollContainerHeight / itemHeight)
  • startIndex = Math.floor((scrollTop / itemHeight))
  • endIndex = startIndex + visibleCount
  • visibleList = listData.slice(startIndex, endIndex)
  • scrollOffset = startIndex * itemHeight

我们用代码实现看看:


<template>
  <div @scroll="scrollEvent($event)" class="list-container">
    <div :style="{height: `${totalHeight}px`, transform: `translateY(${scrollOffset}px)`}" class="list-wrapper">
      <div v-for="item in visibleList" :key="item" class="list-item">
        {{ item }}
      </div>
    </div>
  </div>
</template>
const totalHeight = computed(() => {
      return listData.value.length * itemHeight.value;
})

const scrollEvent = (e) => {
      const scrollTop = e.target.scrollTop; // 获取滚动距离
      startIndex.value = Math.floor(scrollTop / itemHeight.value); // 起始索引为滚动距离/单个列表项高度
      endIndex.value = startIndex.value + visibleCount.value; // 结束索引为起始索引+可视区域内的列表项数量
      visibleList.value = listData.value.slice(startIndex.value, endIndex.value);
      scrollOffset.value = startIndex.value * itemHeight.value; // 偏移量为已滑动出去的列表项数量*单个列表项高度
    }

看下现在的实现效果,无论列表有多少项,页面中始终只会渲染10个列表DOM。

1.gif

不定高的虚拟列表

上面的Demo只是讲述了列表项高度固定时的一个最基础的演示,实际业务应用中,我们还经常会遇到列表项高度不固定的场景,例如商品瀑布流。由于列表项高度不同,如果仍然使用上面的方式去计算索引替换数据会不准确,页面会发生抖动。我们可以先设置一个预估的高度,当列表项加载出来后,获取实际渲染的列表项高度,进行更新。

预估列表项每一项的高度为50px,那么我们得到初始的position数组为, 其中index为列表项数据的实际索引,height为该列表卡片的高度,top为该卡片距离顶部的距离,bottom为该卡片底边的位置。

image.png

当页面渲染出来后,我们获取到每一项的实际高度,如果实际高度和之前预估的高度不一致,就更新该项的height值。

例如:index为0的列表项实际高度为44px,则height更新为44px,由于高度小了6px,底边的位置也就向上了6px,所以bottom更新为50 - 6 = 44。

假设index为1的列表项实际高度就是我们预估的50,但由于index为0的列表项高度减少,导致index为1的列表项的top和bottom也需要相应的减少6, 由此我们发现,当某一项高度改变后,在这一项之后的所有列表项的top和bottom都会受到影响,我们都要去做一次数据更新。

const updateItemHeight = () => {
        const nodes = visibleItemRef.value;
        nodes.forEach((node) => {
            if (!node) {
                return;
            }
            const rect = node.getBoundingClientRect();
            const id = node.id;
            const oldHeight = positions.value[id].height; // 获取当前渲染的列表项前一次高度
            const currentHeight = rect.height; // 获取当前渲染的列表项当前高度
            const diffHeight = oldHeight - currentHeight; // 获取两次高度的差值
            if (diffHeight !== 0) {
                positions.value[id].height = currentHeight; // 更新这一项的高度
                positions.value[id].bottom = positions.value[id].bottom - diffHeight;
            }
        });
        const startId = +nodes[0].id; // 当前渲染的列表项第一项的实际索引
        // 由于当前索引的高度有变化,从当前索引往后的所有项的top和bottom都要更新
        for(let i = startId+1; i<positions.value.length; i+=1) {
            positions.value[i].top = positions.value[i-1].bottom; // 当前项距离顶部的距离就等于上一项底边的位置
            positions.value[i].bottom = positions.value[i].top + positions.value[i].height// 当前项底边的位置就等于当前项距离顶部的位置+当前项的卡片高度
        }

    };

image.png

以上图渲染的列表项为例,当滚动距离超过22时,说明index=0的这一张卡片已经被滑出可视区域,此时的startIndex可以替换为1。

在固定高度的情况下,我们的startIndex=滚动距离/单张卡片的高度,即Math.floor(scrollTop / itemHeight.value)。

在不定高度的情况下,我们只需在position数组中,查找到第一个bottom > scrollTop的卡片,记为startIndex, 偏移量修改为startIndex - 1的卡片的bottom值。

const scrollEvent = (e) => {
      const scrollTop = e.target.scrollTop; // 获取滚动距离
      const startItem = positions.value.find((p) => {
        return p.bottom > scrollTop;
      });
      if (startItem) {
        startIndex.value = startItem.index; // 起始索引为第一个bottom值大于scrollTop的
      } else {
        startIndex.value = 0;
      }
      endIndex.value = startIndex.value + visibleCount.value; // 结束索引为起始索引+可视区域内的列表项数量
      visibleList.value = listData.value.slice(
        startIndex.value,
        endIndex.value
      );
      if (startIndex.value > 0) {
        scrollOffset.value = positions.value[startIndex.value - 1].bottom; // 偏移量为已滑动出去的列表项的底边位置
      } else {
        scrollOffset.value = 0;
      }
    };

上述所有代码实现只是从最基本的思路入手实现的简单Demo,在实际实现过程中,我们还可以对虚拟列表的代码做很多的优化。例如结合分页请求、设置缓冲区、计算偏移量的方法用二分查找等方式降低搜索次数、滚动节流。当然,业界已经有很多封装好的库可以直接拿来用,例如vue-virtual-scroller。

商城H5中的实践效果

在商城H5的品牌特卖会场,我们在引入了vue-virtual-scroller的基础上,添加了下拉分页请求,效果如下:

f429602f-d429-45f9-8ebb-94f85e6ea497.gif

关注公众号「哈啰技术」,第一时间收到最新技术推文。