面試官:不會“不定高”虛擬列表,你在簡歷上面提他干嘛?
前言
很多同學將虛擬列表當做亮點寫在簡歷上面,但是卻不知道如何手寫,那么這個就不是加分項而是減分項了。
什么是不定高虛擬列表
不定高的意思很簡單,就是不知道每一項item的具體高度,如下圖:
圖片
現(xiàn)在我們有個問題,在不定高的情況下我們就不能根據(jù)當前滾動條的scrollTop去計算可視區(qū)域里面實際渲染的第一個item的index位置,也就是start的值。
沒有start,那么就無法實現(xiàn)在滾動的時候只渲染可視區(qū)域的那幾個item了。
預估高度
既然我們不知道每個item的高度,那么就采用預估高度的方式去實現(xiàn)。比如這樣:
const { listData, itemSize } = defineProps({
// 列表數(shù)據(jù)
listData: {
type: Array,
default: () => [],
},
// 預估item高度,不是真實item高度
itemSize: {
type: Number,
default: 300,
},
});
還是和上一篇一樣的套路,計算出當前可視區(qū)域的高度containerHeight,然后結合預估的itemSize就可以得到當前可視區(qū)域里面渲染的item數(shù)量。代碼如下:
const renderCount = computed(() => Math.ceil(containerHeight.value / itemSize));
注意:由于我們是預估的高度,所以這個renderCount的數(shù)量是不準的。
如果預估的高度比實際高太多,那么實際渲染的item數(shù)量就會不夠,導致頁面下方出現(xiàn)白屏的情況。
如果預估的高度太小,那么這里的item數(shù)量就會渲染的太多了,性能又沒之前那么好。
所以預估item高度需要根據(jù)實際業(yè)務去給一個適當?shù)闹?,理論上是寧可預估小點,也不預估的大了(大了會出現(xiàn)白屏)。
start初始值為0,并且算出了renderCount,此時我們也就知道了可視區(qū)域渲染的最后一個end的值。如下:
const end = computed(() => start.value + renderCount.value);
和上一篇一樣計算end時在下方多渲染了一個item,第一個item有一部分滾出可視區(qū)域的情況時,如果不多渲染可能就會出現(xiàn)白屏的情況。
有了start和end,那么就知道了可視區(qū)域渲染的renderList,代碼如下:
const renderList = computed(() => listData.slice(start.value, end.value + 1));
這樣我們就知道了,初始化時可視區(qū)域應該渲染哪些item了,但是因為我們之前是給每個item預估高度,所以我們應該將這些高度的值糾正過來。
更新高度
為了記錄不定高的list里面的每個item的高度,所以我們需要一個數(shù)組來存每個item的高度。所以我們需要定義一個positions數(shù)組來存這些值。
既然都存了每個item的高度,那么同樣可以使用top、bottom這兩個字段去記錄每個item在列表中的開始位置和結束位置。注意bottom - top的值肯定等于height的值。
還有一個index字段記錄每個item的index的值。positions定義如下:
const positions = ref<
{
index: number;
height: number;
top: number;
bottom: number;
}[]
>([]);
positions的初始化值為空數(shù)組,那么什么時候給這個數(shù)組賦值呢?
答案很簡單,虛擬列表渲染的是props傳入進來的listData。所以我們watch監(jiān)聽listData,加上immediate: true。這樣就可以實現(xiàn)初始化時給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結合預估的itemSize,我們就可以得出每一個item里面的height、top、bottom這幾個字段的值。
還有一個問題,我們需要一個元素來撐開滾動條。在定高的虛擬列表中我們是通過itemSize * listData.length得到的。顯然這里不能那樣做了,由于positions數(shù)組中存的是所有item的位置,那么最后一個item的bottom的值就是列表的真實高度。前面也是不準的,會隨著我們糾正positions中的值后他就是越來越準的了。
所以列表的真實高度為:
const listHeight = computed(
() => positions.value[positions.value.length - 1].bottom
);
此時positions數(shù)組中就已經(jīng)記錄了每個item的具體位置,雖然這個位置是錯的。接下來我們就需要將這些錯誤的值糾正過來,如何糾正呢?
答案很簡單,使用Vue的onUpdated鉤子函數(shù),這個鉤子函數(shù)會在響應式狀態(tài)變更而更新其 DOM 樹之后調(diào)用。也就是會在renderList渲染成DOM后觸發(fā)!
此時這些item已經(jīng)渲染成了DOM節(jié)點,那么我們就可以遍歷這些item的DOM節(jié)點拿到每個item的真實高度。都知道每個item的真實高度了,那么也就能夠更新里面所有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) {
// 說明item的高度不等于預估值
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上面,更新時就可以通過+el.getAttribute("data-index")拿到對應item的index。
itemRefs中存的是所有item的DOM元素,遍歷他就可以拿到每一個item,然后拿到每個item在長列表中的index和真實高度realHeight。
diffVal的值是預估的高度比實際的高度大多少,如果diffVal的值不等于0,說明預估的高度不準。此時就需要將當前item的高度height更新了,由于高度只會影響bottom的值,所以只需要更新當前item的height和bottom。
由于當前item的高度變了,假如diffVal的值為正值,說明我們預估的高度多了。此時我們需要從當前item的下一個元素開始遍歷,直到遍歷完整個長列表。我們預估多了,那么只需要將后面的所有item整體都向上移一移,移動的距離就是預估的差值diffVal。
所以這里需要從index + 1開始遍歷,將遍歷到的所有元素的top和bottom的值都減去diffVal。
將可視區(qū)域渲染的所有item都遍歷一遍,將每個item的高度和位置都糾正過來,同時會將后面沒有渲染到的item的top和bottom都糾正過來,這樣就實現(xiàn)了高度的更新。理論上從頭滾到尾,那么整個長列表里面的所有位置和高度都糾正完了。
開始滾動
通過前面我們已經(jīng)實現(xiàn)了預估高度值的糾正,渲染過的item的高度和位置都是糾正過后的了。此時我們需要在滾動后如何計算出新的start的位置,以及offset偏移量的值。
還是和定高同樣的套路,當滾動條在item中間滾動時復用瀏覽器的滾動條,從一個item滾到另外一個item時才需要更新start的值以及offset偏移量的值。如果你看不懂這句話,建議先看我上一篇如何實現(xiàn)一個定高虛擬列表 文章。
此時應該如何計算最新的start值呢?
很簡單!在positions中存了兩個字段分別是top和bottom,分別表示當前item的開始位置和結束位置。如果當前滾動條的scrollTop剛好在top和bottom之間,也就是scrollTop >= top && scrollTop < bottom,那么是不是就說明當前剛好滾到這個item的位置呢。
并且由于在positions數(shù)組中bottom的值是遞增的,那么問題不就變成了查找第一個item的scrollTop < bottom。所以我們得出:
function getStart(scrollTop) {
return positions.value.findIndex((item) => scrollTop < item.bottom);
}
每次scroll滾動都會觸發(fā)一次這個查找,那么我們可以優(yōu)化上面的算法嗎?
positions數(shù)組中的bottom字段是遞增的,這很符合二分查找的規(guī)律。不了解二分查找的同學可以看看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;
}
和定高的虛擬列表一樣,當在start的item中滾動時直接復用瀏覽器的滾動,無需做任何事情。所以此時的offset偏移量就應該等于當前start的item的top值,也就是start的item前面的所有item加起來的高度。所以得出offset的值為:
offset.value = positions.value[start.value].top;
可能有的小伙伴會迷惑,在start的item中的滾動值為什么不算到offset偏移中去呢?
因為在start的item范圍內(nèi)滾動時都是直接使用的瀏覽器滾動,已經(jīng)有了scrollTop,所以無需加到offset偏移中去。
所以我們得出當scroll事件觸發(fā)時代碼如下:
function handleScroll(e) {
const scrollTop = e.target.scrollTop;
start.value = getStart(scrollTop);
offset.value = positions.value[start.value].top;
}
同樣offset偏移值使用translate3d應用到可視區(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>
這個是最終的運行效果圖:
圖片
完整的父組件代碼如下:
<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: () => [],
},
// 預估item高度,不是真實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) {
// 說明item的高度不等于預估值
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>
總結
這篇文章我們講了不定高的虛擬列表如何實現(xiàn),首先給每個item設置一個預估高度itemSize。然后根據(jù)傳入的長列表數(shù)據(jù)listData初始化一個positions數(shù)組,數(shù)組中的top、bottom、height等屬性表示每個item的位置。然后根據(jù)可視區(qū)域的高度加上itemSize算出可視區(qū)域內(nèi)可以渲染多少renderCount個item。接著就是在onUpdated鉤子函數(shù)中根據(jù)每個item的實際高度去修正positions數(shù)組中的值。
在滾動時查找第一個item的bottom大于scrollTop,這個item就是start的值。offset偏移的值為start的top屬性。
值得一提的是如果不定高的列表中有圖片就不能在onUpdated鉤子函數(shù)中修正positions數(shù)組中的值,而是應該監(jiān)聽圖片加載完成后再去修正positions數(shù)組??梢允褂?nbsp;ResizeObserver 去監(jiān)聽渲染的這一堆item,注意ResizeObserver的回調(diào)會觸發(fā)兩次,第一次為渲染item的時候,第二次為item中的圖片加載完成后。