前言
想象一下这个场景:我们正在开发一个聊天应用,需要展示最近一年的聊天记录,总共有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
}
}
结语
虚拟列表的核心思想很简单:用计算换渲染,用内存换时间。通过只渲染可见区域,我们可以在处理海量数据时保持流畅的体验。无论是固定高度还是动态高度,掌握其原理后,我们就能根据实际需求选择最合适的方案。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!