• 作者:老汪软件技巧
  • 发表时间:2024-10-03 21:01
  • 浏览量:

前言

实现方案参考至:,本文相当于是一份个人总结,加深印象和实现思路

实现背景: Vue3 + Vant4(H5端)

前置准备

两个概念:源数据(可能有成百上千条)、视图区域数据(可能就显示20、30条)回顾前端虚拟列表——uniapp小程序实战实现(手搓版) - 掘金 (),我们需要所有数据的数量,以此来算出整个占位盒子的高度;然后在此后的滚动中,利用子绝父相,不断地去更新top值,从而实现视图区域试图的更新。但是在不定高中,每一项的高度都是不固定的,所以我们无法通过 itemHeight * length 来算出总高度,那么怎么做呢?同时,虚拟列表想要实现视图区域数据的切换,我们需要不断的通过滚动位置更新数组截断的起止点,在定高的实现方案里面,我们可以直接通过 scrollTop / itemHeight 来进行判断,在不定高中,同样受限于每一项的高度,我们又如何去更新数组截断的起点?这里给出的解决方案是:预设高度。通过给数据项一个预知高度,首次便可以算出总高度;然后在借由高度差,更新视图区域的每一项的真实高度

因为你会发现,总高度就是整个虚拟列表实现的基础。当然了,预设的高度肯定是无法和每一个子项的真实高度一模一样的,这里也就避免不了重排重绘的问题

我们需要什么?我们需要positions数组记录每一个子项的相关信息,包括当前子项在整个数据源中的索引(后续解决由于预设高度和真实高度存在偏差问题时需要使用)、高度(先是预设高度,后是真实高度)、子项顶部距离容器顶部的距离、子项底部距离容器顶部的距离、高度差(预设高度和真实高度的插值)

同时发现,整个容器的高度,其实就等于最后一项item的bottom值


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
}

但前面也说了,预设高度和真实高度是存在偏差的,所以我们需要更新这个偏差。更新完这个偏差之后,我们就能够顺利的拿到所有数据形成的高度

// 数据 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也要不断执行

watch(
  () => props.dataSource.length,
  () => {
    initPosition()
    nextTick(() => {
      setPosition()
    })
  }
)
watch(
  () => state.startIndex,
  () => {
    setPosition()
  }
)

滚动过程:

前面讲到,在定高的实现方案中,通过 scrollTop / itemHeight 就能更新数组截断的起点,从而更新视图区域的数据。但现在,itemHeight不是固定的,所以自然无法通过这个方法实现。这里采用的是二分法

这里个人感觉是最妙的,因为个人很少有算法和实际应用场景相结合的场景,还是太菜了...

还记不记得前面我们使用了一个positions,存的是源数据的每一项的信息,在滚动事件中,通过二分查找,找到一项,该项的bottom值等于滚动的距离,那么,他就是我们视图区域数据的新起点

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容器跟着上下移动)


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字段,这是不可缺少的。而一般我们都是接口请求获取数据,可能并不会有这个字段,就需要我们自己处理一下

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()

效果展示:

在聊天室中,文字消息有长有短,高度不一;文字消息和图片消息高度也不一致,于是使用不定高虚拟列表

此时已到底部

后记:

正如前面所讲,不定高虚拟列表是需要传入一个预设高度,然后将预设高度与真实高度来一个高度差的处理,而且滚动过程中不断的去进行源数据的截断更新视图区域的数据,必定引起重排重绘,影响性能。但实现虚拟列表的本质又是为了提升性能,所以总会感觉二者就是矛盾的...