拿下不定高虚拟列表——这玩意是有点磨人

前言

实现方案参考至:定高的虚拟列表会了,那不定高的...... 哈,我也会!看我详解一波!🤪🤪🤪之前用原生 JS 实现了一个定高的虚拟 - 掘金 (juejin.cn),本文相当于是一份个人总结,加深印象和实现思路

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

前置准备

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

这里给出的解决方案是:预设高度。通过给数据项一个预知高度,首次便可以算出总高度;然后在借由高度差,更新视图区域的每一项的真实高度 因为你会发现,总高度就是整个虚拟列表实现的基础。 当然了,预设的高度肯定是无法和每一个子项的真实高度一模一样的,这里也就避免不了重排重绘的问题

我们需要什么?

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

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

vue 复制代码
<template>
  <div class="fs-estimated-virtuallist-container">
    <div class="fs-estimated-virtuallist-content" ref="contentRef">
      <div
        class="fs-estimated-virtuallist-list"
        ref="listRef"
        :style="scrollStyle"
      >
        <div
          class="fs-estimated-virtuallist-list-item"
          v-for="i in renderList"
          :key="i.index"
          :id="String(i.index)"
        >
          <slot name="item" :item="i"></slot>
        </div>
      </div>
      <!-- <div class="loading-box" v-if="props.isLoading">
        <van-loading />
      </div> -->
    </div>
  </div>
</template>

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
}
  1. 但前面也说了,预设高度和真实高度是存在偏差的,所以我们需要更新这个偏差。更新完这个偏差之后,我们就能够顺利的拿到所有数据形成的高度
vue 复制代码
// 数据 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用于更新每一项的位置信息(主要是拿着预设高度和真实高度的偏差进行逻辑处理)。那么,当数据源发生变化时,就应该执行initPositionsetPosition;当用户滚动过程中,由于视图区域展示的内容不断在变化,setPosition也要不断执行

vue 复制代码
watch(
  () => props.dataSource.length,
  () => {
    initPosition()
    nextTick(() => {
      setPosition()
    })
  }
)

watch(
  () => state.startIndex,
  () => {
    setPosition()
  }
)

滚动过程:

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

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

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

vue 复制代码
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容器跟着上下移动)

vue 复制代码
<template>
  <div class="fs-estimated-virtuallist-container">
    <div class="fs-estimated-virtuallist-content" ref="contentRef">
      <div
        class="fs-estimated-virtuallist-list"
        ref="listRef"
        :style="scrollStyle"
      >
        <div
          class="fs-estimated-virtuallist-list-item"
          v-for="i in renderList"
          :key="i.index"
          :id="String(i.index)"
        >
          <slot name="item" :item="i"></slot>
        </div>
      </div>
      <!-- <div class="loading-box" v-if="props.isLoading">
        <van-loading />
      </div> -->
    </div>
  </div>
</template>

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

vue 复制代码
<div
  class="chat-virtual-container"
  :style="{ height: innerHeight - 380 + 'px' }"
>
  <virtualList
    :data-source="historyMsg"
    :estimated-height="100"
    :is-loading="false"
  >
    <template #item="{ item }">
        <!-- 具体的结构 -->
    </template>
  </virtualList>
</div>

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

效果展示:

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

此时已到底部

后记:

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

相关推荐
徐_三岁27 分钟前
Vue3实现mqtt的订阅与发布
前端
paopaokaka_luck40 分钟前
基于Spring Boot+Vue的精品项目分享
java·vue.js·spring boot·后端·elementui·毕业设计·mybatis
小雨cc5566ru1 小时前
Thinkphp/Laravel基于vue的实验室上机管理系统
android·vue.js·laravel
qq_544329171 小时前
从0学习React(5)---通过例子体会setState
前端·学习·react.js
xcLeigh2 小时前
HTML5实现好看的唐朝服饰网站模板源码2
前端·html·html5
安冬的码畜日常2 小时前
【玩转 JS 函数式编程_004】1.4 如何应对 JavaScript 的不同版本
开发语言·前端·javascript·ecmascript·函数式编程·fp·functional
iQM752 小时前
TinyVue:一款轻量级且功能强大的Vue UI组件库
前端·javascript·vue.js·ui·jenkins·excel
howard20052 小时前
初试React前端框架
前端·react.js·前端框架
蜗牛快跑2133 小时前
DOM元素导出图片与PDF:多种方案对比与实现
前端·javascript·pdf
小彭努力中3 小时前
50. GLTF格式简介 (Web3D领域JPG)
前端·3d·webgl