虚拟滚动

<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>