vue3实现高度不一致的虚拟滚动列表

我打开简书才发现我几年前也用react做过虚拟滚动的列表,但是之前是每项高度相等的,而且我看现实方式其实是比较繁杂的。

一、背景:

我的场景是一个AI助手的聊天窗口,消息数量无限制且消息内含虚拟表格等,上滑加载更多消息,也就是列表数量不固定,单条消息高度不固定。
其实虚拟滚动插件很多,但是我试了一下不是很符合我的场景,所以决定自己写一个,也是在deepSeek给的示例基础上优化的。

二、实现思路

1. 计算每个列表项的高度:

预加载新增的项,计算每个列表项的高度,并存储这些高度,再使用ResizeObserver监听元素高度变化,纠正为真实高度(大多数情况不会走到这一步)。

2. 计算可见区域:

根据滚动位置,计算出当前可见的列表项,这里做了一个小优化:第一项只要有一部分在可视区域内就展示,保证滚动的连续性。

3. 渲染可见项:

只渲染当前可见的列表项,而不是整个列表。

4. 缓存各项高度数据:

将数据缓存到本地,下次进来直接获取缓存,减少重复计算,提高性能。

5. 滚动到指定项:

上拉加载后让最后一条历史记录的位置不变,根据指定项的位置计算出scrollTop。

三、代码实现

index.vue

<template>
  <div
    ref="virtualScrollContainerRef"
    class="virtual-scroll-container"
    :style="{ height: viewportHeight > 0 ? viewportHeight + 'px' : '100%' }"
    @scroll="handleScroll"
  >
    <div class="virtual-scroll-content" :style="{ height: totalHeight + 'px' }">
      <div
        v-for="item in visibleItems"
        :key="item.messageId"
        class="virtual-scroll-item"
        :style="{ transform: `translateY(${item.offset}px)` }"
        :data-id="item.messageId"
      >
        <slot :msg="item" :index="item.index"></slot>
      </div>
    </div>
  </div>
  <SetScrollItemHight v-model:itemHeights="itemHeights" :items="items">
    <template #default="{ msg, index }">
      <slot :msg="msg" :index="index"></slot>
    </template>
  </SetScrollItemHight>
</template>

<script setup lang="ts">
import SetScrollItemHight from './setScrollItemHight.vue'

const props = defineProps({
  items: {
    // 消息列表
    type: Array<Message.ShowMessageType>,
    default: () => []
  },
  scrollToItem: {
    // 滚动到指定messageId的项
    type: String,
    default: ''
  }
})

const emits = defineEmits(['setVirtualScrollContainerRef'])

// 使用缓存的高度,避免重复计算
const virtualScrollItemHeights = JSON.parse(
  localStorage.getItem('virtualScrollItemHeights') || '{}'
)
const itemHeights = ref<any>(virtualScrollItemHeights) // 存储每个列表项的高度
const visibleItems = ref<any>([]) // 当前可见的列表项
const scrollTop = ref(0) // 当前滚动位置
const viewportHeight = ref(500) // 可视区域高度

const virtualScrollContainerRef = ref<any>(null)

// 各项高度转为数组格式方便计算
const itemHeightsArr = computed(() => {
  return props.items.map(({ messageId }) => itemHeights.value[messageId])
})

const totalHeight = computed(() => itemHeightsArr.value.reduce((sum, height) => sum + height, 0)) // 列表总高度

const updateVisibleItems = () => {
  let startIndex = 0
  let endIndex = 0
  let currentHeight = 0

  // 找到第一个可见的项,scrollTop.value - itemHeightsArr.value[startIndex]保证第一项只要有一部分还在可视区域就展示
  // (props.items.length - 1)保证至少有一个可见项
  while (
    startIndex < props.items.length - 1 &&
    currentHeight < scrollTop.value - itemHeightsArr.value[startIndex]
  ) {
    currentHeight += itemHeightsArr.value[startIndex]
    startIndex++
  }

  // 找到最后一个可见的项
  endIndex = startIndex
  while (endIndex < props.items.length && currentHeight < scrollTop.value + viewportHeight.value) {
    currentHeight += itemHeightsArr.value[endIndex]
    endIndex++
  }

  // 更新可见项
  visibleItems.value = props.items.slice(startIndex, endIndex).map((item: any, index) => {
    const offset = itemHeightsArr.value
      .slice(0, startIndex + index)
      .reduce((sum, height) => sum + height, 0)
    return {
      ...item,
      offset
    }
  })
  onItemObserver(visibleItems.value)
}

// 各项高度变化了更新显示项
watch(
  () => itemHeights.value,
  (val) => {
    updateVisibleItems()
    // 高度缓存起来
    localStorage.setItem('virtualScrollItemHeights', JSON.stringify(val))
  },
  {
    deep: true
  }
)

// 列表数据变化,更新可见项
watch(
  () => props.items,
  () => {
    nextTick(() => {
      updateVisibleItems()
    })
  },
  {
    deep: true,
    immediate: true
  }
)

const handleScroll = (event) => {
  scrollTop.value = event.target.scrollTop
  updateVisibleItems()
}

/**
 * 设置各项高度,只更新有变化的项
 */
const itemObserver = ref<any>(null)
let timer
// 监听元素高度变化,记录实际高度
itemObserver.value = new ResizeObserver((entries) => {
  entries.forEach((entry) => {
    const _height = entry.contentRect.height
    if (!_height) return
    const { id } = (entry.target as any)?.dataset || {}
    if (id && itemHeights.value[id] !== _height) {
      itemHeights.value[id] = _height
    }
  })
})
const onItemObserver = (list) => {
  if (timer) clearTimeout(timer)
  // 等待元素加载完成再执行监听
  timer = setTimeout(() => {
    list?.forEach((item) => {
      const element = document.querySelector(`[data-id="${item.messageId}"]`)
      if (element) {
        itemObserver.value.observe(element)
      }
    })
  }, 25)
}

// 设置viewportHeight
const parentObserver = ref<any>(null)
// 监听父级元素的高度变化并更新
parentObserver.value = new ResizeObserver((entries) => {
  for (let entry of entries) {
    const { height } = entry.contentRect
    viewportHeight.value = height
  }
})
const setviewportHeight = () => {
  const parentDom = document.getElementById('message-content-wrap')
  viewportHeight.value = parentDom?.clientHeight ?? 500
  if (parentDom) {
    parentObserver.value.observe(parentDom)
  }
}

onMounted(() => {
  setviewportHeight()
  emits('setVirtualScrollContainerRef', virtualScrollContainerRef.value)
})

onBeforeUnmount(() => {
  parentObserver.value?.disconnect?.()
  itemObserver.value?.disconnect?.()
  if (timer) clearTimeout(timer)
})

// 滚动到指定项
const onScrollToItem = (messageId) => {
  const cur: any = props.items?.find((item: any) => item.messageId === messageId)
  if (cur) {
    virtualScrollContainerRef.value.scrollTop = itemHeightsArr.value
      .slice(0, cur.index)
      .reduce((sum, height) => sum + height, 0)
  }
}
watch(() => props.scrollToItem, onScrollToItem)
</script>

<style lang="scss" scoped>
.virtual-scroll-container {
  height: 100%; /* 可视区域高度 */
  overflow-y: auto;
}
.virtual-scroll-content {
  position: relative;
  padding: 10px 10px 0;
}
.virtual-scroll-item {
  position: absolute;
  width: calc(100% - 20px);
}
</style>

预加载并初始化虚拟列表单项高度:setScrollItemHight.vue

<template>
  <teleport to="body">
    <div class="hiddenDom" :style="{ width: `${offsetWidth - 5}px` }">
      <div v-for="item in showItems" :key="item.messageId" ref="itemRefs" :data-id="item.messageId">
        <slot :msg="item" :index="item.index"></slot>
      </div>
    </div>
  </teleport>
</template>

<script setup lang="ts">
/**
 * 初始化虚拟列表单项高度
 */
const props = defineProps({
  items: {
    // 消息列表
    type: Array<Message.ShowMessageType>,
    default: () => []
  },
  itemHeights: {
    type: Object,
    default: () => ({})
  }
})

const emits = defineEmits(['update:itemHeights'])

const myItemHeights = computed({
  get() {
    return props.itemHeights
  },
  set(val) {
    emits('update:itemHeights', val)
  }
})
// 只初始化首次加载的项
const showItems = computed(() => {
  return props.items?.filter(({ messageId }) => !myItemHeights.value[messageId])
})

let timer: any
watch(
  () => showItems.value,
  (val) => {
    if (val?.length) {
      if (timer) clearTimeout(timer)
      timer = setTimeout(() => {
        measureHeight()
      }, 10)
    }
  },
  {
    deep: true,
    immediate: true
  }
)

const itemRefs = ref<any>([])

const measureHeight = () => {
  itemRefs.value?.forEach((el) => {
    myItemHeights.value[el.dataset.id] = el.offsetHeight
  })
}

const offsetWidth = ref<any>(500)
onMounted(() => {
  offsetWidth.value = document.getElementById('xiaolu-assistant')?.offsetWidth
})
onUnmounted(() => {
  if (timer) clearTimeout(timer)
})
</script>

<style lang="scss" scoped>
.hiddenDom {
  position: absolute;
  left: -9999px;
  padding: 0 10px;
  > div {
    overflow: hidden;
  }
}
</style>
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容