Skip to content
alex's blog
Go back

Vue3 虚拟列表实现原理与实践

编辑页面

在处理大量数据列表时,传统的渲染方式会导致严重的性能问题。虚拟列表(Virtual List)是一种高效的解决方案,它只渲染可见区域内的元素,从而大幅提升性能。本文将深入讲解虚拟列表的原理和在 Vue3 中的实现。

什么是虚拟列表?

虚拟列表是一种优化技术,它只渲染用户当前可见的列表项,而不是渲染整个列表。通过动态计算可见区域,只创建和更新需要显示的 DOM 元素,从而显著减少内存占用和渲染时间。

为什么需要虚拟列表?

假设你有一个包含 10,000 条数据的列表:

<!-- ❌ 传统方式:渲染所有元素 -->
<template>
  <div>
    <div v-for="item in items" :key="item.id">
      {{ item.name }}
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const items = ref(
  Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    name: `Item ${i}`
  }))
)
</script>

这种方式会导致:

使用虚拟列表后,即使有 10,000 条数据,也只会渲染可见的 10-20 个元素,性能提升显著。

虚拟列表的核心原理

虚拟列表的核心思想是:

  1. 计算可见区域:根据滚动位置计算当前可见的列表项范围
  2. 只渲染可见项:只创建和渲染可见范围内的 DOM 元素
  3. 使用占位符:用占位元素保持总高度,维持滚动条的正确性
  4. 动态更新:滚动时动态更新可见区域

关键参数

// 关键参数说明
const containerHeight = 400      // 容器高度
const itemHeight = 50             // 每个列表项的高度
const totalItems = 10000          // 总数据量
const visibleCount = Math.ceil(containerHeight / itemHeight) // 可见项数量

// 计算可见范围
const startIndex = Math.floor(scrollTop / itemHeight)
const endIndex = Math.min(startIndex + visibleCount + 1, totalItems)

基础实现

1. 固定高度的虚拟列表

首先实现一个固定高度的虚拟列表组件:

<template>
  <div
    ref="containerRef"
    class="virtual-list-container"
    @scroll="handleScroll"
  >
    <!-- 占位元素:保持总高度 -->
    <div :style="{ height: totalHeight + 'px' }">
      <!-- 可见区域 -->
      <div
        :style="{
          transform: `translateY(${offsetY}px)`
        }"
      >
        <div
          v-for="item in visibleItems"
          :key="item.id"
          class="virtual-list-item"
          :style="{ height: itemHeight + 'px' }"
        >
          <slot :item="item" :index="item.index" />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'

const props = defineProps({
  items: {
    type: Array,
    required: true
  },
  itemHeight: {
    type: Number,
    default: 50
  },
  containerHeight: {
    type: Number,
    default: 400
  }
})

const emit = defineEmits(['scroll'])

const containerRef = ref(null)
const scrollTop = ref(0)

// 计算可见范围
const visibleRange = computed(() => {
  const start = Math.floor(scrollTop.value / props.itemHeight)
  const visibleCount = Math.ceil(props.containerHeight / props.itemHeight)
  const end = Math.min(start + visibleCount + 1, props.items.length)
  
  return { start, end }
})

// 可见的列表项
const visibleItems = computed(() => {
  const { start, end } = visibleRange.value
  return props.items.slice(start, end).map((item, index) => ({
    ...item,
    index: start + index
  }))
})

// 总高度
const totalHeight = computed(() => {
  return props.items.length * props.itemHeight
})

// 偏移量
const offsetY = computed(() => {
  return visibleRange.value.start * props.itemHeight
})

// 处理滚动
const handleScroll = (e) => {
  scrollTop.value = e.target.scrollTop
  emit('scroll', {
    scrollTop: scrollTop.value,
    ...visibleRange.value
  })
}

// 滚动到指定位置
const scrollTo = (index) => {
  if (containerRef.value) {
    containerRef.value.scrollTop = index * props.itemHeight
  }
}

// 滚动到指定项
const scrollToItem = (item) => {
  const index = props.items.findIndex(i => i.id === item.id)
  if (index !== -1) {
    scrollTo(index)
  }
}

defineExpose({
  scrollTo,
  scrollToItem
})
</script>

<style scoped>
.virtual-list-container {
  overflow-y: auto;
  position: relative;
}

.virtual-list-item {
  display: flex;
  align-items: center;
  padding: 0 16px;
  border-bottom: 1px solid #eee;
}
</style>

使用示例

<template>
  <VirtualList
    :items="items"
    :item-height="50"
    :container-height="400"
    @scroll="handleScroll"
  >
    <template #default="{ item, index }">
      <div class="list-item">
        <span>{{ index + 1 }}. {{ item.name }}</span>
        <span>{{ item.description }}</span>
      </div>
    </template>
  </VirtualList>
</template>

<script setup>
import { ref } from 'vue'
import VirtualList from './components/VirtualList.vue'

const items = ref(
  Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    name: `Item ${i}`,
    description: `Description for item ${i}`
  }))
)

const handleScroll = (info) => {
  console.log('Scroll info:', info)
}
</script>

动态高度的虚拟列表

实际应用中,列表项的高度往往不固定。实现动态高度的虚拟列表更复杂:

<template>
  <div
    ref="containerRef"
    class="virtual-list-container"
    @scroll="handleScroll"
  >
    <div :style="{ height: totalHeight + 'px' }">
      <div :style="{ transform: `translateY(${offsetY}px)` }">
        <div
          v-for="item in visibleItems"
          :key="item.id"
          :ref="el => setItemRef(el, item.index)"
          class="virtual-list-item"
        >
          <slot :item="item" :index="item.index" />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'

const props = defineProps({
  items: {
    type: Array,
    required: true
  },
  estimatedItemHeight: {
    type: Number,
    default: 50
  },
  containerHeight: {
    type: Number,
    default: 400
  }
})

const containerRef = ref(null)
const scrollTop = ref(0)
const itemHeights = ref(new Map()) // 存储每个项的实际高度
const itemPositions = ref([]) // 存储每个项的累计位置

// 初始化位置数组
const initPositions = () => {
  itemPositions.value = props.items.map((_, index) => {
    if (index === 0) {
      return 0
    }
    const prevHeight = itemHeights.value.get(index - 1) || props.estimatedItemHeight
    return itemPositions.value[index - 1] + prevHeight
  })
}

// 更新项的位置
const updatePosition = (index, height) => {
  if (itemHeights.value.get(index) === height) {
    return // 高度未变化,无需更新
  }
  
  itemHeights.value.set(index, height)
  
  // 重新计算后续项的位置
  for (let i = index + 1; i < props.items.length; i++) {
    const prevHeight = itemHeights.value.get(i - 1) || props.estimatedItemHeight
    itemPositions.value[i] = itemPositions.value[i - 1] + prevHeight
  }
}

// 设置项引用
const itemRefs = new Map()
const setItemRef = (el, index) => {
  if (el) {
    itemRefs.set(index, el)
    nextTick(() => {
      if (el) {
        const height = el.offsetHeight
        updatePosition(index, height)
      }
    })
  }
}

// 查找可见范围
const findVisibleRange = () => {
  const start = findStartIndex(scrollTop.value)
  const end = findEndIndex(start)
  return { start, end }
}

// 二分查找起始索引
const findStartIndex = (scrollTop) => {
  let left = 0
  let right = props.items.length - 1
  let index = 0
  
  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    if (itemPositions.value[mid] <= scrollTop) {
      index = mid
      left = mid + 1
    } else {
      right = mid - 1
    }
  }
  
  return index
}

// 查找结束索引
const findEndIndex = (start) => {
  const containerBottom = scrollTop.value + props.containerHeight
  let end = start
  
  while (end < props.items.length && itemPositions.value[end] < containerBottom) {
    end++
  }
  
  return Math.min(end + 1, props.items.length)
}

const visibleRange = computed(() => {
  if (itemPositions.value.length === 0) {
    return { start: 0, end: Math.min(10, props.items.length) }
  }
  return findVisibleRange()
})

const visibleItems = computed(() => {
  const { start, end } = visibleRange.value
  return props.items.slice(start, end).map((item, index) => ({
    ...item,
    index: start + index
  }))
})

const totalHeight = computed(() => {
  if (itemPositions.value.length === 0) {
    return props.items.length * props.estimatedItemHeight
  }
  const lastIndex = props.items.length - 1
  const lastPosition = itemPositions.value[lastIndex]
  const lastHeight = itemHeights.value.get(lastIndex) || props.estimatedItemHeight
  return lastPosition + lastHeight
})

const offsetY = computed(() => {
  if (itemPositions.value.length === 0) {
    return 0
  }
  return itemPositions.value[visibleRange.value.start] || 0
})

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

onMounted(() => {
  initPositions()
})
</script>

使用第三方库

vue-virtual-scroller

npm install vue-virtual-scroller
<template>
  <RecycleScroller
    class="scroller"
    :items="items"
    :item-size="50"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="item">
      {{ item.name }}
    </div>
  </RecycleScroller>
</template>

<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

const items = ref([
  // 数据
])
</script>

vue-virtual-scroll-list

npm install vue-virtual-scroll-list
<template>
  <VirtualList
    :data-key="'id'"
    :data-sources="items"
    :data-component="itemComponent"
    :estimate-size="50"
  />
</template>

<script setup>
import VirtualList from 'vue-virtual-scroll-list'

const items = ref([...])
const itemComponent = {
  // 列表项组件
}
</script>

性能优化技巧

1. 使用 Object.freeze 冻结数据

const items = Object.freeze(
  Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    name: `Item ${i}`
  }))
)

2. 使用 v-memo 缓存渲染

<template>
  <div
    v-for="item in visibleItems"
    :key="item.id"
    v-memo="[item.id, item.name]"
  >
    {{ item.name }}
  </div>
</template>

3. 防抖滚动事件

import { debounce } from 'lodash-es'

const handleScroll = debounce((e) => {
  scrollTop.value = e.target.scrollTop
}, 16) // 约 60fps

4. 使用 requestAnimationFrame

let rafId = null

const handleScroll = (e) => {
  if (rafId) {
    cancelAnimationFrame(rafId)
  }
  
  rafId = requestAnimationFrame(() => {
    scrollTop.value = e.target.scrollTop
    rafId = null
  })
}

5. 预加载缓冲区

const visibleRange = computed(() => {
  const start = Math.floor(scrollTop.value / props.itemHeight)
  const visibleCount = Math.ceil(props.containerHeight / props.itemHeight)
  const buffer = 5 // 缓冲区大小
  
  const startIndex = Math.max(0, start - buffer)
  const endIndex = Math.min(
    start + visibleCount + buffer,
    props.items.length
  )
  
  return { start: startIndex, end: endIndex }
})

实际应用场景

1. 聊天消息列表

<template>
  <VirtualList
    :items="messages"
    :item-height="80"
    :container-height="600"
    @scroll="handleScroll"
  >
    <template #default="{ item }">
      <MessageItem :message="item" />
    </template>
  </VirtualList>
</template>

2. 表格数据展示

<template>
  <VirtualList
    :items="tableData"
    :item-height="40"
    :container-height="500"
  >
    <template #default="{ item }">
      <TableRow :data="item" />
    </template>
  </VirtualList>
</template>

3. 文件列表

<template>
  <VirtualList
    :items="files"
    :estimated-item-height="60"
    :container-height="400"
  >
    <template #default="{ item }">
      <FileItem :file="item" />
    </template>
  </VirtualList>
</template>

注意事项

  1. 固定高度 vs 动态高度:固定高度实现简单,性能更好;动态高度更灵活但实现复杂
  2. 滚动位置保持:在数据更新时,需要保持滚动位置
  3. 内存管理:及时清理不需要的 DOM 元素和事件监听
  4. 移动端适配:考虑移动端的触摸滚动和性能

总结

虚拟列表是处理大量数据列表的有效方案:

实现虚拟列表时需要注意:

对于大多数场景,建议使用成熟的第三方库,如 vue-virtual-scroller。如果对性能有极致要求,可以基于本文的实现进行定制优化。


虚拟列表是前端性能优化的重要技术,掌握它可以帮助你构建更高效的应用。希望这篇文章对你有帮助!


编辑页面
Share this post on:

Previous Post
Flutter 状态管理完整指南
Next Post
Vue3 性能优化技巧与实践