Back:高性能渲染十万条数据
思路
基本同 普通虚拟列表,只是将 startIdx
和 endIdx
变成 startScroll
和 endScroll
,记录容器可视区域,依据是否在可视区域内创建渲染列表
代码
VirtualWaterFall.vue
:
<template>
<div class="VirtualWaterFall-container" ref="containerRef" @scroll="onScroll">
<div class="VirtualWaterFall-list">
<div class="VirtualWaterFall-item" v-for="{ idx, item, x, y, w, h } in renderList" :key="item.id" :style="{
width: `${w}px`,
height: `${h}px`,
transform: `translate3d(${x}px, ${y}px, 0)`,
}">
<slot name="item" :item="item" :index="idx"></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts" generic="T extends ICardItem">
import { computed, onMounted, ref, watch } from 'vue'
// Props
export interface IVirtualWaterFallProps<T> {
dataSource: T[] // 数据源
gap: number // 卡片间隔
column: number // 瀑布流列数
loadMoreThreshold?: number // 加载更多阈值,单位 px,默认 20
}
export interface ICardItem {
id: string | number
width: number // 图片原始宽度
height: number // 图片原始高度
}
// 每一列的信息
interface IColumnItem {
list: IRenderItem[] // 每一列的卡片列表
height: number // 每一列的高度
}
// 卡片渲染视图项
interface IRenderItem {
idx: number // 数据源原始索引
item: T // 数据源
x: number // 卡片距离列表左侧的距离
y: number // 卡片距离列表顶部的距离
w: number // 卡片自身宽度
h: number // 卡片自身高度
}
const props = defineProps<IVirtualWaterFallProps<T>>()
const emit = defineEmits<{
loadMore: [] // 加载更多
}>()
defineSlots<{
item(props: { item: T; index: number }): any
}>()
const containerRef = ref<HTMLElement>() // 容器 DOM,需要容器宽高
// 每一列的信息
const columns = ref(new Array(props.column).fill(0).map(() => ({ list: [], height: 0 })) as IColumnItem[])
// 起始滚动位置:容器上边缘到列表顶部的距离
const startScroll = ref(0)
// 结束滚动位置:容器下边缘到列表顶部的距离
const endScroll = computed(() => startScroll.value + (containerRef.value?.clientHeight ?? 0))
// 卡片宽度
const cardWidth = computed(() => {
const containerWidth = containerRef.value?.clientWidth ?? 0
return (containerWidth - props.gap * (props.column - 1)) / props.column
})
// 最小列高度和索引,用于确定下一张卡片该插到哪一列
// 最大列高度,用于虚拟列表
const columnHeights = computed(() => {
let minIdx = -1
let minHeight = Infinity
let maxHeight = -Infinity
columns.value.forEach(({ height }, idx) => {
if (height < minHeight) {
minIdx = idx
minHeight = height
}
if (height > maxHeight) {
maxHeight = height
}
})
return { minIdx, minHeight, maxHeight }
})
// 渲染列表
const renderList = computed(() => {
// 二维 -> 一维
const cardList = columns.value.reduce<IRenderItem[]>((prev, { list }) => prev.concat(list), [])
// 在容器可视区域范围内的卡片
return cardList.filter(({ h, y }) => y + h > startScroll.value && y < endScroll.value)
})
const onScroll = () => {
const { scrollTop, clientHeight } = containerRef.value!
startScroll.value = scrollTop
const bottom = columnHeights.value.minHeight - clientHeight - scrollTop
if (bottom <= (props.loadMoreThreshold || 20)) {
emit('loadMore')
}
}
/**
* 计算 dataSource 的 columns 数组
* 这里认为 dataSource 会 push 一些数据
* @param len 新 dataSource 长度
* @param oldLen 旧 dataSource 长度
*/
const calcColumns = (len: number, oldLen: number) => {
const deltaLen = len - oldLen
if (deltaLen < 0) {
throw new Error('dataSource 只能 push 数据')
}
for (let i = 0; i < deltaLen; i++) {
const idx = oldLen + i
const item = props.dataSource[idx]
const { minIdx, minHeight } = columnHeights.value
const height = (cardWidth.value * item.height) / item.width
columns.value[minIdx].height += height + props.gap
columns.value[minIdx].list.push({
idx,
item,
x: minIdx * (cardWidth.value + props.gap),
y: minHeight,
w: cardWidth.value,
h: height,
})
}
}
onMounted(() => {
startScroll.value = containerRef.value!.scrollTop
})
watch(
() => props.dataSource.length,
(len, oldLen) => {
calcColumns(len, oldLen || 0)
},
{ immediate: true },
)
</script>
<style lang="scss">
.VirtualWaterFall {
&-container {
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
&-list {
width: 100%;
position: relative;
}
&-item {
position: absolute;
left: 0;
top: 0;
// 通过 translate 移动
}
}
</style>
App.vue
:
<template>
<div class="container">
<VirtualWaterFall :data-source="dataSource" :gap="10" :column="4" @load-more="loadMore">
<template #item="{ item, index }">
<div class="list-item" :style="{ backgroundColor: item.color }">{{ index + 1 }}</div>
</template>
</VirtualWaterFall>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import Mock from 'mockjs'
import VirtualWaterFall, { type ICardItem } from './components/VirtualWaterFall.vue'
interface DataSourceItem extends ICardItem {
color: string
}
const dataSource = ref<DataSourceItem[]>([])
let isLoading = false
const loadMore = () => {
if (isLoading) return
isLoading = true
setTimeout(() => {
const newData: DataSourceItem[] = Array.from({ length: 20 }, () => ({
id: Mock.Random.guid(),
height: Mock.Random.integer(100, 300),
width: Mock.Random.integer(100, 300),
color: Mock.Random.color(),
}))
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 {
width: 100%;
height: 100%;
box-sizing: border-box;
}
</style>
优化
设立缓冲区来缓解白屏问题