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 来监听列表项内容区域的高度改变,从而实时获取每一列表项的高度。

参考

定高的虚拟列表会了,那不定高的… 哈,我也会!看我详解一波!