Vue3实现动态高度的虚拟滚动列表的示例代码

 更新时间:2025年01月10日 09:36:30   作者:偏向明知山  
虚拟滚动列表是一种优化长列表渲染性能的技术,通过只渲染可视区域内的列表项,减少DOM的渲染数量,本文就来介绍一下Vue3实现动态高度的虚拟滚动列表的示例代码,具有一定的参考价值,感兴趣的可以了解一下

什么是虚拟滚动列表

虚拟滚动列表是一种优化长列表渲染性能的技术,通过只渲染可视区域内的列表项,减少‌DOM的渲染数量,从而提高页面滚动的流畅性。

核心原理

在滚动时,只渲染可视区域内的列表项,而不是一次性渲染所有列表项。通过计算可视区域的起始索引和结束索引,动态渲染该范围内的列表项。当用户滚动时,根据滚动位置动态更新可视区域内的列表项,从而实现虚拟滚动的效果。

图例:

实现思路

为了实现虚拟滚动列表,需要设计三个盒子(可视区盒子、列表容器、列表项容器)

  • 可视区盒子,控制列表展示的区域,超出滚动;
  • 列表容器,包裹所有列表项的盒子,高度为真实列表的高度;
  • 列表项容器,每个列表项外的盒子,用于动态定位展示列表项;

解释:监听1中盒子的滚动事件,在滚动的时候通过事件对象中的scrollTop动态计算对应展示区列表项的开始索引和结束索引,通过列表项的高度计算出对应列表项的偏移量,更新展示区域的列表项;

代码实现

这里需要计算所有列表项外包裹的盒子的高度以及每一项列表偏移量,但是由于我们要渲染的列表项每一项的高度都是不等长的,所以我们只有在对应列表项DOM渲染完成之后才知道每一项的真实高度,然后我们需要维护一个所有列表项对应的高度和偏移量的map数据,用于更新列表,每次在真实的列表项挂在之后动态去更新这个map数据,再以此为模板动态更新包裹的盒子的高度;

list-item容器代码

<!-- list-item.vue -->
<template>
    <div :style="style" ref="domRef">
        <slot name="slot-scope" :data="data"></slot>
    </div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import ResizeObserver from 'resize-observer-polyfill';
 
 
// ResizeObserver兼容低版本浏览器
if (window.ResizeObserver === undefined) {
    window.ResizeObserver = ResizeObserver;
}
const emit = defineEmits(['onSizeChange']);
  
const props = defineProps({
    style: {
        type: Object,
        default: () => { }
    },
    data: {
        type: Object,
        default: () => { }
    },
    index: {
        type: Number,
        default: 0
    }
})
  
const domRef = ref(null);
const resizeObserver = null;
  
  
onMounted(() => {
    const domNode = domRef.value.children[0];
    emit("onSizeChange", props.index, domNode);
    const resizeObserver = new ResizeObserver(() => {
        emit("onSizeChange", props.index, domNode);
    });
    resizeObserver.observe(domNode);
})
  
onUnmounted(() => {
    if (resizeObserver) {
        resizeObserver?.unobserve(domRef.value.children[0]);
    }
})
</script>

此处使用ResizeObserver这个api来监听列表项dom尺寸的改变,用于更新我们维护的列表项map,但是这个api在低版本浏览器会有兼容性问题,导致白屏报错,可安装resize-observer-polyfill兼容低版本浏览器;

list容器代码

<template>
    <div
        class="virtual-wrap"
        :class="{ hideScrollBar: isHideScrollBar }"
        ref="virtualWrap"
        :style="{
            width: width,
            height: height,
        }"
        @scroll="scrollHandle"
    >
        <div class="virtual-content" :style="{height: totalEstimatedHeight +'px'}">
            <list-item v-for="(item,index) in showItemList" :key="item.dataIndex+index" :index="item.dataIndex" :data="item.data" :style="item.style"
                @onSizeChange="sizeChangeHandle">
                <template #slot-scope="slotProps">
                    <slot name="slot-scope" :slotProps="slotProps"></slot>
                </template>
            </list-item>
        </div>
    </div>
</template>
<script setup>
import ListItem from './list-item.vue';
import { ref, onMounted,watch, nextTick } from 'vue'
 
 
const props = defineProps({
    isHideScrollBar: {
        type: Boolean,
        default: false
    },
    height: {
        default: 100,
        type: Number
    },
    width: {
        default: 100,
        type: Number
    },
    itemEstimatedSize: {
        default: 50,
        type: Number
    },
    itemCount: {
        default: 0,
        type: Number
    },
    data: {
        default: ()=>[],
        type: Array
    },
    buffCount:{
        default: 4,
        type: Number
    }
})
 
const virtualWrap = ref(null);
const showItemList = ref([]);
const totalEstimatedHeight=ref(0)
const scrollOffset = ref(0)
 
watch(props.data,()=>{
    getCurrentChildren()
})
 
const sizeChangeHandle = (index, domNode) => {
    const height = domNode.offsetHeight;
    const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
    const itemMetaData = measuredDataMap[index];
    itemMetaData.size = height;
    let offset = 0;
    for (let i = 0; i <= lastMeasuredItemIndex; i++) {
        const itemData = measuredDataMap[i];
        itemData.offset = offset;
        offset += itemData.size;
    }
}
 
// 元数据
const measuredData = {
    measuredDataMap: {},
    lastMeasuredItemIndex: -1,
};
 
const getCurrentChildren = () => {
    //重新计算高度
    estimatedHeight(props.itemEstimatedSize,props.itemCount)
    const [startIndex, endIndex] = getRangeToRender(props, scrollOffset.value)
    const items = [];
    for (let i = startIndex; i <= endIndex; i++) {
        const item = getItemMetaData(i);
        const itemStyle = {
            position: 'absolute',
            height: item.size + 'px',
            width: '100%',
            top: item.offset + 'px',
        };
        items.push({
            style: itemStyle,
            data: props.data[i],
            dataIndex:i
        });
    }
    showItemList.value = items;
}
 
 
const getRangeToRender = (props, scrollOffset) => {
    const { itemCount } = props;
    const startIndex = getStartIndex(props, scrollOffset);
    const endIndex = getEndIndex(props, startIndex + props.buffCount);
    return [
        Math.max(0, startIndex -1 - props.buffCount),
        Math.min(itemCount - 1, endIndex ),
    ];
};
 
const getStartIndex = (props, scrollOffset) => {
    const { itemCount } = props;
    let index = 0;
    while (true) {
        const currentOffset = getItemMetaData(index).offset;
        if (currentOffset >= scrollOffset) return index;
        if (index >= itemCount) return itemCount;
        index++
    }
}
 
const getItemMetaData = (index) => {
    const { itemEstimatedSize = 50 } = props;
    const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
    // 如果当前索引比已记录的索引要大,说明要计算当前索引的项的size和offset
    if (index > lastMeasuredItemIndex) {
        let offset = 0;
        // 计算当前能计算出来的最大offset值
        if (lastMeasuredItemIndex >= 0) {
            const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex];
            offset += lastMeasuredItem.offset + lastMeasuredItem.size;
        }
        // 计算直到index为止,所有未计算过的项
        for (let i = lastMeasuredItemIndex + 1; i <= index; i++) {
            const currentItemSize = itemEstimatedSize;
            measuredDataMap[i] = { size: Number(currentItemSize), offset };
            offset += currentItemSize;
        }
        // 更新已计算的项的索引值
        // measuredData.lastMeasuredItemIndex = index;
    }
    return measuredDataMap[index];
};
 
const getEndIndex = (props, startIndex) => {
    const { height, itemCount } = props;
    // 获取可视区内开始的项
    const startItem = getItemMetaData(startIndex);
    // 可视区内最大的offset值
    const maxOffset = Number(startItem.offset) + Number(height);
    // 开始项的下一项的offset,之后不断累加此offset,知道等于或超过最大offset,就是找到结束索引了
    let offset = Number(startItem.offset) + startItem.size;
    // 结束索引
    let endIndex = startIndex;
 
    // 累加offset
    while (offset <= maxOffset && endIndex < (itemCount - 1)) {
        endIndex++;
        const currentItem = getItemMetaData(endIndex);
        offset += currentItem.size;
    }
     // 更新已计算的项的索引值
    measuredData.lastMeasuredItemIndex = endIndex;
    return endIndex;
};
const estimatedHeight = (defaultEstimatedItemSize = 50, itemCount) => {
    let measuredHeight = 0;
    const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
    // 计算已经获取过真实高度的项的高度之和
    if (lastMeasuredItemIndex >= 0) {
        const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex];
        measuredHeight = lastMeasuredItem.offset + lastMeasuredItem.size;
    }
    // 未计算过真实高度的项数
    const unMeasuredItemsCount = itemCount - measuredData.lastMeasuredItemIndex - 1;
    // 预测总高度
    totalEstimatedHeight.value = measuredHeight + unMeasuredItemsCount * defaultEstimatedItemSize;
}
 
//列表滚动处理
const scrollHandle = (event) => {
    const { scrollTop } = event.currentTarget;
    scrollOffset.value = scrollTop;
    getCurrentChildren();
}
 
onMounted(() => {
    nextTick(() => {
        getCurrentChildren();
    })
})
</script>
<style>
.hideScrollBar::-webkit-scrollbar {
  width: 0;
}
.virtual-wrap {
    position: relative;
    overflow: auto;
}
 
.virtual-content {
    position: relative;
    overflow: auto;
}
</style>

list组件参数

  • props:定义组件的属性,包括是否隐藏滚动条、容器高度、宽度、每项预估高度、总项数、数据列表和缓冲区大小。

  • ref:定义了一些响应式变量,如virtualWrapshowItemListtotalEstimatedHeightscrollOffset

  • watch:监听props.data的变化,当数据变化时重新计算可视区域内的项。

  • sizeChangeHandle:处理列表项大小变化的事件,更新元数据。

  • measuredData:存储已测量项的元数据,包括大小和偏移量。

  • getCurrentChildren:根据滚动位置计算当前需要渲染的项,并更新showItemList

  • getRangeToRender:计算需要渲染的项的起始和结束索引。

  • getStartIndex:根据滚动位置计算可视区域开始的项的索引。

  • getItemMetaData:获取指定索引项的元数据,如果未计算过则进行计算。

  • getEndIndex:根据起始索引和缓冲区大小计算可视区域结束的项的索引。

  • estimatedHeight:计算总高度,包括已测量项和未测量项。

  • scrollHandle:处理滚动事件,更新滚动位置并重新计算可视区域内的项。

  • onMounted:组件挂载后,初始化可视区域内的项。

应用实现

<template>
  <div style="width: 500px; height: 300px">
    <List
      :data="dataList"
      :itemCount="dataList.length"
      :height="500"
      :width="300"
      :isHideScrollBar="false"
      @onSizeChange="handleSizeChange"
    >
      <template #slot-scope="{ slotProps }">
        <!-- 这里定义每个 item 的样式和内容 -->
        <div class="item-content">
          {{ slotProps.data }}
        </div>
      </template>
    </List>
  </div>
</template>

<script setup>
import List from '@/components/List.vue'
import { ref } from 'vue'

const dataList = ref([...Array(100).keys()].map((i) => `Item ${i}`))

const handleSizeChange = (index, domNode) => {
  // 可以在这里处理列表项尺寸变化的逻辑
  console.log(`Item ${index} size changed`, domNode)
}
</script>

<style>
.item-content {
  padding: 10px;
  border-bottom: 1px solid #ddd;
  background-color: #f9f9f9;
}
</style>

到此这篇关于Vue3实现动态高度的虚拟滚动列表的示例代码的文章就介绍到这了,更多相关Vue3动态高度虚拟滚动列表内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家! 

相关文章

  • el-descriptions引入代码中label不生效问题及解决

    el-descriptions引入代码中label不生效问题及解决

    这篇文章主要介绍了el-descriptions引入代码中label不生效问题及解决方案,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2023-12-12
  • Vue中

    Vue中"This dependency was not found"问题的解决方法

    这篇文章主要介绍了Vue中"This dependency was not found"的问题的解决方法,需要的朋友可以参考下
    2018-06-06
  • vue项目的html如何引进public里面的js文件

    vue项目的html如何引进public里面的js文件

    这篇文章主要介绍了vue项目的html如何引进public里面的js文件,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2022-12-12
  • vue+VeeValidate 校验范围实例详解(部分校验,全部校验)

    vue+VeeValidate 校验范围实例详解(部分校验,全部校验)

    validate()可以指定校验范围内,或者是全局的 字段。而validateAll()只能校验全局。这篇文章主要介绍了vue+VeeValidate 校验范围(部分校验,全部校验) ,需要的朋友可以参考下
    2018-10-10
  • Vue技巧Element Table二次封装实战示例

    Vue技巧Element Table二次封装实战示例

    这篇文章主要为大家介绍了Vue技巧Element Table二次封装实战示例,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-11-11
  • 详解Vue.directive 自定义指令

    详解Vue.directive 自定义指令

    这篇文章主要介绍了Vue.directive 自定义指令,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2019-03-03
  • Vue如何实现table表格置顶

    Vue如何实现table表格置顶

    这篇文章主要介绍了Vue如何实现table表格置顶,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2022-04-04
  • Vue3中v-for的使用示例详解

    Vue3中v-for的使用示例详解

    本文主要介绍了Vue3中v-for的使用方法,包括遍历数组、遍历对象、索引访问、嵌套遍历以及结合计算属性和方法的使用,v-for可以帮助用户动态地生成和管理列表数据,并根据需要进行复杂的DOM操作,提供了多种示例,帮助读者更好地理解和使用v-for
    2024-10-10
  • 浅析Vue中渲染函数的使用

    浅析Vue中渲染函数的使用

    在Vue中,渲染函数是一种用于动态生成组件的函数,可以将组件的模板代码编写为JavaScript代码,并在运行时进行渲染,下面我们就来看看它的具体用法吧
    2023-08-08
  • vue cli3 实现分环境打包的步骤

    vue cli3 实现分环境打包的步骤

    这篇文章主要介绍了vue cli3 实现分环境打包的步骤,本文通过图文并茂的形式给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2021-03-03

最新评论