面試官:不會(huì)“不定高”虛擬列表,你在簡(jiǎn)歷上面提他干嘛?
前言
很多同學(xué)將虛擬列表當(dāng)做亮點(diǎn)寫(xiě)在簡(jiǎn)歷上面,但是卻不知道如何手寫(xiě),那么這個(gè)就不是加分項(xiàng)而是減分項(xiàng)了。
什么是不定高虛擬列表
不定高的意思很簡(jiǎn)單,就是不知道每一項(xiàng)item的具體高度,如下圖:
圖片
現(xiàn)在我們有個(gè)問(wèn)題,在不定高的情況下我們就不能根據(jù)當(dāng)前滾動(dòng)條的scrollTop去計(jì)算可視區(qū)域里面實(shí)際渲染的第一個(gè)item的index位置,也就是start的值。
沒(méi)有start,那么就無(wú)法實(shí)現(xiàn)在滾動(dòng)的時(shí)候只渲染可視區(qū)域的那幾個(gè)item了。
預(yù)估高度
既然我們不知道每個(gè)item的高度,那么就采用預(yù)估高度的方式去實(shí)現(xiàn)。比如這樣:
const { listData, itemSize } = defineProps({
// 列表數(shù)據(jù)
listData: {
type: Array,
default: () => [],
},
// 預(yù)估item高度,不是真實(shí)item高度
itemSize: {
type: Number,
default: 300,
},
});
還是和上一篇一樣的套路,計(jì)算出當(dāng)前可視區(qū)域的高度containerHeight,然后結(jié)合預(yù)估的itemSize就可以得到當(dāng)前可視區(qū)域里面渲染的item數(shù)量。代碼如下:
const renderCount = computed(() => Math.ceil(containerHeight.value / itemSize));
注意:由于我們是預(yù)估的高度,所以這個(gè)renderCount的數(shù)量是不準(zhǔn)的。
如果預(yù)估的高度比實(shí)際高太多,那么實(shí)際渲染的item數(shù)量就會(huì)不夠,導(dǎo)致頁(yè)面下方出現(xiàn)白屏的情況。
如果預(yù)估的高度太小,那么這里的item數(shù)量就會(huì)渲染的太多了,性能又沒(méi)之前那么好。
所以預(yù)估item高度需要根據(jù)實(shí)際業(yè)務(wù)去給一個(gè)適當(dāng)?shù)闹?,理論上是寧可預(yù)估小點(diǎn),也不預(yù)估的大了(大了會(huì)出現(xiàn)白屏)。
start初始值為0,并且算出了renderCount,此時(shí)我們也就知道了可視區(qū)域渲染的最后一個(gè)end的值。如下:
const end = computed(() => start.value + renderCount.value);
和上一篇一樣計(jì)算end時(shí)在下方多渲染了一個(gè)item,第一個(gè)item有一部分滾出可視區(qū)域的情況時(shí),如果不多渲染可能就會(huì)出現(xiàn)白屏的情況。
有了start和end,那么就知道了可視區(qū)域渲染的renderList,代碼如下:
const renderList = computed(() => listData.slice(start.value, end.value + 1));
這樣我們就知道了,初始化時(shí)可視區(qū)域應(yīng)該渲染哪些item了,但是因?yàn)槲覀冎笆墙o每個(gè)item預(yù)估高度,所以我們應(yīng)該將這些高度的值糾正過(guò)來(lái)。
更新高度
為了記錄不定高的list里面的每個(gè)item的高度,所以我們需要一個(gè)數(shù)組來(lái)存每個(gè)item的高度。所以我們需要定義一個(gè)positions數(shù)組來(lái)存這些值。
既然都存了每個(gè)item的高度,那么同樣可以使用top、bottom這兩個(gè)字段去記錄每個(gè)item在列表中的開(kāi)始位置和結(jié)束位置。注意bottom - top的值肯定等于height的值。
還有一個(gè)index字段記錄每個(gè)item的index的值。positions定義如下:
const positions = ref<
{
index: number;
height: number;
top: number;
bottom: number;
}[]
>([]);
positions的初始化值為空數(shù)組,那么什么時(shí)候給這個(gè)數(shù)組賦值呢?
答案很簡(jiǎn)單,虛擬列表渲染的是props傳入進(jìn)來(lái)的listData。所以我們watch監(jiān)聽(tīng)listData,加上immediate: true。這樣就可以實(shí)現(xiàn)初始化時(shí)給positions賦值,代碼如下:
watch(() => listData, initPosition, {
immediate: true,
});
function initPosition() {
positions.value = [];
listData.forEach((_item, index) => {
positions.value.push({
index,
height: itemSize,
top: index * itemSize,
bottom: (index + 1) * itemSize,
});
});
}
遍歷listData結(jié)合預(yù)估的itemSize,我們就可以得出每一個(gè)item里面的height、top、bottom這幾個(gè)字段的值。
還有一個(gè)問(wèn)題,我們需要一個(gè)元素來(lái)?yè)伍_(kāi)滾動(dòng)條。在定高的虛擬列表中我們是通過(guò)itemSize * listData.length得到的。顯然這里不能那樣做了,由于positions數(shù)組中存的是所有item的位置,那么最后一個(gè)item的bottom的值就是列表的真實(shí)高度。前面也是不準(zhǔn)的,會(huì)隨著我們糾正positions中的值后他就是越來(lái)越準(zhǔn)的了。
所以列表的真實(shí)高度為:
const listHeight = computed(
() => positions.value[positions.value.length - 1].bottom
);
此時(shí)positions數(shù)組中就已經(jīng)記錄了每個(gè)item的具體位置,雖然這個(gè)位置是錯(cuò)的。接下來(lái)我們就需要將這些錯(cuò)誤的值糾正過(guò)來(lái),如何糾正呢?
答案很簡(jiǎn)單,使用Vue的onUpdated鉤子函數(shù),這個(gè)鉤子函數(shù)會(huì)在響應(yīng)式狀態(tài)變更而更新其 DOM 樹(shù)之后調(diào)用。也就是會(huì)在renderList渲染成DOM后觸發(fā)!
此時(shí)這些item已經(jīng)渲染成了DOM節(jié)點(diǎn),那么我們就可以遍歷這些item的DOM節(jié)點(diǎn)拿到每個(gè)item的真實(shí)高度。都知道每個(gè)item的真實(shí)高度了,那么也就能夠更新里面所有item的top和bottom了。代碼如下:
<template>
<div ref="container" class="container" @scroll="handleScroll($event)">
<div class="placeholder" :style="{ height: listHeight + 'px' }"></div>
<div class="list-wrapper" :style="{ transform: getTransform }">
<div
class="card-item"
v-for="item in renderList"
:key="item.index"
ref="itemRefs"
:data-index="item.index"
>
<span style="color: red"
>{{ item.index }}
<img width="200" :src="item.imgUrl" alt="" />
</span>
{{ item.value }}
</div>
</div>
</div>
</template>
<script setup>
onUpdated(() => {
updatePosition();
});
function updatePosition() {
itemRefs.value.forEach((el) => {
const index = +el.getAttribute("data-index");
const realHeight = el.getBoundingClientRect().height;
let diffVal = positions.value[index].height - realHeight;
const curItem = positions.value[index];
if (diffVal !== 0) {
// 說(shuō)明item的高度不等于預(yù)估值
curItem.height = realHeight;
curItem.bottom = curItem.bottom - diffVal;
for (let i = index + 1; i < positions.value.length - 1; i++) {
positions.value[i].top = positions.value[i].top - diffVal;
positions.value[i].bottom = positions.value[i].bottom - diffVal;
}
}
});
}
</script>
使用:data-index="item.index"將index綁定到item上面,更新時(shí)就可以通過(guò)+el.getAttribute("data-index")拿到對(duì)應(yīng)item的index。
itemRefs中存的是所有item的DOM元素,遍歷他就可以拿到每一個(gè)item,然后拿到每個(gè)item在長(zhǎng)列表中的index和真實(shí)高度realHeight。
diffVal的值是預(yù)估的高度比實(shí)際的高度大多少,如果diffVal的值不等于0,說(shuō)明預(yù)估的高度不準(zhǔn)。此時(shí)就需要將當(dāng)前item的高度height更新了,由于高度只會(huì)影響bottom的值,所以只需要更新當(dāng)前item的height和bottom。
由于當(dāng)前item的高度變了,假如diffVal的值為正值,說(shuō)明我們預(yù)估的高度多了。此時(shí)我們需要從當(dāng)前item的下一個(gè)元素開(kāi)始遍歷,直到遍歷完整個(gè)長(zhǎng)列表。我們預(yù)估多了,那么只需要將后面的所有item整體都向上移一移,移動(dòng)的距離就是預(yù)估的差值diffVal。
所以這里需要從index + 1開(kāi)始遍歷,將遍歷到的所有元素的top和bottom的值都減去diffVal。
將可視區(qū)域渲染的所有item都遍歷一遍,將每個(gè)item的高度和位置都糾正過(guò)來(lái),同時(shí)會(huì)將后面沒(méi)有渲染到的item的top和bottom都糾正過(guò)來(lái),這樣就實(shí)現(xiàn)了高度的更新。理論上從頭滾到尾,那么整個(gè)長(zhǎng)列表里面的所有位置和高度都糾正完了。
開(kāi)始滾動(dòng)
通過(guò)前面我們已經(jīng)實(shí)現(xiàn)了預(yù)估高度值的糾正,渲染過(guò)的item的高度和位置都是糾正過(guò)后的了。此時(shí)我們需要在滾動(dòng)后如何計(jì)算出新的start的位置,以及offset偏移量的值。
還是和定高同樣的套路,當(dāng)滾動(dòng)條在item中間滾動(dòng)時(shí)復(fù)用瀏覽器的滾動(dòng)條,從一個(gè)item滾到另外一個(gè)item時(shí)才需要更新start的值以及offset偏移量的值。如果你看不懂這句話,建議先看我上一篇如何實(shí)現(xiàn)一個(gè)定高虛擬列表 文章。
此時(shí)應(yīng)該如何計(jì)算最新的start值呢?
很簡(jiǎn)單!在positions中存了兩個(gè)字段分別是top和bottom,分別表示當(dāng)前item的開(kāi)始位置和結(jié)束位置。如果當(dāng)前滾動(dòng)條的scrollTop剛好在top和bottom之間,也就是scrollTop >= top && scrollTop < bottom,那么是不是就說(shuō)明當(dāng)前剛好滾到這個(gè)item的位置呢。
并且由于在positions數(shù)組中bottom的值是遞增的,那么問(wèn)題不就變成了查找第一個(gè)item的scrollTop < bottom。所以我們得出:
function getStart(scrollTop) {
return positions.value.findIndex((item) => scrollTop < item.bottom);
}
每次scroll滾動(dòng)都會(huì)觸發(fā)一次這個(gè)查找,那么我們可以優(yōu)化上面的算法嗎?
positions數(shù)組中的bottom字段是遞增的,這很符合二分查找的規(guī)律。不了解二分查找的同學(xué)可以看看leetcode上面的這道題: https://leetcode.cn/problems/search-insert-position/description/。
所以上面的代碼可以優(yōu)化成這樣:
function getStart(scrollTop) {
let left = 0;
let right = positions.value.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (positions.value[mid].bottom === scrollTop) {
return mid + 1;
} elseif (positions.value[mid].bottom < scrollTop) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return left;
}
和定高的虛擬列表一樣,當(dāng)在start的item中滾動(dòng)時(shí)直接復(fù)用瀏覽器的滾動(dòng),無(wú)需做任何事情。所以此時(shí)的offset偏移量就應(yīng)該等于當(dāng)前start的item的top值,也就是start的item前面的所有item加起來(lái)的高度。所以得出offset的值為:
offset.value = positions.value[start.value].top;
可能有的小伙伴會(huì)迷惑,在start的item中的滾動(dòng)值為什么不算到offset偏移中去呢?
因?yàn)樵趕tart的item范圍內(nèi)滾動(dòng)時(shí)都是直接使用的瀏覽器滾動(dòng),已經(jīng)有了scrollTop,所以無(wú)需加到offset偏移中去。
所以我們得出當(dāng)scroll事件觸發(fā)時(shí)代碼如下:
function handleScroll(e) {
const scrollTop = e.target.scrollTop;
start.value = getStart(scrollTop);
offset.value = positions.value[start.value].top;
}
同樣offset偏移值使用translate3d應(yīng)用到可視區(qū)域的div上面,代碼如下:
<template>
<div ref="container" class="container" @scroll="handleScroll($event)">
<div class="placeholder" :style="{ height: listHeight + 'px' }"></div>
<div class="list-wrapper" :style="{ transform: getTransform }">
...省略
</div>
</div>
</template>
<script setup>
const props = defineProps({
offset: {
type: Number,
default: 0,
},
});
const getTransform = computed(() => `translate3d(0,${props.offset}px,0)`);
</script>
這個(gè)是最終的運(yùn)行效果圖:
圖片
完整的父組件代碼如下:
<template>
<div style="height: 100vh; width: 100vw">
<VirtualList :listData="data" :itemSize="50" />
</div>
</template>
<script setup>
import VirtualList from "./dynamic.vue";
import { faker } from "@faker-js/faker";
import { ref } from "vue";
const data = ref([]);
for (let i = 0; i < 1000; i++) {
data.value.push({
index: i,
value: faker.lorem.sentences(),
});
}
</script>
<style>
html {
height: 100%;
}
body {
height: 100%;
margin: 0;
}
#app {
height: 100%;
}
</style>
完整的虛擬列表子組件代碼如下:
<template>
<div ref="container" class="container" @scroll="handleScroll($event)">
<div class="placeholder" :style="{ height: listHeight + 'px' }"></div>
<div class="list-wrapper" :style="{ transform: getTransform }">
<div
class="card-item"
v-for="item in renderList"
:key="item.index"
ref="itemRefs"
:data-index="item.index"
>
<span style="color: red"
>{{ item.index }}
<img width="200" :src="item.imgUrl" alt="" />
</span>
{{ item.value }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUpdated } from "vue";
const { listData, itemSize } = defineProps({
// 列表數(shù)據(jù)
listData: {
type: Array,
default: () => [],
},
// 預(yù)估item高度,不是真實(shí)item高度
itemSize: {
type: Number,
default: 300,
},
});
const container = ref(null);
const containerHeight = ref(0);
const start = ref(0);
const offset = ref(0);
const itemRefs = ref();
const positions = ref<
{
index: number;
height: number;
top: number;
bottom: number;
}[]
>([]);
const end = computed(() => start.value + renderCount.value);
const renderList = computed(() => listData.slice(start.value, end.value + 1));
const renderCount = computed(() => Math.ceil(containerHeight.value / itemSize));
const listHeight = computed(
() => positions.value[positions.value.length - 1].bottom
);
const getTransform = computed(() =>`translate3d(0,${offset.value}px,0)`);
watch(() => listData, initPosition, {
immediate: true,
});
function handleScroll(e) {
const scrollTop = e.target.scrollTop;
start.value = getStart(scrollTop);
offset.value = positions.value[start.value].top;
}
function getStart(scrollTop) {
let left = 0;
let right = positions.value.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (positions.value[mid].bottom === scrollTop) {
return mid + 1;
} elseif (positions.value[mid].bottom < scrollTop) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return left;
}
function initPosition() {
positions.value = [];
listData.forEach((_item, index) => {
positions.value.push({
index,
height: itemSize,
top: index * itemSize,
bottom: (index + 1) * itemSize,
});
});
}
function updatePosition() {
itemRefs.value.forEach((el) => {
const index = +el.getAttribute("data-index");
const realHeight = el.getBoundingClientRect().height;
let diffVal = positions.value[index].height - realHeight;
const curItem = positions.value[index];
if (diffVal !== 0) {
// 說(shuō)明item的高度不等于預(yù)估值
curItem.height = realHeight;
curItem.bottom = curItem.bottom - diffVal;
for (let i = index + 1; i < positions.value.length - 1; i++) {
positions.value[i].top = positions.value[i].top - diffVal;
positions.value[i].bottom = positions.value[i].bottom - diffVal;
}
}
});
}
onMounted(() => {
containerHeight.value = container.value.clientHeight;
});
onUpdated(() => {
updatePosition();
});
</script>
<style scoped>
.container {
height: 100%;
overflow: auto;
position: relative;
}
.placeholder {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.card-item {
padding: 10px;
color: #777;
box-sizing: border-box;
border-bottom: 1px solid #e1e1e1;
}
</style>
總結(jié)
這篇文章我們講了不定高的虛擬列表如何實(shí)現(xiàn),首先給每個(gè)item設(shè)置一個(gè)預(yù)估高度itemSize。然后根據(jù)傳入的長(zhǎng)列表數(shù)據(jù)listData初始化一個(gè)positions數(shù)組,數(shù)組中的top、bottom、height等屬性表示每個(gè)item的位置。然后根據(jù)可視區(qū)域的高度加上itemSize算出可視區(qū)域內(nèi)可以渲染多少renderCount個(gè)item。接著就是在onUpdated鉤子函數(shù)中根據(jù)每個(gè)item的實(shí)際高度去修正positions數(shù)組中的值。
在滾動(dòng)時(shí)查找第一個(gè)item的bottom大于scrollTop,這個(gè)item就是start的值。offset偏移的值為start的top屬性。
值得一提的是如果不定高的列表中有圖片就不能在onUpdated鉤子函數(shù)中修正positions數(shù)組中的值,而是應(yīng)該監(jiān)聽(tīng)圖片加載完成后再去修正positions數(shù)組??梢允褂?nbsp;ResizeObserver 去監(jiān)聽(tīng)渲染的這一堆item,注意ResizeObserver的回調(diào)會(huì)觸發(fā)兩次,第一次為渲染item的時(shí)候,第二次為item中的圖片加載完成后。