虚拟滚动
<template>
<div
ref="container"
class="virtual-scroll"
@scroll="onScroll"
>
<div
class="scroll-height"
:style="placeholderStyle"
/>
<div
ref="items"
class="items-container"
:style="containerStyle"
>
<div
v-for="item in visibleItems"
:key="item.id"
class="item"
:style="itemStyle"
>
{{ item.content }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
// 配置
const ITEM_HEIGHT = 40;
const CONTAINER_HEIGHT = 600;
const BUFFER = 10;
const TOTAL_ITEMS = 10000;
// 生成数据
const allItems = Array.from({ length: TOTAL_ITEMS }, (_, i) => ({
id: i,
content: `Item ${i + 1}`
}));
// Refs
const container = ref<HTMLElement>();
const items = ref<HTMLElement>();
// 状态
const scrollTop = ref(0);
const startIndex = ref(0);
// 优化变量
let rafId: number | null = null;
let lastTime = 0;
const FPS = 60;
const frameTime = 1000 / FPS;
// 计算属性
const totalHeight = computed(() => allItems.length * ITEM_HEIGHT);
const visibleCount = computed(() => Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT) + BUFFER * 2);
const placeholderStyle = computed(() => ({
height: `${totalHeight.value}px`
}));
const containerStyle = computed(() => ({
transform: `translateY(${startIndex.value * ITEM_HEIGHT}px)`
}));
const itemStyle = computed(() => ({
height: `${ITEM_HEIGHT}px`,
lineHeight: `${ITEM_HEIGHT}px`
}));
const visibleItems = computed(() => {
const endIndex = Math.min(startIndex.value + visibleCount.value, allItems.length);
return allItems.slice(startIndex.value, endIndex);
});
// 优化的滚动处理
const onScroll = () => {
const now = performance.now();
// 基于时间的节流
if (now - lastTime < frameTime) {
return;
}
lastTime = now;
// 取消之前的帧
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
// 使用 requestAnimationFrame
rafId = requestAnimationFrame(() => {
if (!container.value) return;
const currentScrollTop = container.value.scrollTop;
scrollTop.value = currentScrollTop;
// 计算新起始索引
const newStart = Math.max(0, Math.floor(currentScrollTop / ITEM_HEIGHT) - BUFFER);
// 只有当变化超过阈值时才更新(避免微小滚动导致的频繁更新)
if (Math.abs(newStart - startIndex.value) > 0) {
startIndex.value = newStart;
}
rafId = null;
});
};
// 防抖的滚动结束处理
let scrollEndTimer: number | null = null;
const onScrollEnd = () => {
if (!container.value) return;
const currentScrollTop = container.value.scrollTop;
const currentStart = Math.floor(currentScrollTop / ITEM_HEIGHT);
// 对齐到item边界
const targetScrollTop = currentStart * ITEM_HEIGHT;
if (Math.abs(currentScrollTop - targetScrollTop) > 1) {
// 使用平滑滚动对齐
container.value.scrollTo({
top: targetScrollTop,
behavior: 'smooth'
});
}
};
// 整合滚动事件
const handleScroll = () => {
onScroll();
if (scrollEndTimer) {
clearTimeout(scrollEndTimer);
}
scrollEndTimer = setTimeout(onScrollEnd, 200) as unknown as number;
};
onMounted(() => {
// 初始渲染
startIndex.value = 0;
});
onUnmounted(() => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
if (scrollEndTimer) {
clearTimeout(scrollEndTimer);
}
});
</script>
<style lang="scss" scoped>
.virtual-scroll {
position: relative;
height: 600px;
width: 300px;
overflow-y: auto;
border: 1px solid #e0e0e0;
background: #fff;
/* 滚动优化 */
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}
.scroll-height {
position: absolute;
width: 100%;
opacity: 0;
pointer-events: none;
}
.items-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
will-change: transform;
}
.item {
padding: 0 16px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background: #f5f5f5;
}
}
</style>