前言
实现方案参考至:定高的虚拟列表会了,那不定高的...... 哈,我也会!看我详解一波!🤪🤪🤪之前用原生 JS 实现了一个定高的虚拟 - 掘金 (juejin.cn),本文相当于是一份个人总结,加深印象和实现思路
实现背景: Vue3 + Vant4(H5端)
前置准备
- 两个概念:源数据(可能有成百上千条)、视图区域数据(可能就显示20、30条)
- 回顾前端虚拟列表------uniapp小程序实战实现(手搓版) - 掘金 (juejin.cn),我们需要所有数据的数量,以此来算出整个占位盒子的高度;然后在此后的滚动中,利用子绝父相,不断地去更新top值,从而实现视图区域试图的更新。但是在不定高中,每一项的高度都是不固定的,所以我们无法通过 itemHeight * length 来算出总高度,那么怎么做呢?
- 同时,虚拟列表想要实现视图区域数据的切换,我们需要不断的通过滚动位置更新数组截断的起止点,在定高的实现方案里面,我们可以直接通过 scrollTop / itemHeight 来进行判断,在不定高中,同样受限于每一项的高度,我们又如何去更新数组截断的起点?
这里给出的解决方案是:预设高度。通过给数据项一个预知高度,首次便可以算出总高度;然后在借由高度差,更新视图区域的每一项的真实高度 因为你会发现,总高度就是整个虚拟列表实现的基础。 当然了,预设的高度肯定是无法和每一个子项的真实高度一模一样的,这里也就避免不了重排重绘的问题
我们需要什么?
- 我们需要
positions
数组记录每一个子项的相关信息,包括当前子项在整个数据源 中的索引(后续解决由于预设高度和真实高度存在偏差问题时需要使用)、高度(先是预设高度,后是真实高度)、子项顶部距离容器顶部的距离、子项底部距离容器顶部的距离、高度差(预设高度和真实高度的插值)
同时发现,整个容器的高度,其实就等于最后一项item的bottom值
vue
<template>
<div class="fs-estimated-virtuallist-container">
<div class="fs-estimated-virtuallist-content" ref="contentRef">
<div
class="fs-estimated-virtuallist-list"
ref="listRef"
:style="scrollStyle"
>
<div
class="fs-estimated-virtuallist-list-item"
v-for="i in renderList"
:key="i.index"
:id="String(i.index)"
>
<slot name="item" :item="i"></slot>
</div>
</div>
<!-- <div class="loading-box" v-if="props.isLoading">
<van-loading />
</div> -->
</div>
</div>
</template>
const props = defineProps({
dataSource: Array, // 源数据
estimatedHeight: Number, // 每一项的预设高度
isLoading: {
type: Boolean,
defaule: false,
},
})
const emit = defineEmits(['getMoreData'])
const contentRef = ref()
const listRef = ref()
// 源数据的每一项的相关信息
const positions = ref([])
const state = reactive({
viewHeight: 0,
listHeight: 0,
startIndex: 0,
maxCount: 0,
preLen: 0,
})
// 初始化每一项的位置信息
const initPosition = () => {
const pos = []
const disLen = props.dataSource.length - state.preLen
const currentLen = positions.value.length
const preBottom = positions.value[currentLen - 1]
? positions.value[currentLen - 1].bottom
: 0
for (let i = 0; i < disLen; i++) {
const item = props.dataSource[state.preLen + i]
pos.push({
index: item.index,
height: props.estimatedHeight,
top: preBottom
? preBottom + i * props.estimatedHeight
: item.index * props.estimatedHeight,
bottom: preBottom
? preBottom + (i + 1) * props.estimatedHeight
: (item.index + 1) * props.estimatedHeight,
dHeight: 0,
})
}
positions.value = [...positions.value, ...pos]
// 记录下上次存的数据数量
state.preLen = props.dataSource.length
}
- 但前面也说了,预设高度和真实高度是存在偏差的,所以我们需要更新这个偏差。更新完这个偏差之后,我们就能够顺利的拿到所有数据形成的高度
vue
// 数据 item 渲染完成后,更新数据item的真实高度
const setPosition = () => {
const nodes = listRef.value.children
if (!nodes || !nodes.length) return console.log('获取children失败')
Array.from(nodes).forEach((node) => {
// 每一项的元素大小信息
const rect = node.getBoundingClientRect()
// positions里面存的是源数据的每一项的信息,nodes仅是视图区域的数据的每一项的信息
const item = positions.value[+node.id]
// 预设高度和真实高度的差
const dHeight = item.height - rect.height
if (dHeight) {
item.height = rect.height
item.bottom = item.bottom - dHeight
item.dHeight = dHeight
}
})
// id存的其实是下标
const startId = +nodes[0].id
const len = positions.value.length
// 在列表中,其中有一项的高度有偏差,后面的子项的信息都会做出相对应的修改,而且不一定只有第一个元素有偏差,所以在后续的循环过程中,需要累加这个startHeight(如果有偏差的话)
let startHeight = positions.value[startId].dHeight
positions.value[startId].dHeight = 0
// 从第二项开始,因为第一项前面已经处理了
for (let i = startId + 1; i < len; i++) {
const item = positions.value[i]
item.top = positions.value[i - 1].bottom
item.bottom = item.bottom - startHeight
if (item.dHeight !== 0) {
startHeight += item.dHeight
item.dHeight = 0
}
}
state.listHeight = positions.value[len - 1].bottom
}
注意:positions和nodes所存放的内容的区别,前者是存了所有源数据的每一项的信息,后者是存着视图区域的每一项的信息。偏差只有等到在视图区域出现时才能知道,但是偏差出现之后需要更新的却是一整个数据源列表的高度
一旦有高度差,此后的每一项都要进行信息的更新。同时,第一个item有高度差,可能第二个、第三个...也会有高度差,所以这个高度差从上至下还需要累加起来,用于后续的item的更新
见
startHeight += item.dHeight
3. 两个事件的执行时机:initPosition
用于初始化每一项的位置信息,setPosition
用于更新每一项的位置信息(主要是拿着预设高度和真实高度的偏差进行逻辑处理)。那么,当数据源发生变化时,就应该执行initPosition
和setPosition
;当用户滚动过程中,由于视图区域展示的内容不断在变化,setPosition
也要不断执行
vue
watch(
() => props.dataSource.length,
() => {
initPosition()
nextTick(() => {
setPosition()
})
}
)
watch(
() => state.startIndex,
() => {
setPosition()
}
)
滚动过程:
前面讲到,在定高的实现方案中,通过 scrollTop / itemHeight 就能更新数组截断的起点,从而更新视图区域的数据。但现在,itemHeight不是固定的,所以自然无法通过这个方法实现。这里采用的是二分法
这里个人感觉是最妙的,因为个人很少有算法和实际应用场景相结合的场景,还是太菜了...
还记不记得前面我们使用了一个positions
,存的是源数据的每一项的信息,在滚动事件中,通过二分查找,找到一项,该项的bottom
值等于滚动的距离,那么,他就是我们视图区域数据的新起点
vue
const endIndex = computed(() =>
Math.min(props.dataSource.length, state.startIndex + state.maxCount)
)
const renderList = computed(() =>
props.dataSource.slice(state.startIndex, endIndex.value)
)
const init = () => {
state.viewHeight = contentRef.value ? contentRef.value.offsetHeight : 0
state.maxCount = Math.ceil(state.viewHeight / props.estimatedHeight) + 1 // 预设高度一定要比真实DOM渲染的时候的最小高度小一点,因为maxCount是固定的
contentRef.value && contentRef.value.addEventListener('scroll', handleScroll)
}
// 处理滚动事件
const handleScroll = () => {
const { scrollTop, clientHeight, scrollHeight } = contentRef.value
state.startIndex = binarySearch(positions.value, scrollTop)
const bottom = scrollHeight - clientHeight - scrollTop
if (bottom <= 20) {
!props.isLoading && emit('getMoreData')
}
}
// 二分查找startIndex
const binarySearch = (list, value) => {
let left = 0,
right = list.length - 1,
templateIndex = -1
while (left < right) {
const midIndex = Math.floor((left + right) / 2)
const midValue = list[midIndex].bottom
if (midValue === value) return midIndex + 1
else if (midValue < value) left = midIndex + 1
else if (midValue > value) {
if (templateIndex === -1 || templateIndex > midIndex)
templateIndex = midIndex
right = midIndex
}
}
return templateIndex
}
onMounted(() => {
init()
})
onUnmounted(() => {
destory()
})
但是,理想很丰满,现实却很骨感。每一次滚动的距离不可能完美的找到一个子项的bottom与其完美匹配
所以,在查找的过程中,遇到左区间的右边界大于目标值时,除了缩小区间范围外,我们还需不断更新templateIndex
,最终要么返回一个恰好bottom等于滚动距离的,要么返回templateIndex
但我们似乎还漏掉了一些细节:滚动事件虽然有了,滚动过程中视图区域的数据也会跟着截断更新了,但是我们的样式没有对应的进行改变(这里只list容器跟着上下移动)
vue
<template>
<div class="fs-estimated-virtuallist-container">
<div class="fs-estimated-virtuallist-content" ref="contentRef">
<div
class="fs-estimated-virtuallist-list"
ref="listRef"
:style="scrollStyle"
>
<div
class="fs-estimated-virtuallist-list-item"
v-for="i in renderList"
:key="i.index"
:id="String(i.index)"
>
<slot name="item" :item="i"></slot>
</div>
</div>
<!-- <div class="loading-box" v-if="props.isLoading">
<van-loading />
</div> -->
</div>
</div>
</template>
const offsetDis = computed(() =>
state.startIndex > 0 ? positions.value[state.startIndex - 1].bottom : 0
)
const scrollStyle = computed(() => ({
height: `${state.listHeight - offsetDis.value}px`,
transform: `translate3d(0, ${offsetDis.value}px, 0)`,
}))
使用注意细节:
在实现的时候,我们存的positions
中需要index
字段,这是不可缺少的。而一般我们都是接口请求获取数据,可能并不会有这个字段,就需要我们自己处理一下
vue
<div
class="chat-virtual-container"
:style="{ height: innerHeight - 380 + 'px' }"
>
<virtualList
:data-source="historyMsg"
:estimated-height="100"
:is-loading="false"
>
<template #item="{ item }">
<!-- 具体的结构 -->
</template>
</virtualList>
</div>
const historyMsg = ref([])
const getList = async () => {
const { data } = axios.get('xxx')
const newData = data.map((chat, index) => {
return {
...chat,
index,
}
})
// 如果你有用到分页加载的话,这里还应该追加上先前的数据[...historyMsg.value, ...newData]
historyMsg.value = [...newData]
}
getList()
效果展示:
在聊天室中,文字消息有长有短,高度不一;文字消息和图片消息高度也不一致,于是使用不定高虚拟列表
此时已到底部
后记:
正如前面所讲,不定高虚拟列表是需要传入一个预设高度,然后将预设高度与真实高度来一个高度差的处理,而且滚动过程中不断的去进行源数据的截断更新视图区域的数据,必定引起重排重绘,影响性能。但实现虚拟列表的本质又是为了提升性能,所以总会感觉二者就是矛盾的...