Vue 3 无限列表虚拟滚动完整实现
Vue 3 无限列表虚拟滚动完整实现
以下是基于 Vue 3 的无限列表虚拟滚动完整实现方案:
1. 基础虚拟滚动组件
1.1 固定高度虚拟列表
<template>
<div
ref="containerRef"
class="virtual-scroll-container"
@scroll="handleScroll"
>
<!-- 撑开容器高度的占位元素 -->
<div
class="virtual-scroll-placeholder"
:style="{ height: totalHeight + 'px' }"
></div>
<!-- 可见项目容器 -->
<div
class="virtual-scroll-content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="item in visibleItems"
:key="getItemKey(item)"
class="virtual-scroll-item"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item" :index="item.__index" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
const props = defineProps({
// 数据源
items: {
type: Array,
required: true
},
// 每项高度
itemHeight: {
type: Number,
default: 50
},
// 容器高度
height: {
type: Number,
default: 400
},
// 预渲染数量
overscan: {
type: Number,
default: 5
},
// 项的唯一键
itemKey: {
type: [String, Function],
default: 'id'
}
})
const containerRef = ref(null)
const scrollTop = ref(0)
// 计算可见区域
const visibleRange = computed(() => {
const startIndex = Math.max(
0,
Math.floor(scrollTop.value / props.itemHeight) - props.overscan
)
const endIndex = Math.min(
props.items.length - 1,
startIndex + Math.ceil(props.height / props.itemHeight) + props.overscan * 2
)
return { startIndex, endIndex }
})
// 可见项数据
const visibleItems = computed(() => {
const { startIndex, endIndex } = visibleRange.value
return props.items.slice(startIndex, endIndex + 1).map((item, index) => ({
...item,
__index: startIndex + index
}))
})
// 总高度
const totalHeight = computed(() => props.items.length * props.itemHeight)
// Y轴偏移量
const offsetY = computed(() => visibleRange.value.startIndex * props.itemHeight)
// 获取项的唯一键
const getItemKey = (item) => {
if (typeof props.itemKey === 'function') {
return props.itemKey(item)
}
return item[props.itemKey]
}
// 滚动处理
const handleScroll = (event) => {
scrollTop.value = event.target.scrollTop
}
// 滚动到指定位置
const scrollTo = (index) => {
if (containerRef.value) {
containerRef.value.scrollTop = index * props.itemHeight
}
}
// 滚动到项
const scrollToItem = (item) => {
const index = props.items.findIndex(i => getItemKey(i) === getItemKey(item))
if (index !== -1) {
scrollTo(index)
}
}
defineExpose({
scrollTo,
scrollToItem
})
</script>
<style scoped>
.virtual-scroll-container {
height: v-bind('props.height + "px"');
overflow: auto;
position: relative;
}
.virtual-scroll-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.virtual-scroll-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.virtual-scroll-item {
position: absolute;
width: 100%;
box-sizing: border-box;
}
</style>
1.2 使用示例
<template>
<div class="demo-container">
<h2>虚拟滚动列表 ({{ totalItems }} 项)</h2>
<VirtualList
:items="items"
:item-height="60"
:height="500"
:overscan="5"
>
<template #default="{ item, index }">
<div class="list-item" :class="{ even: index % 2 === 0 }">
<div class="item-header">
<span class="item-index">#{{ index + 1 }}</span>
<span class="item-name">{{ item.name }}</span>
</div>
<div class="item-content">{{ item.content }}</div>
<div class="item-footer">
<span class="item-date">{{ item.date }}</span>
<span class="item-tag" :style="{ backgroundColor: item.color }">
{{ item.tag }}
</span>
</div>
</div>
</template>
</VirtualList>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import VirtualList from './components/VirtualList.vue'
const totalItems = 10000
const items = ref([])
// 生成模拟数据
const generateItems = () => {
const tags = ['前端', '后端', '移动端', 'AI', '数据库', '运维']
const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#feca57', '#ff9ff3']
return Array.from({ length: totalItems }, (_, index) => ({
id: index + 1,
name: `项目 ${index + 1}`,
content: `这是第 ${index + 1} 个项目的内容描述,用于演示虚拟滚动效果。`,
date: new Date(Date.now() - Math.random() * 1e10).toLocaleDateString(),
tag: tags[index % tags.length],
color: colors[index % colors.length]
}))
}
onMounted(() => {
items.value = generateItems()
})
</script>
<style scoped>
.demo-container {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.list-item {
padding: 12px 16px;
border-bottom: 1px solid #e1e1e1;
background: white;
transition: background-color 0.2s;
}
.list-item:hover {
background-color: #f5f5f5;
}
.list-item.even {
background-color: #fafafa;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.item-index {
font-size: 12px;
color: #666;
font-weight: bold;
}
.item-name {
font-weight: bold;
color: #333;
}
.item-content {
font-size: 14px;
color: #666;
margin-bottom: 8px;
line-height: 1.4;
}
.item-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.item-date {
color: #999;
}
.item-tag {
padding: 2px 8px;
border-radius: 12px;
color: white;
font-size: 11px;
}
</style>
2. 可变高度虚拟列表
2.1 动态高度虚拟列表组件
<template>
<div
ref="containerRef"
class="dynamic-virtual-list"
@scroll="handleScroll"
>
<div
class="list-placeholder"
:style="{ height: totalHeight + 'px' }"
></div>
<div class="list-content">
<div
v-for="item in visibleItems"
:key="getItemKey(item)"
ref="itemRefs"
class="list-item"
:data-index="item.__index"
>
<slot :item="item" :index="item.__index" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
const props = defineProps({
items: {
type: Array,
required: true
},
height: {
type: Number,
default: 400
},
estimatedItemHeight: {
type: Number,
default: 50
},
overscan: {
type: Number,
default: 5
},
itemKey: {
type: [String, Function],
default: 'id'
}
})
const containerRef = ref(null)
const itemRefs = ref([])
const scrollTop = ref(0)
// 位置缓存
const positions = ref([])
const itemHeights = ref(new Map())
// 初始化位置信息
const initPositions = () => {
positions.value = props.items.map((item, index) => {
const key = getItemKey(item)
const cachedHeight = itemHeights.value.get(key)
return {
index,
key,
top: index * props.estimatedItemHeight,
height: cachedHeight || props.estimatedItemHeight,
bottom: (index + 1) * props.estimatedItemHeight
}
})
}
// 更新位置信息
const updatePositions = () => {
let top = 0
positions.value = props.items.map((item, index) => {
const key = getItemKey(item)
const height = itemHeights.value.get(key) || props.estimatedItemHeight
const position = {
index,
key,
top,
height,
bottom: top + height
}
top += height
return position
})
}
// 总高度
const totalHeight = computed(() => {
if (positions.value.length === 0) return 0
return positions.value[positions.value.length - 1].bottom
})
// 获取可见范围
const getVisibleRange = () => {
const startIndex = findNearestIndex(scrollTop.value)
const endIndex = findNearestIndex(scrollTop.value + props.height)
return {
startIndex: Math.max(0, startIndex - props.overscan),
endIndex: Math.min(props.items.length - 1, endIndex + props.overscan)
}
}
// 二分查找最近索引
const findNearestIndex = (scrollTop) => {
let left = 0
let right = positions.value.length - 1
let index = -1
while (left <= right) {
const mid = Math.floor((left + right) / 2)
const midValue = positions.value[mid]
if (midValue.bottom >= scrollTop) {
index = mid
right = mid - 1
} else {
left = mid + 1
}
}
return index === -1 ? positions.value.length - 1 : index
}
// 可见项数据
const visibleItems = computed(() => {
const { startIndex, endIndex } = getVisibleRange()
return props.items.slice(startIndex, endIndex + 1).map((item, index) => ({
...item,
__index: startIndex + index
}))
})
// 获取项的唯一键
const getItemKey = (item) => {
if (typeof props.itemKey === 'function') {
return props.itemKey(item)
}
return item[props.itemKey]
}
// 滚动处理
const handleScroll = (event) => {
scrollTop.value = event.target.scrollTop
}
// 测量项高度并更新位置
const measureItems = () => {
nextTick(() => {
let updated = false
itemRefs.value.forEach((itemEl) => {
if (!itemEl) return
const index = parseInt(itemEl.dataset.index)
const item = props.items[index]
const key = getItemKey(item)
const height = itemEl.offsetHeight
const oldHeight = itemHeights.value.get(key)
if (oldHeight !== height) {
itemHeights.value.set(key, height)
updated = true
}
})
if (updated) {
updatePositions()
}
})
}
// 监听数据变化
watch(() => props.items, () => {
initPositions()
}, { deep: true })
// 监听可见项变化,重新测量高度
watch(visibleItems, () => {
measureItems()
})
onMounted(() => {
initPositions()
})
defineExpose({
scrollTo: (index) => {
if (containerRef.value && positions.value[index]) {
containerRef.value.scrollTop = positions.value[index].top
}
}
})
</script>
<style scoped>
.dynamic-virtual-list {
height: v-bind('props.height + "px"');
overflow: auto;
position: relative;
}
.list-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.list-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.list-item {
position: absolute;
width: 100%;
box-sizing: border-box;
}
</style>
3. 无限滚动虚拟列表
3.1 无限滚动虚拟列表
<template>
<div class="infinite-virtual-list">
<VirtualList
ref="virtualListRef"
:items="visibleData"
:item-height="itemHeight"
:height="containerHeight"
:overscan="overscan"
@scroll="handleVirtualScroll"
>
<template #default="{ item, index }">
<slot :item="item" :index="index" :loading="item.__loading" />
</template>
</VirtualList>
<div v-if="loading" class="loading-indicator">
加载中...
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import VirtualList from './VirtualList.vue'
const props = defineProps({
// 数据获取函数
fetchData: {
type: Function,
required: true
},
itemHeight: {
type: Number,
default: 50
},
containerHeight: {
type: Number,
default: 400
},
overscan: {
type: Number,
default: 10
},
pageSize: {
type: Number,
default: 50
},
threshold: {
type: Number,
default: 100
}
})
const emit = defineEmits(['load-more', 'data-loaded'])
const virtualListRef = ref(null)
const allData = ref([])
const currentPage = ref(0)
const loading = ref(false)
const hasMore = ref(true)
const scrollTop = ref(0)
// 可见数据(当前加载的数据)
const visibleData = computed(() => allData.value)
// 加载更多数据
const loadMore = async () => {
if (loading.value || !hasMore.value) return
loading.value = true
try {
currentPage.value++
// 添加 loading 项
allData.value.push({
id: `loading-${Date.now()}`,
__loading: true
})
const newData = await props.fetchData(currentPage.value, props.pageSize)
// 移除 loading 项
allData.value = allData.value.filter(item => !item.__loading)
if (newData.length === 0) {
hasMore.value = false
return
}
allData.value.push(...newData)
emit('data-loaded', newData, currentPage.value)
// 如果数据不足一屏,继续加载
if (allData.value.length < props.pageSize * 2) {
await loadMore()
}
} catch (error) {
console.error('加载数据失败:', error)
// 移除 loading 项
allData.value = allData.value.filter(item => !item.__loading)
currentPage.value--
} finally {
loading.value = false
}
}
// 虚拟滚动处理
const handleVirtualScroll = (event) => {
scrollTop.value = event.target.scrollTop
const scrollHeight = event.target.scrollHeight
const clientHeight = event.target.clientHeight
const scrollBottom = scrollHeight - scrollTop.value - clientHeight
// 接近底部时加载更多
if (scrollBottom < props.threshold && hasMore.value && !loading.value) {
emit('load-more', currentPage.value + 1)
loadMore()
}
}
// 重新加载
const reload = async () => {
allData.value = []
currentPage.value = 0
hasMore.value = true
await loadMore()
}
// 滚动到顶部
const scrollToTop = () => {
if (virtualListRef.value) {
virtualListRef.value.scrollTo(0)
}
}
defineExpose({
reload,
scrollToTop
})
onMounted(() => {
loadMore()
})
</script>
<style scoped>
.infinite-virtual-list {
position: relative;
}
.loading-indicator {
padding: 16px;
text-align: center;
color: #666;
background: #f5f5f5;
}
</style>
4. 性能优化技巧
4.1 使用 shallowRef 优化大数据量
import { shallowRef } from 'vue'
// 对于大数据量,使用 shallowRef 避免深度响应式
const largeData = shallowRef([])
// 批量更新数据
const updateData = (newData) => {
largeData.value = newData
}
4.2 防抖滚动处理
import { debounce } from 'lodash-es'
const handleScroll = debounce((event) => {
scrollTop.value = event.target.scrollTop
}, 16) // 约 60fps
4.3 使用 CSS will-change 优化
.virtual-scroll-content {
will-change: transform;
}
.virtual-scroll-item {
will-change: transform;
contain: layout style paint;
}
5. 完整使用示例
<template>
<div class="infinite-list-demo">
<div class="controls">
<button @click="reload">重新加载</button>
<button @click="scrollToTop">滚动到顶部</button>
<span>已加载: {{ data.length }} 项</span>
</div>
<InfiniteVirtualList
ref="infiniteListRef"
:fetch-data="fetchData"
:item-height="80"
:container-height="600"
:page-size="30"
@load-more="onLoadMore"
@data-loaded="onDataLoaded"
>
<template #default="{ item, index, loading }">
<div v-if="loading" class="loading-item">
加载中...
</div>
<div v-else class="data-item">
<div class="item-header">
<h3>{{ item.title }}</h3>
<span class="index">#{{ index + 1 }}</span>
</div>
<p class="item-content">{{ item.content }}</p>
<div class="item-meta">
<span class="author">{{ item.author }}</span>
<span class="time">{{ item.time }}</span>
</div>
</div>
</template>
</InfiniteVirtualList>
</div>
</template>
<script setup>
import { ref } from 'vue'
import InfiniteVirtualList from './components/InfiniteVirtualList.vue'
const infiniteListRef = ref(null)
const data = ref([])
// 模拟数据获取
const fetchData = async (page, pageSize) => {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500))
const startIndex = (page - 1) * pageSize
return Array.from({ length: pageSize }, (_, i) => ({
id: startIndex + i + 1,
title: `项目 ${startIndex + i + 1}`,
content: `这是第 ${startIndex + i + 1} 个项目的内容描述。虚拟滚动技术可以高效处理大量数据。`,
author: `作者 ${(startIndex + i) % 10 + 1}`,
time: new Date(Date.now() - Math.random() * 1e9).toLocaleString()
}))
}
const onLoadMore = (page) => {
console.log('开始加载第', page, '页')
}
const onDataLoaded = (newData, page) => {
data.value = [...data.value, ...newData]
console.log('第', page, '页加载完成,共', data.value.length, '项')
}
const reload = () => {
data.value = []
infiniteListRef.value?.reload()
}
const scrollToTop = () => {
infiniteListRef.value?.scrollToTop()
}
</script>
<style scoped>
.infinite-list-demo {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.controls {
margin-bottom: 20px;
display: flex;
gap: 10px;
align-items: center;
}
.loading-item {
padding: 20px;
text-align: center;
background: #f8f9fa;
color: #6c757d;
border-radius: 4px;
}
.data-item {
padding: 16px;
border: 1px solid #e9ecef;
border-radius: 6px;
margin-bottom: 8px;
background: white;
transition: box-shadow 0.2s;
}
.data-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.item-header h3 {
margin: 0;
color: #333;
}
.index {
color: #6c757d;
font-size: 12px;
}
.item-content {
color: #666;
line-height: 1.5;
margin-bottom: 8px;
}
.item-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #999;
}
</style>
这个实现方案提供了:
- ✅ 固定高度虚拟滚动
- ✅ 可变高度虚拟滚动
- ✅ 无限滚动加载
- ✅ 性能优化
- ✅ 完整的 TypeScript 支持(可扩展)
- ✅ 灵活的插槽系统
可以根据具体需求选择合适的实现方案。
挣钱养家

浙公网安备 33010602011771号