虚拟列表完全指南:从原理到实战,轻松渲染10万条数据

前言

想象一下这个场景:我们正在开发一个聊天应用,需要展示最近一年的聊天记录,总共有10万条消息。如果用传统方式渲染,页面会直接卡死,用户直接口吐芬芳了。

再想象另一个场景:我们在做一个数据后台,需要在表格中展示5万条日志。如果一次性渲染所有数据,内存占用轻松超过 500MB,用户的电脑风扇会疯狂嘶吼。

这就是虚拟列表要解决的问题:让海量数据的渲染变得像渲染几十条数据一样流畅。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,带领我们一步步掌握虚拟列表的核心技术。

为什么需要虚拟列表?

大量 DOM 元素导致的渲染性能问题

我们先来看一段最普通的代码:

html 复制代码
<template>
  <div class="list">
    <div v-for="item in 100000" :key="item" class="list-item">
      第 {{ item }} 条数据
    </div>
  </div>
</template>

猜猜看,这段代码会有什么后果? 实际测试结果

  • 渲染时间:Chrome 需要 3-5 秒才能完成渲染
  • 内存占用:100,000 个 DOM 节点占用约 300MB 内存
  • 滚动卡顿:每秒需要处理大量重绘和回流,像幻灯片一样卡
  • 交互延迟:点击、选中等操作有明显延迟

为什么会出现这种情况?让我们用一个生活中的小示例来解释。

用生活化的比喻理解问题

假如我们在一个巨大的图书馆工作,每天需要整理10万本书:

  • 传统渲染方式:把10万本书全部搬到桌子上,想用哪本拿哪本

    • 桌子被堆得满满的
    • 找一本书要翻半天
    • 挪动一下都费劲
  • 虚拟列表方式:只把当前需要用到的几本书放在桌上:

    • 桌子永远只有几本书
    • 想看其他书时,把新书拿上来,旧书放回去
    • 永远轻松自如

DOM元素为什么这么"重"?

每个DOM元素都不是简单的"标签",而是一个庞大的 JavaScript 对象:

json 复制代码
// 一个简单的 div 元素包含的属性(简化版)
{
  tagName: 'DIV',
  id: '',
  className: '',
  style: { ... },      // 几十个样式属性
  attributes: { ... },  // 属性集合
  children: [],         // 子节点
  parentNode: ...,      // 父节点引用
  offsetHeight: 0,      // 位置信息
  offsetWidth: 0,
  offsetTop: 0,
  offsetLeft: 0,
  // ... 还有几百个其他属性
}

内存占用计算

text 复制代码
一个div ≈ 4-8KB
10万个div ≈ 400-800MB
Vue组件实例 ≈ 每个额外占用2-3KB
总计 ≈ 700MB-1.1GB

这就是为什么传统渲染方式会卡死的根本原因。

虚拟列表的核心原理

核心思想:只渲染看得见的元素

虚拟列表的核心思想其实特别简单:用户能看到多少,就渲染多少

text 复制代码
可视区域高度: 400px
每个列表项高度: 50px
可视区域能容纳: 8个列表项

数据总量: 100,000条
实际渲染: 8条 + 少量缓冲 = 12条
节省了: 99.988%的DOM节点

图解虚拟列表原理

text 复制代码
┌─────────────────────────┐
│   滚动容器 (height:400px)│
│  ┌─────────────────────┐│
│  │   不可见区域(顶部)  ││ ← 用padding-top撑开
│  │   (1000px 空白)     ││
│  ├─────────────────────┤│
│  │   ┌─────────────┐  ││
│  │   │  可视区域    │  ││ ← 只渲染这8条
│  │   │  Item 100   │  ││
│  │   │  Item 101   │  ││
│  │   │  Item 102   │  ││
│  │   │  Item 103   │  ││
│  │   │  Item 104   │  ││
│  │   │  Item 105   │  ││
│  │   │  Item 106   │  ││
│  │   │  Item 107   │  ││
│  │   └─────────────┘  ││
│  ├─────────────────────┤│
│  │   不可见区域(底部) ││ ← 用padding-bottom撑开
│  │   (9000px 空白)     ││
│  └─────────────────────┘│
└─────────────────────────┘

三个关键技术点

1. 计算可视区域

typescript 复制代码
// 已知条件
容器高度 = 400px
列表项高度 = 50px

// 计算可视区域能显示多少个
可视数量 = 容器高度 / 列表项高度 = 8个

// 根据滚动位置计算应该显示哪些
开始索引 = 滚动高度 / 列表项高度
结束索引 = 开始索引 + 可视数量

2. 撑起滚动条

为了让滚动条显示正确的总高度,我们通常需要创建一个占位元素

html 复制代码
<div class="container">
  <!-- 占位元素:只有高度,没有内容,用于撑开滚动条 -->
  <div :style="{ height: totalHeight + 'px' }"></div>
  
  <!-- 实际内容:通过绝对定位或transform移动位置 -->
  <div :style="{ transform: `translateY(${offsetY}px)` }">
    <div v-for="item in visibleItems">...</div>
  </div>
</div>

3. 滚动时更新内容

typescript 复制代码
function onScroll(event) {
  // 获取滚动位置
  const scrollTop = event.target.scrollTop
  
  // 计算新的开始索引
  const startIndex = Math.floor(scrollTop / itemHeight)
  
  // 更新可视区域的数据
  visibleItems.value = data.slice(startIndex, startIndex + visibleCount)
  
  // 计算偏移量,让内容移动到正确位置
  offsetY.value = startIndex * itemHeight
}

从零实现固定高度虚拟列表

最简单的实现

让我们从一个最基础的版本开始,帮助我们理解虚拟列表的核心逻辑:

html 复制代码
<template>
  <!-- 滚动容器 -->
  <div 
    class="virtual-list" 
    @scroll="onScroll"
    :style="{ height: containerHeight + 'px' }"
    ref="containerRef"
  >
    <!-- 占位元素:撑开滚动条 -->
    <div 
      class="placeholder" 
      :style="{ height: totalHeight + 'px' }"
    ></div>
    
    <!-- 实际内容区域 -->
    <div 
      class="content" 
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div 
        v-for="item in visibleData" 
        :key="item.id"
        class="list-item"
      >
        {{ item.name }}
      </div>
    </div>
  </div>
</template>

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

// 接收父组件传过来的数据
const props = defineProps({
  data: {
    type: Array,
    required: true
  },
  itemHeight: {
    type: Number,
    default: 50
  },
  containerHeight: {
    type: Number,
    default: 400
  }
})

// 当前滚动位置
const scrollTop = ref(0)

// 计算可视区域能显示多少个
const visibleCount = computed(() => 
  Math.ceil(props.containerHeight / props.itemHeight)
)

// 计算开始索引
const startIndex = computed(() => 
  Math.floor(scrollTop.value / props.itemHeight)
)

// 计算结束索引
const endIndex = computed(() => 
  Math.min(startIndex.value + visibleCount.value, props.data.length)
)

// 可视区域的数据
const visibleData = computed(() => 
  props.data.slice(startIndex.value, endIndex.value)
)

// 内容总高度
const totalHeight = computed(() => 
  props.data.length * props.itemHeight
)

// 内容偏移量
const offsetY = computed(() => 
  startIndex.value * props.itemHeight
)

// 滚动处理函数
function onScroll(event) {
  scrollTop.value = event.target.scrollTop
}
</script>

<style scoped>
.virtual-list {
  position: relative;
  overflow-y: auto;
  border: 1px solid #e8e8e8;
}

.placeholder {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: -1;
}

.content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.list-item {
  height: v-bind(itemHeight + 'px');
  line-height: v-bind(itemHeight + 'px');
  padding: 0 16px;
  border-bottom: 1px solid #f0f0f0;
  box-sizing: border-box;
}
</style>

组件使用示例

html 复制代码
<template>
  <VirtualList 
    :data="largeData" 
    :item-height="50" 
    :container-height="400"
  />
</template>

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

// 生成10万条测试数据
const largeData = ref(
  Array.from({ length: 100000 }, (_, i) => ({
    id: i,
    name: `用户 ${i}`,
    email: `user${i}@example.com`
  }))
)
</script>

存在的问题和改进

上面的基础版本虽然能用,但有几个问题:

  • 快速滚动时会出现白屏
  • 没有缓冲区域,滚动体验不好
  • 性能还可以进一步优化

解决方案:添加缓冲区

typescript 复制代码
// 添加 overscan 参数,在可视区域上下额外渲染几个
const props = defineProps({
  // ... 其他参数
  overscan: {
    type: Number,
    default: 3 // 上下各多渲染3个
  }
})

const startIndex = computed(() => {
  let index = Math.floor(scrollTop.value / props.itemHeight)
  // 减去上缓冲
  index = Math.max(0, index - props.overscan)
  return index
})

const endIndex = computed(() => {
  let index = startIndex.value + visibleCount.value + props.overscan * 2
  index = Math.min(index, props.data.length)
  return index
})

封装成可复用的组合式函数

为了更好的复用性,我们可以把逻辑提取到组合式函数中:

typescript 复制代码
// composables/useVirtualList.js
import { ref, computed } from 'vue'

export function useVirtualList(data, options) {
  const {
    itemHeight,
    containerHeight,
    overscan = 3
  } = options

  const scrollTop = ref(0)
  
  // 可视区域能显示的最大项目数
  const visibleCount = computed(() => 
    Math.ceil(containerHeight / itemHeight)
  )
  
  // 起始索引
  const startIndex = computed(() => {
    let index = Math.floor(scrollTop.value / itemHeight)
    index = Math.max(0, index - overscan)
    return index
  })
  
  // 结束索引
  const endIndex = computed(() => {
    let index = startIndex.value + visibleCount.value + overscan * 2
    index = Math.min(index, data.length)
    return index
  })
  
  // 可视区域的数据
  const visibleData = computed(() => 
    data.slice(startIndex.value, endIndex.value)
  )
  
  // 内容总高度
  const totalHeight = computed(() => data.length * itemHeight)
  
  // 内容偏移量
  const offsetY = computed(() => startIndex.value * itemHeight)
  
  // 滚动处理函数
  const onScroll = (event) => {
    scrollTop.value = event.target.scrollTop
  }
  
  // 滚动到指定索引
  const scrollTo = (index) => {
    const targetScroll = index * itemHeight
    scrollTop.value = targetScroll
    return targetScroll
  }
  
  return {
    visibleData,
    totalHeight,
    offsetY,
    onScroll,
    scrollTo,
    startIndex,
    endIndex
  }
}

进阶:动态高度的虚拟列表

为什么要处理动态高度?

在实际应用中,列表项的高度往往是动态的,我们无法提前得知它到底会占用多少高度:

html 复制代码
<!-- 每条消息的高度都不一样 -->
<div class="message">
  <div class="header">张三 14:30</div>
  <div class="content">
    这是一条很短的消息
  </div>
</div>

<div class="message">
  <div class="header">李四 14:31</div>
  <div class="content">
    这是一条很长的消息,可能会换行,可能会换很多行,
    所以这个元素的高度会比上一条高很多...
  </div>
</div>

核心挑战

动态高度的主要挑战是:在渲染之前,我们不知道每个元素的具体高度,这就带来了两个问题:

  • 无法准确计算滚动条的总高度
  • 无法精确定位滚动到某个元素

解决方案:预估 + 测量 + 缓存

1. 预估一个默认高度

typescript 复制代码
// 先给每个元素一个预估高度
const itemSizes = ref(
  data.map(() => ({
    height: 40,        // 预估高度
    measured: false    // 是否已测量
  }))
)

2. 渲染后测量真实高度

typescript 复制代码
// 在组件渲染后测量实际高度
function measureItem(index, element) {
  if (element && !itemSizes.value[index].measured) {
    const height = element.offsetHeight
    itemSizes.value[index].height = height
    itemSizes.value[index].measured = true
  }
}

3. 缓存测量结果,并更新总高度

typescript 复制代码
// 计算累积高度(用于快速定位)
const cumulativeHeights = computed(() => {
  const heights = [0]
  let total = 0
  
  for (let i = 0; i < itemSizes.value.length; i++) {
    total += itemSizes.value[i].height
    heights.push(total)
  }
  
  return heights
})

// 总高度
const totalHeight = computed(() => 
  cumulativeHeights.value[data.length] || 0
)

完整实现

typescript 复制代码
<!-- DynamicVirtualList.vue -->
<template>
  <div 
    class="virtual-list"
    :style="{ height: containerHeight + 'px' }"
    @scroll="onScroll"
    ref="containerRef"
  >
    <!-- 占位元素:撑起滚动条 -->
    <div 
      class="phantom"
      :style="{ height: totalHeight + 'px' }"
    ></div>
    
    <!-- 可见内容 -->
    <div 
      class="content"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div
        v-for="(item, idx) in visibleData"
        :key="item.id"
        :data-index="startIndex + idx"
        ref="itemRefs"
        class="list-item"
      >
        <slot 
          name="item" 
          :item="item" 
          :index="startIndex + idx"
        >
          <div class="default-item">
            {{ item.name || item }}
          </div>
        </slot>
      </div>
    </div>
  </div>
</template>

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

const props = defineProps({
  data: {
    type: Array,
    required: true
  },
  estimatedItemHeight: {
    type: Number,
    default: 40
  },
  containerHeight: {
    type: Number,
    required: true
  },
  overscan: {
    type: Number,
    default: 3
  }
})

// 存储每个项的高度
const itemSizes = ref(
  props.data.map(() => ({
    height: props.estimatedItemHeight,
    measured: false
  }))
)

// 当前滚动位置
const scrollTop = ref(0)

// 容器引用
const containerRef = ref()
const itemRefs = ref([])

// 计算累积高度(用于快速定位)
const cumulativeHeights = computed(() => {
  const heights = [0]
  let total = 0
  
  for (let i = 0; i < itemSizes.value.length; i++) {
    total += itemSizes.value[i].height
    heights.push(total)
  }
  
  return heights
})

// 总高度
const totalHeight = computed(() => 
  cumulativeHeights.value[props.data.length] || 0
)

// 二分查找:根据滚动位置找起始索引
function findStartIndex(scrollTop) {
  const heights = cumulativeHeights.value
  let left = 0
  let right = heights.length - 1
  
  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    const midValue = heights[mid]
    
    if (midValue === scrollTop) {
      return mid
    } else if (midValue < scrollTop) {
      left = mid + 1
    } else {
      right = mid - 1
    }
  }
  
  return Math.max(0, right)
}

// 计算可见区域的起止索引
const startIndex = computed(() => {
  return Math.max(0, findStartIndex(scrollTop.value) - props.overscan)
})

const endIndex = computed(() => {
  let end = startIndex.value
  let currentHeight = cumulativeHeights.value[startIndex.value]
  const targetHeight = scrollTop.value + props.containerHeight
  
  while (
    end < props.data.length && 
    currentHeight < targetHeight + props.estimatedItemHeight * props.overscan
  ) {
    end++
    currentHeight = cumulativeHeights.value[end]
  }
  
  return Math.min(end + props.overscan, props.data.length)
})

// 可见区域的数据
const visibleData = computed(() => 
  props.data.slice(startIndex.value, endIndex.value)
)

// 内容偏移量
const offsetY = computed(() => 
  cumulativeHeights.value[startIndex.value] || 0
)

// 测量元素高度
function measureItems() {
  nextTick(() => {
    itemRefs.value.forEach((el, idx) => {
      if (!el) return
      
      const globalIndex = startIndex.value + idx
      const height = el.offsetHeight
      
      // 如果高度变化了,更新缓存
      if (height > 0 && itemSizes.value[globalIndex].height !== height) {
        itemSizes.value[globalIndex].height = height
        itemSizes.value[globalIndex].measured = true
      }
    })
  })
}

// 滚动处理
function onScroll(event) {
  scrollTop.value = event.target.scrollTop
}

// 当可见数据变化时,重新测量
watch(visibleData, measureItems, { immediate: true })

// 滚动到指定项
function scrollTo(index) {
  if (index < 0 || index >= props.data.length) return
  
  const targetScroll = cumulativeHeights.value[index]
  if (containerRef.value) {
    containerRef.value.scrollTop = targetScroll
    scrollTop.value = targetScroll
  }
}

defineExpose({
  scrollTo
})
</script>

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

.phantom {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: -1;
}

.content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.list-item {
  box-sizing: border-box;
}
</style>

组件使用示例

html 复制代码
<template>
  <div class="demo">
    <h3>动态高度虚拟列表</h3>
    <p>可见区域: {{ startIndex }} - {{ endIndex }}</p>
    
    <DynamicVirtualList
      :data="messages"
      :container-height="500"
      :estimated-item-height="60"
      ref="listRef"
    >
      <template #item="{ item, index }">
        <div class="message" :class="{ mine: item.isMine }">
          <div class="header">
            <span class="name">{{ item.name }}</span>
            <span class="time">{{ item.time }}</span>
          </div>
          <div class="content">{{ item.content }}</div>
          <div v-if="item.image" class="image">
            <img :src="item.image" @load="listRef?.measureItems()" />
          </div>
        </div>
      </template>
    </DynamicVirtualList>
    
    <button @click="scrollTo(500)">滚动到第500条</button>
  </div>
</template>

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

// 生成模拟聊天数据
const messages = ref(
  Array.from({ length: 5000 }, (_, i) => {
    const hasImage = i % 10 === 0
    const isLong = i % 5 === 0
    
    return {
      id: i,
      name: i % 2 === 0 ? '张三' : '李四',
      time: new Date(Date.now() - i * 60000).toLocaleTimeString(),
      content: isLong 
        ? '这是一条很长的消息,用来测试动态高度效果。'.repeat(5 + Math.floor(Math.random() * 10))
        : '这是一条普通消息',
      isMine: i % 3 === 0,
      image: hasImage ? `https://picsum.photos/200/150?random=${i}` : null
    }
  })
)

const listRef = ref()
const startIndex = ref(0)
const endIndex = ref(0)

function scrollTo(index) {
  listRef.value?.scrollTo(index)
}
</script>

<style>
.message {
  padding: 12px 16px;
  border-bottom: 1px solid #f0f0f0;
}

.message.mine {
  background-color: #e6f7ff;
}

.header {
  margin-bottom: 8px;
}

.name {
  font-weight: 600;
  margin-right: 12px;
}

.time {
  color: #999;
  font-size: 12px;
}

.content {
  line-height: 1.6;
  color: #333;
}

.image {
  margin-top: 8px;
}

.image img {
  max-width: 200px;
  border-radius: 4px;
}
</style>

性能优化技巧

使用 requestAnimationFrame 优化滚动

滚动事件会触发频繁计算更新,可以使用 requestAnimationFrame 节流:

typescript 复制代码
let ticking = false

function onScroll(event) {
  if (!ticking) {
    requestAnimationFrame(() => {
      scrollTop.value = event.target.scrollTop
      ticking = false
    })
    ticking = true
  }
}

使用 v-memo 缓存列表项

对于高度复杂的列表项,可以使用 v-memo 缓存渲染结果,避免不必要的更新:

html 复制代码
<template>
  <div
    v-for="item in visibleData"
    :key="item.id"
    v-memo="[item.id, item.version, item.likes]"
    class="list-item"
  >
    <ComplexItem :data="item" />
  </div>
</template>

<!-- v-memo 的作用:只有当依赖的值变化时才重新渲染 -->
<!-- 避免因为父组件更新导致的无关渲染 -->

使用 shallowRef 处理大型数据

对于大型数据,如果直接使用 ref 定义,每个属性都变成响应式,开销大。这时我们可以使用 shallowRef 避免深层响应式:

typescript 复制代码
import { shallowRef } from 'vue'

// shallowRef:只有数组引用变化时才会触发更新
const data = shallowRef(generateLargeArray())

// 更新时替换整个数组
function updateData(newArray: any[]) {
  data.value = newArray
}

// 修改单个项不会触发响应式
function updateItem(index: number, newValue: any) {
  // 更新时,需要创建新数组
  const newData = [...data.value]
  newData[index] = newValue
  data.value = newData
}

使用 Intersection Observer 优化图片加载

typescript 复制代码
// 使用 Intersection Observer 实现图片懒加载
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target
        const src = img.dataset.src
        if (src) {
          img.src = src
          img.removeAttribute('data-src')
          observer.unobserve(img)
        }
      }
    })
  },
  {
    rootMargin: '100px' // 提前100px加载
  }
)

// 在列表项渲染后观察图片
watch(visibleData, () => {
  nextTick(() => {
    document.querySelectorAll('img[data-src]').forEach(img => {
      observer.observe(img)
    })
  })
})

性能优化清单

优化点 方法 效果
滚动事件 requestAnimationFrame 减少计算次数
列表项更新 v-memo 避免无关渲染
大型数据 shallowRef 减少响应式开销
图片加载 Intersection Observer 按需加载
高度测量 ResizeObserver 监听高度变化
缓存策略 LRU缓存 限制缓存大小

第三方库推荐

vue-virtual-scroller

安装

bash 复制代码
npm install vue-virtual-scroller@next
# 或者:
yarn install vue-virtual-scroller@next

使用

html 复制代码
<template>
  <RecycleScroller
    class="scroller"
    :items="list"
    :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 list = ref(Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `Item ${i}`
})))
</script>

优点

  • 功能完善,支持动态高度
  • 性能优秀,经过大量项目验证
  • 提供网格布局支持
  • 有活跃的社区维护

缺点

  • 需要额外引入CSS
  • 包体积较大(约20KB)
  • 定制复杂样式可能受限

与手写对比

场景 推荐方案 原因
学习目的 手写 深入理解原理
简单固定高度 手写 实现简单,无依赖
生产环境复杂需求 第三方库 稳定可靠,功能完善
特殊定制需求 手写 完全可控
团队协作项目 第三方库 减少维护成本

常见问题与解决方案

问题1:快速滚动出现白屏

当页面滚动太快时,新的内容来不及渲染,出现白屏。

解决方案:缓冲区 + 骨架屏占位

html 复制代码
<script setup>
// 增加缓冲
const props = defineProps({
  overscan: {
    type: Number,
    default: 5 // 增加到5个
  }
})
</script>

<!-- 显示骨架屏占位 -->
<template>
  <div v-if="loading" class="skeleton">
    <div v-for="n in 5" class="skeleton-item"></div>
  </div>
</template>

问题2:高度测量不准确

由于不同规格的图片加载、字体渲染等原因,导致高度发生变化,高度测量不准。

解决方案:使用 ResizeObserver 监听高度变化

typescript 复制代码
import { useResizeObserver } from '@vueuse/core'

useResizeObserver(itemRefs, (entries) => {
  entries.forEach(entry => {
    const index = entry.target.dataset.index
    if (index) {
      measureItem(Number(index), entry.contentRect.height)
    }
  })
})

问题3:滚动位置跳动

当上方元素的高度发生变化时,滚动位置会跳动。

解决方案:使用 scroll-save 保持滚动位置

typescript 复制代码
// 保存当前视口顶部的元素
function saveScrollPosition() {
  const container = containerRef.value
  if (!container) return
  
  const firstVisibleIndex = findStartIndex(container.scrollTop)
  const firstVisibleElement = document.querySelector(`[data-index="${firstVisibleIndex}"]`)
  
  if (firstVisibleElement) {
    const offset = firstVisibleElement.getBoundingClientRect().top
    savedPosition.value = { index: firstVisibleIndex, offset }
  }
}

问题4:内存泄漏

当组件销毁时没有及时清理观察者和定时器,导致内存泄漏。

解决方案:及时清理

typescript 复制代码
import { onUnmounted } from 'vue'

// 保存所有需要清理的资源
const observers = []
const timers = []

// 组件销毁时清理
onUnmounted(() => {
  observers.forEach(observer => observer.disconnect())
  timers.forEach(timer => clearTimeout(timer))
})

虚拟列表的适用场景

何时应该使用虚拟列表?

场景 数据量 是否使用 原因
聊天记录 1000+ 无限滚动,DOM 爆炸
商品列表 1000+ 首屏加载慢
后台表格 10000+ 性能卡顿
下拉菜单 <100 简单列表,没必要
评论列表 <500 ⚠️ 酌情使用,看复杂度
卡片列表 <200 正常渲染即可

性能对比

方案 DOM 节点数 内存占用 滚动帧率 实现复杂度
传统渲染 100,000 500-800MB 5-10fps
固定高度虚拟列表 20-30 5-10MB 60fps
动态高度虚拟列表 20-30 5-10MB 55-60fps
第三方库 20-30 5-10MB 60fps

最佳实践清单

  • 预估高度:动态高度列表需要合理的预估高度
  • 缓冲区域:上下各保留 2-5 个缓冲项
  • 测量机制:动态高度需要精确测量
  • 滚动优化 :使用 ref 节流
  • 键值管理:使用稳定的唯一键
  • 内存释放:及时清理观察者和定时器

性能优化清单

  • 使用 requestAnimationFrame 优化滚动事件
  • 添加 overscan 缓冲区域
  • 使用 v-memo 缓存复杂列表项
  • 大型数据用 shallowRef 存储
  • 图片使用懒加载
  • 监听高度变化并及时更新
  • 组件销毁时清理资源

用户体验清单

  • 快速滚动时显示骨架屏
  • 滚动到底部自动加载更多
  • 有新消息,自动滚动到底部
  • 支持点击滚动到指定项
  • 支持滚动位置(返回时恢复)

最终的代码模板

typescript 复制代码
// 一个完整的虚拟列表组合式函数模板
export function useVirtualList<T>(
  data: Ref<T[]>,
  options: {
    itemHeight: number
    containerHeight: number
    dynamicHeight?: boolean
    overscan?: number
  }
) {
  // 状态管理
  const scrollTop = ref(0)
  const startIndex = ref(0)
  
  // 计算可见数据
  const visibleData = computed(() => {
    // 计算逻辑
  })
  
  // 滚动处理(节流)
  const onScroll = useThrottle((e: Event) => {
    // 更新 scrollTop
  }, 16)
  
  // 动态高度测量
  const measureItem = (index: number, height: number) => {
    // 更新缓存
  }
  
  // 滚动到指定项
  const scrollTo = (index: number) => {
    // 计算目标位置并滚动
  }
  
  return {
    visibleData,
    totalHeight: computed(() => data.value.length * options.itemHeight),
    offsetY: computed(() => startIndex.value * options.itemHeight),
    onScroll,
    measureItem,
    scrollTo
  }
}

结语

虚拟列表的核心思想很简单:用计算换渲染,用内存换时间。通过只渲染可见区域,我们可以在处理海量数据时保持流畅的体验。无论是固定高度还是动态高度,掌握其原理后,我们就能根据实际需求选择最合适的方案。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
兆子龙2 小时前
React Hooks 避坑指南:那些让你 debug 到凌晨的陷阱
前端·javascript
兆子龙2 小时前
你不会使用 CSS 函数 clamp()?那你太 low 了😀
前端·javascript
兆子龙2 小时前
前端性能优化终极清单:从 3 秒到 0.5 秒的实战经验
前端·javascript
兆子龙2 小时前
babel-loader:让你的 JS 代码兼容所有浏览器
前端
百万蹄蹄向前冲3 小时前
支付宝 VS 微信 小程序差异
前端·后端·微信小程序
兆子龙3 小时前
JavaScript 的 Symbol.iterator:手写一个可迭代对象
前端
NGC_66113 小时前
ArrayList扩容机制
java·前端·算法
独泪了无痕8 小时前
使用Fetch API 探索前后端数据交互
前端·http·交互设计