Back:高性能渲染十万条数据

前置:瀑布流普通虚拟列表

思路

基本同 普通虚拟列表,只是将 startIdxendIdx 变成 startScrollendScroll,记录容器可视区域,依据是否在可视区域内创建渲染列表

代码

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>

优化

设立缓冲区来缓解白屏问题

参考

瀑布流优化:我把小红书的瀑布流虚拟列表撕出来了!但女友不理我了