当前位置: 首页 > news >正文

虚拟列表+无限滚动的实现

一、什么是虚拟列表和无限滚动?

这里我们从它们的概念、原理、优点和场景进行回答。

首先,虚拟列表和无限滚动是在网页开发和应用程序开发中用于优化列表展示和用户体验的两种技术

1. 虚拟列表

  • 概念:虚拟列表是一种只渲染可见区域内列表项的技术。它通过计算当前视口内可见的列表项范围,只创建和更新这些可见项的 DOM 元素,而不是一次性渲染整个列表的所有项。

  • 原理:需要计算可视区域中,渲染元素的开始下标、结束下标,在滚动的时候,去更改这个开始下标,结束下标依赖开始下标。然后,仅渲染这个范围内的列表项,并在用户滚动时动态更新可见区域的内容。(在下面的内容会具体讲解)

  • 优点:显著减少了 DOM 元素的数量,提高了页面的渲染性能和加载速度,尤其是在处理大量数据时,可以避免因创建过多 DOM 元素而导致的内存占用过高和页面卡顿问题。

  • 适用场景:适用于数据量庞大的场景,如商品列表、员工列表、学生名单等。

2. 无限滚动

  • 概念:无限滚动是一种当用户滚动到页面底部时,自动加载更多内容的技术,给用户一种列表内容无限的感觉。
  • 工作原理:这里有两种方式,来加载更多
    • 第一种:通过监听页面的滚动事件,当检测到用户滚动到离页面底部一定距离时,触发加载更多数据的操作。新数据会被追加到当前列表的末尾,实现无缝滚动加载。
    • 第二种:在底部设置一个容器,通过 IntersectionObserver API 来判断这个元素是否出现在视口,从而来加载更多数据。
  • 优点:提供了一种流畅的浏览体验,用户无需手动点击 “加载更多” 按钮或翻页,能够持续浏览内容,提高了用户获取信息的效率和连贯性。
  • 适用场景:常用于社交媒体动态、新闻资讯列表、图片画廊等场景,这些场景通常有大量的内容需要展示,且用户希望能够不断浏览新的信息而不被打断。

总结:

  • 虚拟列表 -> 性能优化
  • 无限滚动 -> 用户体验

二、固定高度虚拟列表

固定高度的虚拟列表实现起来还是比较简单的,因为每个子项的高度是固定的。

需要计算:

  • 开始下标:Math.floor(scrollTop / 每一项高度)
  • 结束下标:开始下标 + 可视区域数量
  • offset:开始下标 * 每一项高度

在这里插入图片描述

具体实现

<template><!-- 列表容器,监听滚动事件 --><divclass="infinite-container"ref="containerRef"@scroll="handleScroll"><!-- 占位区域,用于模拟完整列表的高度 --><divclass="infinite-placeholder":style="{ height: `${containerHeight}px` }"></div><!-- 实际渲染的内容,根据偏移量动态调整位置,因为上面这个滚动条展位容器采用的是定位,但是因为 .render-container 这个容器高度是不会变化的,永远只有5个元素这么高(如果只渲染5个可视元素)随着滚动,这个容器一个是在外层容器的顶部,不会进行偏移,所以需要使用便宜,让 .render-container 容器能正常移动到视口--><divclass="render-container":style="{ transform: `translate3D(0, ${offset}px, 0)` }"><!-- 渲染的列表项 --><divclass="infinite-item"v-for="item in renderedItems":key="item.uid":style="{ height: `${props.itemSize}px` }">{{ item.value }}</div></div></div>
</template><scriptsetuplang="ts"
>import { computed, onMounted, ref } from 'vue'// 定义组件的 propsconst props = defineProps<{listData: { value: any; uid: any }[] // 列表数据,每项包含值和唯一标识符itemSize: number // 每项的高度bufferCount: number // 缓冲区大小}>()// 屏幕高度(用于计算可视区域的显示数量)const screenHeight = ref(0)// 当前滚动距离(顶部到当前可视区域顶部的距离)const scrollTop = ref(0)// 列表容器的引用,用于获取容器的实际高度const containerRef = ref<HTMLElement | null>(null)// 列表总数量const totalItemCount = computed(() => props.listData.length)// 列表容器的总高度,计算方法为:总数量 * 每项高度const containerHeight = computed(() => totalItemCount.value * props.itemSize)// 可视区域显示的数量,取决于容器高度和每项的高度(向上取整) Math.ceil(1.6) => 2const visibleCount = computed(() =>Math.ceil(screenHeight.value / props.itemSize))// // 当前滚动位置对应的起始索引// const startIndex = computed(() => Math.floor(scrollTop.value / props.itemSize))// // 当前滚动位置对应的结束索引,基于起始索引和可视数量// const endIndex = computed(() => startIndex.value + visibleCount.value)// 计算当前可视范围的顶部索引const topIndex = computed(() => Math.floor(scrollTop.value / props.itemSize))// 当前滚动位置对应的起始索引const startIndex = computed(() =>Math.max(0, topIndex.value - props.bufferCount))// 当前滚动位置对应的结束索引,基于起始索引和可视数量const endIndex = computed(() =>Math.min(totalItemCount.value - 1,topIndex.value + visibleCount.value + props.bufferCount))// 当前需要渲染的列表项,基于起始索引和结束索引const renderedItems = computed(() =>props.listData.slice(startIndex.value, endIndex.value))// 偏移位置,用于调整显示位置,使列表与滚动位置对齐const offset = computed(() => startIndex.value * props.itemSize)// 滚动事件处理函数,更新当前滚动位置const handleScroll = (e: Event) => {scrollTop.value = (e.target as HTMLElement).scrollTop}// 组件挂载后,初始化屏幕高度onMounted(() => {screenHeight.value = containerRef.value?.clientHeight ?? 0})
</script><style scoped>/* 列表容器样式 */.infinite-container {border: 1px solid red; /* 红色边框,用于调试 */position: relative; /* 相对定位 */overflow: auto; /* 可滚动 */height: 100%; /* 占满父容器高度 */}/* 占位元素样式,用于模拟完整列表高度 */.infinite-placeholder {position: absolute;left: 0;top: 0;right: 0;z-index: -1; /* 放在内容后面 */}/* 列表项样式 */.infinite-item {border-bottom: 1px solid blue; /* 每项底部的蓝色边框 */}
</style>

三、不定高虚拟列表 + 无限滚动

思路:

  • 需要传递一个新的参数estimatedHeight,表示预估高度,初始化的时候,列表的源数据 * 预估高度,来计算列表的总高度占位
  • 需要缓存一个positions的位置数组,用来记录每个元素的位置信息,类型为:
// 记录 dataSource 数据的位置信息
interface IPosInfo {// 当前pos对应的元素索引index: number// 元素顶部所处位置top: number// 元素底部所处位置bottom: number// 元素高度height: number// 高度差:判断是否需要更新dHeight: number
}const positions = ref<IPosInfo[]>([])
  • 在元素进入可视区域后,去根据元素实际渲染的高度,去更新这个positions数组。当前视口内和已经滚动过的元素,这个 positions 记录的 top、bottom、height 都是实际值,而未出现过的元素,就使用的是预估高度。

    • 计算初始位置信息
    // 初始化:只会计算新增的位置信息,通过estimatedHeight预设高度,进行默认配置
    const initPosition = () => {// 计算加载更多的位置信息(默认为 estimatedHeight * 当前元素下标,需要累加)const pos: IPosInfo[] = []// “加载更多”的数据长度,如果初次渲染,就是当前渲染的数据长度const disLen = props.dataSource.length - state.preLen// 获取当前数据源的长度(加载更多之前的)const currentLen = positions.value.length// 获取当前数据源最后一个元素的高度(加载更多之前的),第一次渲染时,为0const preBottom = positions.value[currentLen - 1]? positions.value[currentLen - 1].bottom: 0// 遍历当前加载数据源,对每个元素通过 estimatedHeight 预设高度,计算初始高度信息for (let i = 0; i < disLen; i++) {const item = props.dataSource[state.preLen + i]pos.push({index: item.id,// 预设高度height: props.estimatedHeight,top: preBottom? preBottom + i * props.estimatedHeight: item.id * props.estimatedHeight,bottom: preBottom? preBottom + (i + 1) * props.estimatedHeight: (item.id + 1) * props.estimatedHeight,// 预设高度与真实高度差:判断是否需要更新,默认为0dHeight: 0})}// 更新数据源位置信息positions.value = [...positions.value, ...pos]// 更新新的长度state.preLen = props.dataSource.length
    }
    
    • 滚动后,更新视口内元素的真实高度,并更新未显示元素的信息
    // 数据 item 渲染完成后,更新数据item的真实高度
    // 但也只是计算可视区域中每一个渲染元素的高度,来重新计算后续未渲染的元素,但是在可视区域前的元素位置信息会被缓存记录
    // 对于可视区域前的元素,其位置信息会被缓存记录,不会被重新计算,从而优化性能。
    const setPosition = () => {// 渲染区域中的元素const nodes = listRef.value?.childrenif (!nodes || !nodes.length) returnArray.from(nodes).forEach((node) => {// 获取每个元素高度const rect = node.getBoundingClientRect()// 在每个渲染元素上添加id标识,:id="String(i.id)",用于滚动时定位// 获取当前元素const item = positions.value[Number(node.id)]// 判断是否需要更新,通过position之前计算的高度 - 当前渲染后的真实高度,来得到一个差值,如果差值不为0,则更新位置信息const dHeight = item.height - rect.height// 更新当前元素的位置信息if (dHeight) {item.height = rect.heightitem.bottom = item.bottom - dHeightitem.dHeight = dHeight}})// 获取渲染区域中第一个元素的idconst startId = Number(nodes[0].id)// 获取第一个元素的dHeight 差值信息let startDHeight = positions.value[startId].dHeightconst len = positions.value.length// 第一个元素在上面的循环中,已经调整过了,虽然上面遍历了可视区域的其他元素,但是因为有这个dheight,导致高度不是很准确,需要重新计算positions.value[startId].dHeight = 0// 遍历渲染区域中第二个元素到数据源最后一个数据,在可视区域的元素通过dheight进行校准,// 不在可视区域的根据estimatedHeight高度,来调整top、bottomfor (let i = startId + 1; i < len; i++) {const item = positions.value[i]item.top = positions.value[i - 1].bottomitem.bottom = item.bottom - startDHeightif (item.dHeight !== 0) {startDHeight += item.dHeightitem.dHeight = 0}}// 更新列表高度state.listHeight = positions.value[len - 1].bottom
    }
    
  • 当滚动到底部时,触发加载更多,并向外抛出事件

完整代码:

<template><div class="virtuallist-container"><divclass="virtuallist-content"ref="contentRef"@scroll="handleScroll"><divclass="virtuallist-placeholder":style="{ height: `${state.listHeight}px` }"></div><divclass="virtuallist-list"ref="listRef":style="scrollStyle"><divclass="virtuallist-list-item"v-for="i in renderList":key="i.id":id="String(i.id)"><slotname="item":item="i"></slot></div></div></div></div>
</template><script setup lang="ts" generic="T extends {id:number}">
import {type CSSProperties,computed,nextTick,onMounted,reactive,ref,watch
} from 'vue'
import { useThrottle } from './tool'// 不定高虚拟列表
interface IEstimatedListProps<T> {loading: boolean// 预估高度(越小越好,尽量比实际渲染高度小)estimatedHeight: numberdataSource: T[]
}
const props = defineProps<IEstimatedListProps<T>>()// 加载更多触发的事件
const emit = defineEmits<{getMoreData: []
}>()defineSlots<{item(props: { item: T }): any
}>()// 容器 ref
const contentRef = ref<HTMLDivElement>()
// 列表 ref
const listRef = ref<HTMLDivElement>()
// 记录 dataSource 数据的位置信息
interface IPosInfo {// 当前pos对应的元素索引index: number// 元素顶部所处位置top: number// 元素底部所处位置bottom: number// 元素高度height: number// 高度差:判断是否需要更新dHeight: number
}
const positions = ref<IPosInfo[]>([])const state = reactive({// contentRef 容器高度viewHeight: 0,// 列表高度listHeight: 0,// 渲染列表开始下标startIndex: 0,// 可视区域最大渲染数量maxCount: 0,// 存储“加载更多”前的数据长度preLen: 0
})const init = () => {// 获取容器高度state.viewHeight = contentRef.value ? contentRef.value.offsetHeight : 0// 获取可视区域最大渲染数量(+1是为了设置缓冲大小)state.maxCount = Math.ceil(state.viewHeight / props.estimatedHeight) + 1
}onMounted(() => {init()
})// 渲染列表结束下标
const endIndex = computed(() =>Math.min(props.dataSource.length, state.startIndex + state.maxCount)
)// 渲染列表,做截取
const renderList = computed(() =>props.dataSource.slice(state.startIndex, endIndex.value)
)// 列表偏移量(offset)
const offsetDis = computed(() =>state.startIndex > 0 ? positions.value[state.startIndex - 1].bottom : 0
)// 列表偏移样式
const scrollStyle = computed(() =>({transform: `translate3d(0, ${offsetDis.value}px, 0)`} as CSSProperties)
)// 在数据初始化,数据源改变时会重新计算位置信息
watch(() => props.dataSource.length,() => {initPosition()// 确保在最新dom中,能获取到可视区域的元素,并调整位置信息nextTick(() => {setPosition()})}
)// 初始化:只会计算新增的位置信息,通过estimatedHeight预设高度,进行默认配置
const initPosition = () => {// 计算加载更多的位置信息(默认为 estimatedHeight * 当前元素下标,需要累加)const pos: IPosInfo[] = []// “加载更多”的数据长度,如果初次渲染,就是当前渲染的数据长度const disLen = props.dataSource.length - state.preLen// 获取当前数据源的长度(加载更多之前的)const currentLen = positions.value.length// 获取当前数据源最后一个元素的高度(加载更多之前的),第一次渲染时,为0const preBottom = positions.value[currentLen - 1]? positions.value[currentLen - 1].bottom: 0// 遍历当前加载数据源,对每个元素通过 estimatedHeight 预设高度,计算初始高度信息for (let i = 0; i < disLen; i++) {const item = props.dataSource[state.preLen + i]pos.push({index: item.id,// 预设高度height: props.estimatedHeight,top: preBottom? preBottom + i * props.estimatedHeight: item.id * props.estimatedHeight,bottom: preBottom? preBottom + (i + 1) * props.estimatedHeight: (item.id + 1) * props.estimatedHeight,// 预设高度与真实高度差:判断是否需要更新,默认为0dHeight: 0})}// 更新数据源位置信息positions.value = [...positions.value, ...pos]// 更新新的长度state.preLen = props.dataSource.length
}// 数据 item 渲染完成后,更新数据item的真实高度
// 但也只是计算可视区域中每一个渲染元素的高度,来重新计算后续未渲染的元素,但是在可视区域前的元素位置信息会被缓存记录
// 对于可视区域前的元素,其位置信息会被缓存记录,不会被重新计算,从而优化性能。
const setPosition = () => {// 渲染区域中的元素const nodes = listRef.value?.childrenif (!nodes || !nodes.length) returnArray.from(nodes).forEach((node) => {// 获取每个元素高度const rect = node.getBoundingClientRect()// 在每个渲染元素上添加id标识,:id="String(i.id)",用于滚动时定位// 获取当前元素const item = positions.value[Number(node.id)]// 判断是否需要更新,通过position之前计算的高度 - 当前渲染后的真实高度,来得到一个差值,如果差值不为0,则更新位置信息const dHeight = item.height - rect.height// 更新当前元素的位置信息if (dHeight) {item.height = rect.heightitem.bottom = item.bottom - dHeightitem.dHeight = dHeight}})// 获取渲染区域中第一个元素的idconst startId = Number(nodes[0].id)// 获取第一个元素的dHeight 差值信息let startDHeight = positions.value[startId].dHeightconst len = positions.value.length// 第一个元素在上面的循环中,已经调整过了,虽然上面遍历了可视区域的其他元素,但是因为有这个dheight,导致高度不是很准确,需要重新计算positions.value[startId].dHeight = 0// 遍历渲染区域中第二个元素到数据源最后一个数据,在可视区域的元素通过dheight进行校准,// 不在可视区域的根据estimatedHeight高度,来调整top、bottomfor (let i = startId + 1; i < len; i++) {const item = positions.value[i]item.top = positions.value[i - 1].bottomitem.bottom = item.bottom - startDHeightif (item.dHeight !== 0) {startDHeight += item.dHeightitem.dHeight = 0}}// 更新列表高度state.listHeight = positions.value[len - 1].bottom
}const handleScroll = useThrottle(() => {const { scrollTop, clientHeight, scrollHeight } = contentRef.value!// 根据二分查找更新开始下标state.startIndex = binarySearch(positions.value, scrollTop)// 如果滚动到底部,则触发加载更多,这里也可以通过 IntersactionObserver 来实现const bottom = scrollHeight - clientHeight - scrollTopif (bottom <= 20) {!props.loading && emit('getMoreData')}
})// 监听开始下标,更新位置信息
watch(() => state.startIndex,() => {setPosition()}
)// 二分查找
const binarySearch = (list: IPosInfo[], value: number) => {let left = 0,right = list.length - 1,templateIndex = -1while (left < right) {const midIndex = Math.floor((left + right) / 2)const midValue = list[midIndex].bottomif (midValue === value) return midIndex + 1else if (midValue < value) left = midIndex + 1else if (midValue > value) {if (templateIndex === -1 || templateIndex > midIndex)templateIndex = midIndexright = midIndex}}return templateIndex
}
</script><style scoped lang="scss">
.virtuallist-placeholder {position: absolute;left: 0;top: 0;right: 0;z-index: -1; /* 放在内容后面 */
}
.virtuallist {&-container {width: 100%;height: 100%;}&-content {width: 100%;height: 100%;overflow: auto;position: relative;}&-list-item {width: 100%;box-sizing: border-box;}
}
</style>

相关文章:

  • Java自定义注解详解
  • RT Thread Studio创建USB虚拟串口工程
  • 设计一个食品种类表
  • 黑马点评redis改 part 6
  • Spring AOP思想与应用详解
  • 0804标星_复制_删除-网络ajax请求2-react-仿低代码平台项目
  • 量子力学:量子通信
  • 基于javaweb的SpringBoot在线电子书小说阅读系统设计与实现(源码+文档+部署讲解)
  • 收藏按钮变色问题
  • 基于物理信息的神经网络在异常检测Anomaly Detection中的应用:实践指南
  • 猿人学web端爬虫攻防大赛赛题第19题——乌拉乌拉乌拉
  • Java练习1
  • Java 设计模式心法之第26篇 - 解释器 (Interpreter) - 构建领域特定语言的解析引擎
  • 用Python做有趣的AI项目 2【进阶版】:智能聊天机器人 v2(NLTK + 规则引擎)
  • Godot开发2D冒险游戏——第三节:游戏地图绘制
  • 【Hive入门】Hive基础操作与SQL语法:DML操作全面解析
  • uniapp+vue3表格样式
  • 心磁图技术突破传统局限!心血管疾病早筛迈入“三零“新时代
  • 神经网络笔记 - 神经网络
  • 2025年大一ACM训练-搜索
  • 暴涨96%!一季度“中国游中国购”持续升温,还有更多利好
  • 影子调查|23岁男子驾照拟注销背后的“被精神病”疑云
  • 新加坡选情渐热:播客、短视频各显神通,总理反对身份政治
  • 俄总统助理:普京与美特使讨论了恢复俄乌直接谈判的可能性
  • 杨荫凯已任浙江省委常委、组织部部长
  • 金隅集团:今年拿地将选择核心热门地块,稳健审慎投资