Vue3實現(xiàn)動態(tài)高度的虛擬滾動列表的示例代碼
什么是虛擬滾動列表
虛擬滾動列表是一種優(yōu)化長列表渲染性能的技術(shù),通過只渲染可視區(qū)域內(nèi)的列表項,減少DOM的渲染數(shù)量,從而提高頁面滾動的流暢性。
核心原理
在滾動時,只渲染可視區(qū)域內(nèi)的列表項,而不是一次性渲染所有列表項。通過計算可視區(qū)域的起始索引和結(jié)束索引,動態(tài)渲染該范圍內(nèi)的列表項。當(dāng)用戶滾動時,根據(jù)滾動位置動態(tài)更新可視區(qū)域內(nèi)的列表項,從而實現(xiàn)虛擬滾動的效果。
圖例:

實現(xiàn)思路
為了實現(xiàn)虛擬滾動列表,需要設(shè)計三個盒子(可視區(qū)盒子、列表容器、列表項容器)
- 可視區(qū)盒子,控制列表展示的區(qū)域,超出滾動;
- 列表容器,包裹所有列表項的盒子,高度為真實列表的高度;
- 列表項容器,每個列表項外的盒子,用于動態(tài)定位展示列表項;
解釋:監(jiān)聽1中盒子的滾動事件,在滾動的時候通過事件對象中的scrollTop動態(tài)計算對應(yīng)展示區(qū)列表項的開始索引和結(jié)束索引,通過列表項的高度計算出對應(yīng)列表項的偏移量,更新展示區(qū)域的列表項;
代碼實現(xiàn)
這里需要計算所有列表項外包裹的盒子的高度以及每一項列表偏移量,但是由于我們要渲染的列表項每一項的高度都是不等長的,所以我們只有在對應(yīng)列表項DOM渲染完成之后才知道每一項的真實高度,然后我們需要維護一個所有列表項對應(yīng)的高度和偏移量的map數(shù)據(jù),用于更新列表,每次在真實的列表項掛在之后動態(tài)去更新這個map數(shù)據(jù),再以此為模板動態(tài)更新包裹的盒子的高度;
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來監(jiān)聽列表項dom尺寸的改變,用于更新我們維護的列表項map,但是這個api在低版本瀏覽器會有兼容性問題,導(dǎo)致白屏報錯,可安裝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;
}
}
// 元數(shù)據(jù)
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;
// 如果當(dāng)前索引比已記錄的索引要大,說明要計算當(dāng)前索引的項的size和offset
if (index > lastMeasuredItemIndex) {
let offset = 0;
// 計算當(dāng)前能計算出來的最大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;
// 獲取可視區(qū)內(nèi)開始的項
const startItem = getItemMetaData(startIndex);
// 可視區(qū)內(nèi)最大的offset值
const maxOffset = Number(startItem.offset) + Number(height);
// 開始項的下一項的offset,之后不斷累加此offset,知道等于或超過最大offset,就是找到結(jié)束索引了
let offset = Number(startItem.offset) + startItem.size;
// 結(jié)束索引
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;
// 計算已經(jīng)獲取過真實高度的項的高度之和
if (lastMeasuredItemIndex >= 0) {
const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex];
measuredHeight = lastMeasuredItem.offset + lastMeasuredItem.size;
}
// 未計算過真實高度的項數(shù)
const unMeasuredItemsCount = itemCount - measuredData.lastMeasuredItemIndex - 1;
// 預(yù)測總高度
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組件參數(shù)
props:定義組件的屬性,包括是否隱藏滾動條、容器高度、寬度、每項預(yù)估高度、總項數(shù)、數(shù)據(jù)列表和緩沖區(qū)大小。ref:定義了一些響應(yīng)式變量,如virtualWrap、showItemList、totalEstimatedHeight和scrollOffset。watch:監(jiān)聽props.data的變化,當(dāng)數(shù)據(jù)變化時重新計算可視區(qū)域內(nèi)的項。sizeChangeHandle:處理列表項大小變化的事件,更新元數(shù)據(jù)。measuredData:存儲已測量項的元數(shù)據(jù),包括大小和偏移量。getCurrentChildren:根據(jù)滾動位置計算當(dāng)前需要渲染的項,并更新showItemList。getRangeToRender:計算需要渲染的項的起始和結(jié)束索引。getStartIndex:根據(jù)滾動位置計算可視區(qū)域開始的項的索引。getItemMetaData:獲取指定索引項的元數(shù)據(jù),如果未計算過則進行計算。getEndIndex:根據(jù)起始索引和緩沖區(qū)大小計算可視區(qū)域結(jié)束的項的索引。estimatedHeight:計算總高度,包括已測量項和未測量項。scrollHandle:處理滾動事件,更新滾動位置并重新計算可視區(qū)域內(nèi)的項。onMounted:組件掛載后,初始化可視區(qū)域內(nèi)的項。
應(yīng)用實現(xiàn)
<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 的樣式和內(nèi)容 -->
<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>到此這篇關(guān)于Vue3實現(xiàn)動態(tài)高度的虛擬滾動列表的示例代碼的文章就介紹到這了,更多相關(guān)Vue3動態(tài)高度虛擬滾動列表內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
vue 監(jiān)聽某個div垂直滾動條下拉到底部的方法
今天小編就為大家分享一篇vue 監(jiān)聽某個div垂直滾動條下拉到底部的方法,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-09-09
Vue3.2?setup語法糖及Hook函數(shù)基本使用
這篇文章主要為大家介紹了Vue3.2?setup語法糖及Hook函數(shù)基本使用示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-07-07
vue2.0和mintui-infiniteScroll結(jié)合如何實現(xiàn)無線滾動加載
這篇文章主要介紹了vue2.0和mintui-infiniteScroll結(jié)合如何實現(xiàn)無線滾動加載,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2023-10-10
如何使用el-table實現(xiàn)純前端導(dǎo)出(適用于el-table任意表格)
我們?nèi)粘W鲰椖?特別是后臺管理系統(tǒng),常常需要導(dǎo)出excel文件,這篇文章主要給大家介紹了關(guān)于如何使用el-table實現(xiàn)純前端導(dǎo)出的相關(guān)資料,本文適用于el-table任意表格,需要的朋友可以參考下2024-03-03
vue使用window.open()跳轉(zhuǎn)頁面的代碼案例
這篇文章主要介紹了vue中對window.openner的使用,vue使用window.open()跳轉(zhuǎn)頁面的代碼案例,本文通過實例代碼給大家詳細講解,需要的朋友可以參考下2022-11-11
vue自定義橫向滾動條css導(dǎo)航兩行排列布局實現(xiàn)示例
這篇文章主要為大家介紹了vue自定義橫向滾動條css導(dǎo)航兩行排列布局實現(xiàn)示例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-08-08
在vue中使用inheritAttrs實現(xiàn)組件的擴展性介紹
這篇文章主要介紹了在vue中使用inheritAttrs實現(xiàn)組件的擴展性介紹,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-12-12
詳解vuex 中的 state 在組件中如何監(jiān)聽
本篇文章主要介紹了詳解vuex 中的 state 在組件中如何監(jiān)聽,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-05-05

