🔥【全网最牛和通俗易懂】虚拟列表(终章),不定高度+动态图片加载,十万条数据流畅渲染全攻略!

1,558 阅读5分钟

大家好,我是前端架构师,关注微信公众号【程序员大卫】领取免费前端精品资料。

背景

今天是 虚拟列表 系列的终章,我将带大家深入探讨 不定高度列表项 的处理方式。如果你还没有看过前两篇内容,可以先点击下面的链接:

虚拟列表-示例.gif

当前方案的优势

相较于市面上其他虚拟列表实现,我的这个方案具备以下优势:

  1. 高效索引查找
    根据滚动方向精准定位起始和结束索引,显著提升性能。
  2. 创新高度调整机制
    无需依赖传统二分查找算法,而是通过记录高度调整值,保证列表项的连续性。
  3. 动态监听高度变化
    利用 ResizeObserver 实时监听可视区域和整体列表高度的变化,确保数据准确。

如果你的项目不支持 ResizeObserver,欢迎在评论区留言,我会单独出一篇文章讲解如何用其他技术解决监听问题。

接下来,我会通过实例,从 简单到复杂 手把手讲解解决方案,并分享一些重要注意事项。

核心实现步骤

1. 初始化数据结构

假设以下场景:

  • 每项预估高度为 100
  • 可视区域高度为 450

初始数据结构如下:

[
  { "top": 0, "bottom": 100, "height": 100 },
  { "top": 100, "bottom": 200, "height": 100 },
  { "top": 200, "bottom": 300, "height": 100 },
  ...
]

预估高度.png

可以发现:

  • 每项的 top 值为前一项的 bottom 值。
  • 每项的 bottom 值为自身 top + height

2. 精确查找索引

根据滚动高度,确定起始和结束索引的公式为:

if (scrollTop >= item.top && scrollTop <= item.bottom) {
   // 找到对应索引
}

注意:
列表项之间必须保持连续,否则会出现无法匹配的情况。例如,如果滚动高度为 140,而列表项如下:

[
  { "top": 0, "bottom": 100 },
  { "top": 200, "bottom": 300 },
  { "top": 300, "bottom": 400 }
]

此时无法找到对应的索引,导致无法渲染虚拟列表。

3. 预估每项高度的作用

预估高度用于初始渲染,后续会根据实际高度进行调整。例如:
预估每项高度为 100,滚动高度为 0,起始索引为 0,结束索引为 4,渲染如下:

预估高度.png

4. 高度修正

渲染完成后,各列表项的实际高度可能不同。通过 ResizeObserver,我们可以动态监听每项高度并修正:

  • 如果列表项 高度较低:结束索引会增加,例如从 4 变为 7

高度较低修正.png

  • 如果列表项 高度较高:结束索引会减少,例如从 4 变为 3

高度较高修正.png

5. 确保列表项连续性

这是实现的核心难点。未渲染的列表项需要与已渲染项保持连续,以下分两种情况讨论:

情况 1:高度连续

如果下一项的 top 大于上一项的 bottom,简单赋值即可:

if (nextItem.top >= lastItem.bottom) {
  nextItem.top = lastItem.bottom;
}

高度连续.png

情况 2:高度不连续

如果下一项的 top 小于上一项的 bottom,需要记录调整值如下:

// 记录调整值
const heightAdjustment = lastItem.bottom - nextItem.top;

高度不连续.png

什么时候使用调整值 heightAdjustment

仅当用户滚动滚动条时使用。在这种情况下,代码会根据滚动条的位置计算对应的列表项索引,此时需要加入调整值 heightAdjustment,以确正确的找到的起始索引和结束索引。

/**
 * 根据滚动位置查找可视区域内的列表项索引
 * @param scrollTop 滚动位置
 * @param direction 查找方向
 * @returns 找到的索引,未找到返回-1
 */
const findVisibleIndex = (
  scrollTop: number,
  direction: "down" | "up"
): number => {
  let index = startIndex.value;
  const itemCount = itemPositions.value.length;

  while (index >= 0 && index < itemCount) {
    const isLastItem = index === itemCount - 1;

    let { top, bottom } = itemPositions.value[index];

    // 处理向下滚动时的高度调整
    if (direction === "down" && index >= heightUpdateState.lastUpdatedIndex) {
      top += heightUpdateState.heightAdjustment;
      bottom += heightUpdateState.heightAdjustment;
    }

    // 检查当前项是否在指定的滚动位置范围内, 或者已经滚动到底部了
    if (scrollTop >= top && (scrollTop <= bottom || isLastItem)) {
      return index;
    }

    direction === "down" ? index++ : index--;
  }

  return 0;
};

6. 滚动方向的优化查找

根据 newScrollTopprevScrollTop,判断滚动方向:

  • 向下滚动:新起始索引在旧索引下方。
  • 向上滚动:新起始索引在旧索引上方。

利用滚动方向查找当前的起始索引和结束索引,比二分查找效率更高。

/**
 * 更新可视区域的起始和结束索引
 * @param scrollTop 滚动位置
 * @param direction 滚动方向
 */
const updateVisibleRange = (scrollTop: number, direction: "up" | "down") => {
  startIndex.value = findVisibleIndex(scrollTop, direction);
  endIndex.value = findVisibleIndex(scrollTop + viewportHeight.value, "down");
};

// 监听滚动位置变化
watch(scrollTop, (newScrollTop, prevScrollTop) => {
  const direction = newScrollTop - prevScrollTop > 0 ? "down" : "up";
  updateVisibleRange(newScrollTop, direction);
});

7. 动态调整后的处理

在某项被删除或高度发生变化时,为了保持页面的流畅性:

  • 使用唯一标识 uid 来复用旧列表项的高度。通常每一项的高度是固定的(如果发生变化,可以进行修正),但其相邻的上一项和下一项可能会发生变化,例如,上一项可能从 item-2 变为 item-1,下一项可能从 item-5 变为 item-6(由于相邻的项被删除)。
  • 然后根据以下规则依次调整每一项数据的 topbottom 值:
    • top = previous.bottom
    • bottom = top + height

结语

代码细节可以查看我的 GitHub 项目,希望大家点个 ⭐⭐⭐ 支持!

GitHub 源码地址:
github.com/feutopia/fe…

如果你对虚拟列表有其他问题或建议,欢迎留言讨论!

最后

点赞👍 + 关注➕ + 收藏❤️ = 学会了🎉。

更多优质内容关注公众号,@前端大卫。