Vue 3 虚拟滚动列表实现方案
一、什么是虚拟滚动?
当列表数据量很大时(如 10000 条),直接渲染所有 DOM 节点会导致页面卡顿。虚拟滚动的核心思想是:只渲染可视区域内的元素 ,通过控制 startIndex 和 endIndex 来决定渲染范围。
核心原理
┌─────────────────────────────┐
│ phantom (撑开总高度) │ ← 总高度 = itemCount × itemHeight
│ ┌───────────────────────┐ │
│ │ 上方不可见区域 │ │
│ ├───────────────────────┤ │
│ │ 可见区域 │ │ ← 只渲染 startIndex ~ endIndex
│ │ (startIndex ~ endIndex)│ │
│ ├───────────────────────┤ │
│ │ 下方不可见区域 │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
关键计算
startIndex = Math.floor(scrollTop / itemHeight) - bufferSizeendIndex = startIndex + visibleCount + bufferSizeoffset = startIndex * itemHeight(用translateY把可见项推到正确位置)
二、方案一:固定高度 + 原生滚动
最基础的虚拟滚动实现,所有 item 高度固定。
vue
<template>
<div class="virtual-scroll-demo">
<h1>虚拟滚动列表示例</h1>
<p>总数据量: {{ items.length }} 条</p>
<div class="virtual-list-container" ref="containerRef" @scroll="handleScroll">
<div class="virtual-list-phantom" :style="{ height: `${totalHeight}px` }">
<div class="virtual-list-content" :style="{ transform: `translateY(${offset}px)` }">
<div v-for="item in visibleItems" :key="item.index" class="virtual-list-item">
Item #{{ item.index + 1 }} - {{ item.data }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const ITEM_HEIGHT = 40
const CONTAINER_HEIGHT = 400
const BUFFER_SIZE = 3
const items = Array.from({ length: 10000 }, (_, i) => ({
index: i,
data: `这是第 ${i + 1} 条数据`
}))
const totalHeight = computed(() => items.length * ITEM_HEIGHT)
const containerRef = ref(null)
const scrollTop = ref(0)
const startIndex = computed(() => {
const start = Math.floor(scrollTop.value / ITEM_HEIGHT)
return Math.max(0, start - BUFFER_SIZE)
})
const endIndex = computed(() => {
const visibleCount = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT)
const start = Math.floor(scrollTop.value / ITEM_HEIGHT)
const end = start + visibleCount + BUFFER_SIZE
return Math.min(items.length - 1, end)
})
const offset = computed(() => {
return startIndex.value * ITEM_HEIGHT
})
const visibleItems = computed(() => {
return items.slice(startIndex.value, endIndex.value + 1)
})
const handleScroll = () => {
if (containerRef.value) {
scrollTop.value = containerRef.value.scrollTop
}
}
</script>
<style scoped>
.virtual-scroll-demo {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.virtual-list-container {
height: 400px;
overflow-y: auto;
border: 2px solid #ccc;
border-radius: 4px;
position: relative;
}
.virtual-list-phantom {
position: relative;
width: 100%;
}
.virtual-list-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.virtual-list-item {
height: 40px;
line-height: 40px;
padding: 0 16px;
border-bottom: 1px solid #eee;
background: white;
}
.virtual-list-item:hover {
background: #f5f5f5;
}
</style>
三、方案二:固定高度 + Better-Scroll
用 Better-Scroll 替换原生滚动,核心虚拟滚动逻辑不变。
与原生滚动的区别
| 原生滚动 | Better-Scroll | |
|---|---|---|
| 滚动容器 | overflow: auto |
BScroll 实例 |
| 监听滚动 | @scroll + scrollTop |
scroll 事件 + pos.y |
| 撑开高度 | phantom div | 同样用 phantom div |
关键点
probeType: 3:必须在动画帧期间也触发 scroll 事件(probeType: 2在惯性滚动时不触发)pos.y是负值,需要取绝对值
vue
<template>
<div class="virtual-scroll-demo">
<h1>虚拟滚动列表示例 (Better-Scroll)</h1>
<p>总数据量: {{ items.length }} 条</p>
<div class="virtual-list-container" ref="containerRef">
<div class="virtual-list-phantom" :style="{ height: `${totalHeight}px` }">
<div class="virtual-list-content" :style="{ transform: `translateY(${offset}px)` }">
<div v-for="item in visibleItems" :key="item.index" class="virtual-list-item">
Item #{{ item.index + 1 }} - {{ item.data }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import BScroll from 'better-scroll'
const ITEM_HEIGHT = 40
const CONTAINER_HEIGHT = 400
const BUFFER_SIZE = 3
const items = Array.from({ length: 10000 }, (_, i) => ({
index: i,
data: `这是第 ${i + 1} 条数据`
}))
const totalHeight = computed(() => items.length * ITEM_HEIGHT)
const containerRef = ref(null)
const scrollTop = ref(0)
let bsInstance = null
const startIndex = computed(() => {
const start = Math.floor(scrollTop.value / ITEM_HEIGHT)
return Math.max(0, start - BUFFER_SIZE)
})
const endIndex = computed(() => {
const visibleCount = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT)
const start = Math.floor(scrollTop.value / ITEM_HEIGHT)
const end = start + visibleCount + BUFFER_SIZE
return Math.min(items.length - 1, end)
})
const offset = computed(() => {
return startIndex.value * ITEM_HEIGHT
})
const visibleItems = computed(() => {
return items.slice(startIndex.value, endIndex.value + 1)
})
onMounted(() => {
bsInstance = new BScroll(containerRef.value, {
probeType: 3, // 实时派发滚动事件(包括惯性滚动动画期间)
click: true,
scrollY: true,
bounce: false
})
bsInstance.on('scroll', (pos) => {
scrollTop.value = Math.abs(pos.y)
})
})
onUnmounted(() => {
if (bsInstance) {
bsInstance.destroy()
}
})
</script>
<style scoped>
.virtual-scroll-demo {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.virtual-list-container {
height: 400px;
overflow: hidden;
border: 2px solid #ccc;
border-radius: 4px;
position: relative;
}
.virtual-list-phantom {
position: relative;
width: 100%;
}
.virtual-list-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.virtual-list-item {
height: 40px;
line-height: 40px;
padding: 0 16px;
border-bottom: 1px solid #eee;
background: white;
}
.virtual-list-item:hover {
background: #f5f5f5;
}
</style>
四、方案三:固定高度 + 上拉加载更多
在虚拟滚动基础上,检测滚动到底部时加载更多数据。
核心逻辑
- 滚动时检测
scrollHeight - scrollTop - clientHeight < threshold - 触发
loadMore(),用loading状态防止重复触发 - 新数据
push到数组尾部,phantom 总高度自动更新
vue
<template>
<div class="virtual-scroll-demo">
<h1>虚拟滚动 - 上拉加载更多</h1>
<p>当前数据量: {{ items.length }} 条 {{ loading ? '(加载中...)' : '' }}</p>
<div class="virtual-list-container" ref="containerRef" @scroll="handleScroll">
<div class="virtual-list-phantom" :style="{ height: `${totalHeight}px` }">
<div class="virtual-list-content" :style="{ transform: `translateY(${offset}px)` }">
<div v-for="item in visibleItems" :key="item.index" class="virtual-list-item">
Item #{{ item.index + 1 }} - {{ item.data }}
</div>
</div>
</div>
<div v-if="loading" class="loading-tip">加载中...</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const ITEM_HEIGHT = 40
const CONTAINER_HEIGHT = 400
const BUFFER_SIZE = 3
const LOAD_THRESHOLD = 100
const PAGE_SIZE = 20
const items = ref(
Array.from({ length: 50 }, (_, i) => ({
index: i,
data: `这是第 ${i + 1} 条数据`
}))
)
const scrollTop = ref(0)
const loading = ref(false)
const containerRef = ref(null)
const totalHeight = computed(() => items.value.length * ITEM_HEIGHT)
const startIndex = computed(() => {
const start = Math.floor(scrollTop.value / ITEM_HEIGHT)
return Math.max(0, start - BUFFER_SIZE)
})
const endIndex = computed(() => {
const visibleCount = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT)
const start = Math.floor(scrollTop.value / ITEM_HEIGHT)
const end = start + visibleCount + BUFFER_SIZE
return Math.min(items.value.length - 1, end)
})
const offset = computed(() => startIndex.value * ITEM_HEIGHT)
const visibleItems = computed(() => {
return items.value.slice(startIndex.value, endIndex.value + 1)
})
const handleScroll = () => {
if (!containerRef.value) return
scrollTop.value = containerRef.value.scrollTop
const { scrollTop: st, scrollHeight, clientHeight } = containerRef.value
if (scrollHeight - st - clientHeight < LOAD_THRESHOLD && !loading.value) {
loadMore()
}
}
const loadMore = () => {
loading.value = true
setTimeout(() => {
const start = items.value.length
const newItems = Array.from({ length: PAGE_SIZE }, (_, i) => ({
index: start + i,
data: `这是第 ${start + i + 1} 条数据`
}))
items.value.push(...newItems)
loading.value = false
}, 500)
}
</script>
<style scoped>
.virtual-scroll-demo {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.virtual-list-container {
height: 400px;
overflow-y: auto;
border: 2px solid #ccc;
border-radius: 4px;
position: relative;
}
.virtual-list-phantom {
position: relative;
width: 100%;
}
.virtual-list-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.virtual-list-item {
height: 40px;
line-height: 40px;
padding: 0 16px;
border-bottom: 1px solid #eee;
background: white;
}
.virtual-list-item:hover {
background: #f5f5f5;
}
.loading-tip {
text-align: center;
padding: 10px;
color: #999;
font-size: 14px;
}
</style>
五、方案四:不定高度 + 上拉加载更多
核心难点
固定高度时,startIndex = scrollTop / itemHeight 可以直接算出。不定高度时,无法直接计算,需要:
- 前缀和数组 :
positions[i]= 第 i 项顶部的 y 坐标 - 二分查找 :在前缀和数组中找到
scrollTop对应的索引 - ResizeObserver:渲染后测量真实高度,更新缓存
- 位置补偿 :高度变化时调整
scrollTop,防止跳动
实现流程
1. 初始化:每项给预估高度(60px),构建前缀和数组
2. 滚动时:二分查找找到 startIndex
3. 渲染后:ResizeObserver 测量真实高度
4. 更新:用真实高度替换预估高度,重新计算前缀和
5. 补偿:如果真实高度和预估不同,调整 scrollTop 防止跳动
vue
<template>
<div class="virtual-scroll-demo">
<h1>虚拟滚动 - 不定高度 + 上拉加载</h1>
<p>当前数据量: {{ items.length }} 条 {{ loading ? '(加载中...)' : '' }}</p>
<div class="virtual-list-container" ref="containerRef" @scroll="handleScroll">
<div class="virtual-list-phantom" :style="{ height: `${totalHeight}px` }">
<div class="virtual-list-content" :style="{ transform: `translateY(${offset}px)` }">
<div v-for="item in visibleItems" :key="item.index" class="virtual-list-item"
:ref="el => setItemRef(el, item.index)">
Item #{{ item.index + 1 }}
<div class="item-content">{{ item.data }}</div>
</div>
</div>
<div v-if="loading" class="loading-tip">加载中...</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
const ESTIMATED_HEIGHT = 60
const CONTAINER_HEIGHT = 400
const BUFFER_SIZE = 3
const LOAD_THRESHOLD = 100
const PAGE_SIZE = 20
const items = ref(
Array.from({ length: 50 }, (_, i) => ({
index: i,
data: `这是第 ${i + 1} 条数据,内容长度不同:${'x'.repeat(Math.floor(Math.random() * 100) + 20)}`
}))
)
const scrollTop = ref(0)
const loading = ref(false)
const containerRef = ref(null)
// 高度缓存:index → 实际高度
const heightCache = new Map()
// 前缀和数组:positions[i] = 第 i 项顶部的 y 坐标
const positions = ref([])
const initPositions = () => {
const pos = [0]
for (let i = 0; i < items.value.length; i++) {
const height = heightCache.get(i) || ESTIMATED_HEIGHT
pos.push(pos[i] + height)
}
positions.value = pos
}
initPositions()
const totalHeight = computed(() => positions.value[positions.value.length - 1] || 0)
// 二分查找
const findStartIndex = (scrollPos) => {
const pos = positions.value
let low = 0, high = pos.length - 1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
if (pos[mid] < scrollPos) low = mid + 1
else high = mid - 1
}
return high >= 0 ? high : 0
}
const startIndex = computed(() => {
const start = findStartIndex(scrollTop.value)
return Math.max(0, start - BUFFER_SIZE)
})
const endIndex = computed(() => {
const start = findStartIndex(scrollTop.value)
let end = start
const pos = positions.value
const maxScroll = scrollTop.value + CONTAINER_HEIGHT
while (end < items.value.length && pos[end] < maxScroll) end++
return Math.min(items.value.length - 1, end + BUFFER_SIZE)
})
const offset = computed(() => positions.value[startIndex.value] || 0)
const visibleItems = computed(() => {
return items.value.slice(startIndex.value, endIndex.value + 1)
})
const handleScroll = () => {
if (!containerRef.value) return
scrollTop.value = containerRef.value.scrollTop
const { scrollTop: st, scrollHeight, clientHeight } = containerRef.value
if (scrollHeight - st - clientHeight < LOAD_THRESHOLD && !loading.value) {
loadMore()
}
}
const loadMore = () => {
loading.value = true
setTimeout(() => {
const start = items.value.length
const newItems = Array.from({ length: PAGE_SIZE }, (_, i) => ({
index: start + i,
data: `这是第 ${start + i + 1} 条数据,内容长度不同:${'x'.repeat(Math.floor(Math.random() * 100) + 20)}`
}))
items.value.push(...newItems)
loading.value = false
}, 500)
}
// 监听 items 变化,只观察新增元素
let prevLength = items.value.length
watch(() => items.value.length, (newLength) => {
initPositions()
nextTick(() => {
for (let i = prevLength; i < newLength; i++) {
const el = itemRefs.get(i)
if (el && !el.dataset.observed) {
el.dataset.index = i
el.dataset.observed = 'true'
resizeObserver?.observe(el)
}
}
prevLength = newLength
})
})
// 设置 item ref
const itemRefs = new Map()
const setItemRef = (el, index) => {
if (el) itemRefs.set(index, el)
else itemRefs.delete(index)
}
// ResizeObserver 测量真实高度
let resizeObserver = null
onMounted(() => {
resizeObserver = new ResizeObserver((entries) => {
let needUpdate = false
for (const entry of entries) {
const index = parseInt(entry.target.dataset.index)
if (isNaN(index)) continue
const height = entry.target.offsetHeight
const oldHeight = heightCache.get(index)
if (oldHeight !== height) {
heightCache.set(index, height)
needUpdate = true
}
}
if (needUpdate) {
const oldScrollTop = scrollTop.value
const oldStartIndex = startIndex.value
const oldOffset = positions.value[oldStartIndex] || 0
initPositions()
// 位置补偿:防止跳动
const newOffset = positions.value[oldStartIndex] || 0
const diff = newOffset - oldOffset
if (containerRef.value && diff !== 0) {
containerRef.value.scrollTop = oldScrollTop + diff
scrollTop.value = oldScrollTop + diff
}
}
})
nextTick(() => {
itemRefs.forEach((el, index) => {
if (el) {
el.dataset.index = index
el.dataset.observed = 'true'
resizeObserver.observe(el)
}
})
})
})
onUnmounted(() => {
if (resizeObserver) resizeObserver.disconnect()
})
</script>
<style scoped>
.virtual-scroll-demo {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.virtual-list-container {
height: 400px;
overflow-y: auto;
border: 2px solid #ccc;
border-radius: 4px;
position: relative;
}
.virtual-list-phantom {
position: relative;
width: 100%;
}
.virtual-list-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.virtual-list-item {
padding: 10px 16px;
border-bottom: 1px solid #eee;
background: white;
}
.virtual-list-item:hover {
background: #f5f5f5;
}
.item-content {
margin-top: 5px;
color: #666;
font-size: 14px;
word-break: break-all;
}
.loading-tip {
text-align: center;
padding: 10px;
color: #999;
font-size: 14px;
}
</style>
六、常见问题
1. translateY 的作用是什么?
content 是 absolute 定位在 phantom 内部的。phantom 随容器滚动上移了 scrollTop,所以 translateY(offset) 需要把 content 推到 startIndex * itemHeight 的位置,让 item 在视口中出现在正确位置。
2. Buffer 的作用是什么?
Buffer 是额外多渲染的几项,藏在视口外。当用户快速滚动时,buffer 项可以立即顶上,避免白屏。
3. 为什么 offset = startIndex * ITEM_HEIGHT?
因为 startIndex 包含了 buffer,content 块的第一项是 buffer 项。translateY(startIndex * itemHeight) 把 buffer 项推到正确位置,buffer 项在视口上方被裁掉,可见项从视口顶部开始正确显示。
4. Better-Scroll 为什么要用 probeType: 3?
probeType: 2 在惯性滚动(momentum)动画期间不会 触发 scroll 事件,只有 probeType: 3 才会在每一帧都触发,保证虚拟滚动在惯性滚动时也能正确更新。