大家好,我是前端架构师,关注微信公众号【程序员大卫】领取免费前端精品资料。
背景
今天是 虚拟列表 系列的终章,我将带大家深入探讨 不定高度列表项 的处理方式。如果你还没有看过前两篇内容,可以先点击下面的链接:
当前方案的优势
相较于市面上其他虚拟列表实现,我的这个方案具备以下优势:
- 高效索引查找
根据滚动方向精准定位起始和结束索引,显著提升性能。 - 创新高度调整机制
无需依赖传统二分查找算法,而是通过记录高度调整值,保证列表项的连续性。 - 动态监听高度变化
利用ResizeObserver实时监听可视区域和整体列表高度的变化,确保数据准确。
如果你的项目不支持
ResizeObserver,欢迎在评论区留言,我会单独出一篇文章讲解如何用其他技术解决监听问题。
接下来,我会通过实例,从 简单到复杂 手把手讲解解决方案,并分享一些重要注意事项。
核心实现步骤
1. 初始化数据结构
假设以下场景:
- 每项预估高度为 100
- 可视区域高度为 450
初始数据结构如下:
[
{ "top": 0, "bottom": 100, "height": 100 },
{ "top": 100, "bottom": 200, "height": 100 },
{ "top": 200, "bottom": 300, "height": 100 },
...
]
可以发现:
- 每项的
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,渲染如下:
4. 高度修正
渲染完成后,各列表项的实际高度可能不同。通过 ResizeObserver,我们可以动态监听每项高度并修正:
- 如果列表项 高度较低:结束索引会增加,例如从
4变为7。
- 如果列表项 高度较高:结束索引会减少,例如从
4变为3。
5. 确保列表项连续性
这是实现的核心难点。未渲染的列表项需要与已渲染项保持连续,以下分两种情况讨论:
情况 1:高度连续
如果下一项的 top 大于上一项的 bottom,简单赋值即可:
if (nextItem.top >= lastItem.bottom) {
nextItem.top = lastItem.bottom;
}
情况 2:高度不连续
如果下一项的 top 小于上一项的 bottom,需要记录调整值如下:
// 记录调整值
const heightAdjustment = lastItem.bottom - nextItem.top;
什么时候使用调整值 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. 滚动方向的优化查找
根据 newScrollTop 和 prevScrollTop,判断滚动方向:
- 向下滚动:新起始索引在旧索引下方。
- 向上滚动:新起始索引在旧索引上方。
利用滚动方向查找当前的起始索引和结束索引,比二分查找效率更高。
/**
* 更新可视区域的起始和结束索引
* @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(由于相邻的项被删除)。 - 然后根据以下规则依次调整每一项数据的
top和bottom值:top = previous.bottombottom = top + height
结语
代码细节可以查看我的 GitHub 项目,希望大家点个 ⭐⭐⭐ 支持!
GitHub 源码地址:
github.com/feutopia/fe…
如果你对虚拟列表有其他问题或建议,欢迎留言讨论!
最后
点赞👍 + 关注➕ + 收藏❤️ = 学会了🎉。
更多优质内容关注公众号,@前端大卫。