Back:高性能渲染十万条数据
前置:普通虚拟列表
不定高:item 高度不固定
预估高度要小于真实高度,否则可能出现白屏
代码
VirtualList.vue
:
<template>
<div class="VirtualList-container" ref="containerRef">
<div class="VirtualList-list" ref="listRef" :style="listStyle">
<div class="VirtualList-list-item" v-for="{ item, idx } in renderList" :data-idx="idx + ''">
<slot name="item" :item="item" :index="idx"></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts" generic="T">
import { CSSProperties, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
// Props
export interface IVirtualListProps<T> {
estimatedHeight: number // 预估高度
dataSource: T[] // 数据源
loadMoreThreshold?: number // 加载更多阈值,单位 px,默认 20
}
// item 的位置信息
export interface IPosInfo {
idx: number // item 索引
scrollBottom: number // 当前 item 底部到 list 顶部的距离
height: number // 当前 item 高度
}
const props = defineProps<IVirtualListProps<T>>()
const emit = defineEmits<{
loadMore: [] // 加载更多
}>()
defineSlots<{
item(props: { item: T; index: number }): any
}>()
const containerRef = ref<HTMLElement>() // 容器 DOM
const listRef = ref<HTMLElement>() // 列表 DOM
const containerHeight = ref<number>(0) // 容器高度
const maxCount = ref<number>(0) // 虚拟列表视图最大容纳量
const startIdx = ref<number>(0) // 起始索引
// 末尾索引
const endIdx = computed(() => Math.min(startIdx.value + maxCount.value, props.dataSource.length))
// 渲染列表
const renderList = computed(() => props.dataSource.slice(startIdx.value, endIdx.value).map((item, idx) => ({ item, idx: startIdx.value + idx })))
const positions = ref<IPosInfo[]>([]) // 所有 item 的位置信息
// 列表需要动态计算的样式
const listStyle = computed(() => {
const listHeight = positions.value[positions.value.length - 1]?.scrollBottom ?? 0
const translateY = positions.value[startIdx.value - 1]?.scrollBottom ?? 0
return {
height: `${listHeight - translateY}px`,
transform: `translateY(${translateY}px)`,
} as CSSProperties
})
onMounted(() => {
// 初始化
containerHeight.value = containerRef.value?.offsetHeight || 0
maxCount.value = Math.ceil(containerHeight.value / props.estimatedHeight) + 1
// addEventListener
containerRef.value && containerRef.value.addEventListener('scroll', onScroll)
})
onUnmounted(() => {
containerRef.value && containerRef.value.removeEventListener('scroll', onScroll)
})
const onScroll = () => {
const { scrollTop, clientHeight, scrollHeight } = containerRef.value!
// 更新起始索引
startIdx.value = binarySearch(positions.value, scrollTop)
// 加载更多
const bottom = scrollHeight - clientHeight - scrollTop
if (bottom <= (props.loadMoreThreshold || 20)) {
emit('loadMore')
}
}
/**
* 在 list 中二分查找,找到第一个大于等于 value 的位置
* 没有找到返回 -1
*/
const binarySearch = (list: IPosInfo[], value: number) => {
let left = 0
let right = list.length - 1
let templateIdx = -1
while (left < right) {
const mid = Math.floor((left + right) / 2)
const midValue = list[mid].scrollBottom
if (midValue === value) {
return mid + 1
}
if (midValue < value) {
left = mid + 1
} else {
if (templateIdx === -1 || templateIdx > mid) {
templateIdx = mid
}
right = mid
}
}
return templateIdx
}
/**
* 初始化 dataSource 的 positions 数组
* 这里认为 dataSource 会 push 一些数据
* @param len 新 dataSource 长度
* @param oldLen 旧 dataSource 长度
*/
const initPositions = (len: number, oldLen: number) => {
const deltaLen = len - oldLen
if (deltaLen < 0) {
throw new Error('dataSource 只能 push 数据')
}
let prevScrollBottom = positions.value[oldLen - 1]?.scrollBottom ?? 0
const newPos: IPosInfo[] = Array.from({ length: deltaLen }, (_, i) => {
prevScrollBottom += props.estimatedHeight
return {
idx: oldLen + i,
scrollBottom: prevScrollBottom,
height: props.estimatedHeight,
}
})
positions.value = [...positions.value, ...newPos]
}
/**
* 数据 item 渲染完成后,更新 positions 的真实高度
*/
const calcPositions = () => {
const nodes = listRef.value?.children
if (!nodes || !nodes.length) return
let changeIdx = -1
for (const node of nodes) {
const realHeight = node.getBoundingClientRect().height
const idx = +(node as HTMLElement).dataset.idx!
const pos = positions.value[idx]
if (pos.height !== realHeight) {
if (changeIdx === -1) {
changeIdx = idx
}
pos.height = realHeight
}
}
if (changeIdx === -1) return
let prevScrollBottom = positions.value[changeIdx - 1]?.scrollBottom ?? 0
for (let i = changeIdx; i < positions.value.length; i++) {
prevScrollBottom += positions.value[i].height
positions.value[i].scrollBottom = prevScrollBottom
}
}
// dataSource 更新时,重新计算位置
watch(
() => props.dataSource.length,
(len, oldLen) => {
initPositions(len, oldLen || 0)
nextTick(() => {
calcPositions()
})
},
{ immediate: true },
)
// 起始索引更新时,重新计算位置
watch(startIdx, () => {
calcPositions()
})
</script>
<style lang="scss">
.VirtualList {
&-container {
width: 100%;
height: 100%;
overflow-y: auto;
}
&-list {
width: 100%;
}
&-list-item {
width: 100%;
}
}
</style>
App.vue
:
<template>
<div class="container">
<VirtualList :data-source="dataSource" :estimated-height="100" @loadMore="loadMore">
<template #item="{ item, index }">
<div class="list-item">{{ index + 1 }} - {{ item }}</div>
</template>
</VirtualList>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import Mock from 'mockjs'
import VirtualList from './components/VirtualList.vue'
const dataSource = ref<string[]>([])
let isLoading = false
const loadMore = () => {
if (isLoading) return
isLoading = true
setTimeout(() => {
const newData: string[] = Array.from({ length: 20 }, () => Mock.mock('@csentence(40, 100)'))
dataSource.value = dataSource.value.concat(newData)
isLoading = false
}, 40)
}
onMounted(() => {
loadMore()
})
</script>
<style scoped lang="scss">
.container {
width: 600px;
height: 400px;
margin: 100px auto;
border: 1px solid red;
}
.list-item {
min-height: 100px;
box-sizing: border-box;
border: 1px solid #000;
padding: 10px;
letter-spacing: 0.1em;
font-size: 20px;
line-height: 1.7;
}
</style>
优化
设立缓冲区来缓解白屏问题
如果列表项中包含图片,并且列表高度由图片撑开,由于图片会发送网络请求,此时无法保证在获取列表项真实高度时图片是否已经加载完成,从而造成计算不准确的情况。
这种情况下,如果能监听列表项的大小变化就能获取其真正的高度了。可以使用 ResizeObserver 来监听列表项内容区域的高度改变,从而实时获取每一列表项的高度。