大列表渲染性能方案完全指南

HTMLPAGE 团队
18 分钟阅读

深入解析前端大列表渲染的性能挑战与解决方案,从虚拟滚动到分页加载,掌握处理海量数据展示的核心技术和最佳实践。

#大列表 #性能优化 #虚拟滚动 #分页加载 #前端渲染

大列表渲染性能方案完全指南

大列表渲染的挑战

在现代 Web 应用中,展示大量数据是常见需求:电商产品列表、社交媒体信息流、日志查看器、数据表格等。当数据量从几百条增长到几万甚至几十万条时,传统的渲染方式会遇到严重的性能问题。

大列表渲染的性能问题:

数据量        DOM 节点数      内存占用        渲染时间
───────────────────────────────────────────────────────
100 条        ~500           ~5 MB          ~50ms   ✅
1,000 条      ~5,000         ~50 MB         ~500ms  ⚠️
10,000 条     ~50,000        ~500 MB        ~5s     ❌
100,000 条    ~500,000       ~5 GB          崩溃    💀

问题根源:
┌─────────────────────────────────────────────────────────┐
│                                                         │
│  1. DOM 节点过多                                        │
│     → 内存占用高                                        │
│     → 布局计算慢                                        │
│     → 滚动卡顿                                          │
│                                                         │
│  2. 初始渲染耗时                                        │
│     → 首屏白屏时间长                                    │
│     → 用户等待体验差                                    │
│                                                         │
│  3. 交互响应慢                                          │
│     → 搜索/筛选延迟                                     │
│     → 更新单项导致全量重渲染                            │
│                                                         │
└─────────────────────────────────────────────────────────┘

解决方案概览

方案对比

大列表渲染方案对比:

┌──────────────────┬──────────────┬──────────────┬──────────────┐
│      方案        │   适用场景   │    复杂度    │   性能提升   │
├──────────────────┼──────────────┼──────────────┼──────────────┤
│ 分页加载         │ <10,000 条   │    低        │    中        │
│ 懒加载/无限滚动  │ <10,000 条   │    低        │    中        │
│ 虚拟滚动         │ 任意数量     │    中        │    高        │
│ 时间切片         │ 初始渲染     │    中        │    中        │
│ Web Worker       │ 复杂计算     │    高        │    高        │
│ Canvas/WebGL     │ 超大量数据   │    高        │    极高      │
└──────────────────┴──────────────┴──────────────┴──────────────┘

方案一:分页加载

分页是最简单直接的解决方案,将大量数据分割成小块按需加载。

传统分页实现

// composables/usePagination.ts
import { ref, computed, watch } from 'vue';

interface PaginationOptions<T> {
  data: T[] | (() => Promise<T[]>);
  pageSize?: number;
  initialPage?: number;
}

export function usePagination<T>(options: PaginationOptions<T>) {
  const {
    data,
    pageSize = 20,
    initialPage = 1
  } = options;
  
  const currentPage = ref(initialPage);
  const allData = ref<T[]>([]);
  const isLoading = ref(false);
  const totalCount = ref(0);
  
  // 计算总页数
  const totalPages = computed(() => 
    Math.ceil(totalCount.value / pageSize)
  );
  
  // 当前页数据
  const currentData = computed(() => {
    const start = (currentPage.value - 1) * pageSize;
    const end = start + pageSize;
    return allData.value.slice(start, end);
  });
  
  // 分页信息
  const pagination = computed(() => ({
    current: currentPage.value,
    pageSize,
    total: totalCount.value,
    totalPages: totalPages.value,
    hasNext: currentPage.value < totalPages.value,
    hasPrev: currentPage.value > 1
  }));
  
  // 加载数据
  async function loadData() {
    isLoading.value = true;
    try {
      const result = typeof data === 'function' ? await data() : data;
      allData.value = result;
      totalCount.value = result.length;
    } finally {
      isLoading.value = false;
    }
  }
  
  // 页面导航
  function goToPage(page: number) {
    if (page >= 1 && page <= totalPages.value) {
      currentPage.value = page;
    }
  }
  
  function nextPage() {
    if (pagination.value.hasNext) {
      currentPage.value++;
    }
  }
  
  function prevPage() {
    if (pagination.value.hasPrev) {
      currentPage.value--;
    }
  }
  
  // 初始加载
  loadData();
  
  return {
    data: currentData,
    pagination,
    isLoading,
    goToPage,
    nextPage,
    prevPage,
    reload: loadData
  };
}

服务端分页

// composables/useServerPagination.ts
interface ServerPaginationOptions {
  fetchFn: (params: { page: number; pageSize: number }) => Promise<{
    data: any[];
    total: number;
  }>;
  pageSize?: number;
}

export function useServerPagination(options: ServerPaginationOptions) {
  const { fetchFn, pageSize = 20 } = options;
  
  const data = ref([]);
  const currentPage = ref(1);
  const total = ref(0);
  const isLoading = ref(false);
  const error = ref<Error | null>(null);
  
  const totalPages = computed(() => Math.ceil(total.value / pageSize));
  
  async function fetchPage(page: number) {
    isLoading.value = true;
    error.value = null;
    
    try {
      const result = await fetchFn({ page, pageSize });
      data.value = result.data;
      total.value = result.total;
      currentPage.value = page;
    } catch (e) {
      error.value = e as Error;
    } finally {
      isLoading.value = false;
    }
  }
  
  // 预加载下一页
  async function prefetchNext() {
    if (currentPage.value < totalPages.value) {
      // 静默预加载,不更新状态
      await fetchFn({ page: currentPage.value + 1, pageSize });
    }
  }
  
  // 初始加载
  fetchPage(1);
  
  return {
    data,
    currentPage,
    total,
    totalPages,
    isLoading,
    error,
    goToPage: fetchPage,
    prefetchNext
  };
}

// 使用示例
const { data, pagination, goToPage } = useServerPagination({
  fetchFn: async ({ page, pageSize }) => {
    const response = await fetch(
      `/api/products?page=${page}&limit=${pageSize}`
    );
    return response.json();
  },
  pageSize: 20
});

方案二:无限滚动

无限滚动(也叫懒加载)在用户滚动到底部时自动加载更多数据,适合社交媒体、新闻流等场景。

基础无限滚动

// composables/useInfiniteScroll.ts
import { ref, onMounted, onUnmounted } from 'vue';

interface InfiniteScrollOptions<T> {
  fetchFn: (page: number) => Promise<T[]>;
  pageSize?: number;
  threshold?: number; // 距离底部多少像素时触发加载
  container?: HTMLElement | null;
}

export function useInfiniteScroll<T>(options: InfiniteScrollOptions<T>) {
  const {
    fetchFn,
    pageSize = 20,
    threshold = 200,
    container = null
  } = options;
  
  const items = ref<T[]>([]);
  const page = ref(1);
  const isLoading = ref(false);
  const hasMore = ref(true);
  const error = ref<Error | null>(null);
  
  // 加载更多数据
  async function loadMore() {
    if (isLoading.value || !hasMore.value) return;
    
    isLoading.value = true;
    error.value = null;
    
    try {
      const newItems = await fetchFn(page.value);
      
      if (newItems.length < pageSize) {
        hasMore.value = false;
      }
      
      items.value = [...items.value, ...newItems];
      page.value++;
    } catch (e) {
      error.value = e as Error;
    } finally {
      isLoading.value = false;
    }
  }
  
  // 滚动处理
  function handleScroll() {
    const scrollContainer = container || document.documentElement;
    const scrollTop = scrollContainer.scrollTop;
    const scrollHeight = scrollContainer.scrollHeight;
    const clientHeight = scrollContainer.clientHeight;
    
    // 距离底部小于阈值时加载
    if (scrollHeight - scrollTop - clientHeight < threshold) {
      loadMore();
    }
  }
  
  // 使用 IntersectionObserver 优化
  let observer: IntersectionObserver | null = null;
  const sentinelRef = ref<HTMLElement | null>(null);
  
  function setupObserver() {
    if (!sentinelRef.value) return;
    
    observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          loadMore();
        }
      },
      {
        root: container,
        rootMargin: `${threshold}px`
      }
    );
    
    observer.observe(sentinelRef.value);
  }
  
  // 重置
  function reset() {
    items.value = [];
    page.value = 1;
    hasMore.value = true;
    error.value = null;
  }
  
  onMounted(() => {
    loadMore(); // 初始加载
    setupObserver();
  });
  
  onUnmounted(() => {
    observer?.disconnect();
  });
  
  return {
    items,
    isLoading,
    hasMore,
    error,
    sentinelRef,
    loadMore,
    reset
  };
}

无限滚动组件

<!-- components/InfiniteList.vue -->
<script setup lang="ts" generic="T">
import { useInfiniteScroll } from '@/composables/useInfiniteScroll';

const props = defineProps<{
  fetchFn: (page: number) => Promise<T[]>;
  pageSize?: number;
  threshold?: number;
}>();

const { items, isLoading, hasMore, error, sentinelRef, reset } = 
  useInfiniteScroll({
    fetchFn: props.fetchFn,
    pageSize: props.pageSize,
    threshold: props.threshold
  });

defineExpose({ reset });
</script>

<template>
  <div class="infinite-list">
    <!-- 列表项 -->
    <div v-for="(item, index) in items" :key="index" class="list-item">
      <slot :item="item" :index="index" />
    </div>
    
    <!-- 加载状态 -->
    <div v-if="isLoading" class="loading">
      <slot name="loading">
        <div class="loading-spinner">加载中...</div>
      </slot>
    </div>
    
    <!-- 错误状态 -->
    <div v-else-if="error" class="error">
      <slot name="error" :error="error">
        <p>加载失败: {{ error.message }}</p>
        <button @click="reset">重试</button>
      </slot>
    </div>
    
    <!-- 没有更多 -->
    <div v-else-if="!hasMore && items.length > 0" class="no-more">
      <slot name="no-more">
        <p>已加载全部内容</p>
      </slot>
    </div>
    
    <!-- 空状态 -->
    <div v-else-if="!hasMore && items.length === 0" class="empty">
      <slot name="empty">
        <p>暂无数据</p>
      </slot>
    </div>
    
    <!-- 观察哨兵元素 -->
    <div ref="sentinelRef" class="sentinel" />
  </div>
</template>

<style scoped>
.infinite-list {
  position: relative;
}

.sentinel {
  height: 1px;
  visibility: hidden;
}

.loading, .error, .no-more, .empty {
  padding: 20px;
  text-align: center;
}

.loading-spinner {
  display: inline-block;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}
</style>

方案三:虚拟滚动

虚拟滚动是处理超大列表的最佳方案,核心思想是只渲染可视区域内的元素。

虚拟滚动原理

虚拟滚动工作原理:

完整数据(10万条)                      实际渲染(~20条)
┌─────────────────┐                    ┌─────────────────┐
│ Item 0          │                    │                 │
│ Item 1          │                    │                 │
│ ...             │                    │                 │
│ Item 999        │                    │                 │
├─────────────────┤ ← 可视区域顶部 →   ├─────────────────┤
│ Item 1000       │    ═══════════    │ Item 1000       │
│ Item 1001       │    可视区域       │ Item 1001       │
│ Item 1002       │    用户看到的     │ Item 1002       │
│ ...             │    只有这些       │ ...             │
│ Item 1019       │    ═══════════    │ Item 1019       │
├─────────────────┤ ← 可视区域底部 →   ├─────────────────┤
│ Item 1020       │                    │                 │
│ ...             │                    │                 │
│ Item 99999      │                    │                 │
└─────────────────┘                    └─────────────────┘

关键计算:
- startIndex = Math.floor(scrollTop / itemHeight)
- endIndex = startIndex + Math.ceil(viewportHeight / itemHeight)
- 渲染 items[startIndex...endIndex]
- 使用 paddingTop 模拟滚动位置

固定高度虚拟滚动实现

// composables/useVirtualList.ts
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';

interface VirtualListOptions<T> {
  items: T[];
  itemHeight: number;
  overscan?: number; // 额外渲染的缓冲项数
}

export function useVirtualList<T>(options: VirtualListOptions<T>) {
  const { items, itemHeight, overscan = 5 } = options;
  
  const containerRef = ref<HTMLElement | null>(null);
  const scrollTop = ref(0);
  const containerHeight = ref(0);
  
  // 计算可见范围
  const visibleRange = computed(() => {
    const start = Math.floor(scrollTop.value / itemHeight);
    const visibleCount = Math.ceil(containerHeight.value / itemHeight);
    
    // 添加缓冲区
    const startIndex = Math.max(0, start - overscan);
    const endIndex = Math.min(items.length, start + visibleCount + overscan);
    
    return { startIndex, endIndex };
  });
  
  // 可见项
  const visibleItems = computed(() => {
    const { startIndex, endIndex } = visibleRange.value;
    return items.slice(startIndex, endIndex).map((item, index) => ({
      item,
      index: startIndex + index
    }));
  });
  
  // 总高度(用于滚动条)
  const totalHeight = computed(() => items.length * itemHeight);
  
  // 顶部偏移(用于定位可见项)
  const offsetTop = computed(() => 
    visibleRange.value.startIndex * itemHeight
  );
  
  // 滚动处理
  function handleScroll(e: Event) {
    const target = e.target as HTMLElement;
    scrollTop.value = target.scrollTop;
  }
  
  // 滚动到指定索引
  function scrollToIndex(index: number, behavior: ScrollBehavior = 'auto') {
    if (!containerRef.value) return;
    
    const offset = index * itemHeight;
    containerRef.value.scrollTo({
      top: offset,
      behavior
    });
  }
  
  // 监听容器大小变化
  let resizeObserver: ResizeObserver | null = null;
  
  onMounted(() => {
    if (!containerRef.value) return;
    
    containerHeight.value = containerRef.value.clientHeight;
    
    resizeObserver = new ResizeObserver((entries) => {
      containerHeight.value = entries[0].contentRect.height;
    });
    
    resizeObserver.observe(containerRef.value);
  });
  
  onUnmounted(() => {
    resizeObserver?.disconnect();
  });
  
  return {
    containerRef,
    visibleItems,
    totalHeight,
    offsetTop,
    handleScroll,
    scrollToIndex
  };
}

虚拟滚动组件

<!-- components/VirtualList.vue -->
<script setup lang="ts" generic="T">
import { useVirtualList } from '@/composables/useVirtualList';

const props = defineProps<{
  items: T[];
  itemHeight: number;
  overscan?: number;
}>();

const {
  containerRef,
  visibleItems,
  totalHeight,
  offsetTop,
  handleScroll,
  scrollToIndex
} = useVirtualList({
  items: props.items,
  itemHeight: props.itemHeight,
  overscan: props.overscan
});

defineExpose({ scrollToIndex });
</script>

<template>
  <div 
    ref="containerRef"
    class="virtual-list-container"
    @scroll="handleScroll"
  >
    <!-- 撑开滚动高度的占位元素 -->
    <div 
      class="virtual-list-spacer"
      :style="{ height: `${totalHeight}px` }"
    >
      <!-- 实际渲染的列表项 -->
      <div 
        class="virtual-list-content"
        :style="{ transform: `translateY(${offsetTop}px)` }"
      >
        <div
          v-for="{ item, index } in visibleItems"
          :key="index"
          class="virtual-list-item"
          :style="{ height: `${itemHeight}px` }"
        >
          <slot :item="item" :index="index" />
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-list-container {
  overflow-y: auto;
  height: 100%;
}

.virtual-list-spacer {
  position: relative;
}

.virtual-list-content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.virtual-list-item {
  box-sizing: border-box;
}
</style>

动态高度虚拟滚动

当列表项高度不固定时,需要更复杂的实现:

// composables/useDynamicVirtualList.ts
interface DynamicVirtualListOptions<T> {
  items: T[];
  estimatedItemHeight: number;
  getItemKey: (item: T, index: number) => string | number;
}

export function useDynamicVirtualList<T>(
  options: DynamicVirtualListOptions<T>
) {
  const { items, estimatedItemHeight, getItemKey } = options;
  
  const containerRef = ref<HTMLElement | null>(null);
  const scrollTop = ref(0);
  const containerHeight = ref(0);
  
  // 缓存每个项的实际高度
  const itemHeights = ref<Map<string | number, number>>(new Map());
  
  // 获取项的高度(已测量或估算)
  function getItemHeight(index: number): number {
    const key = getItemKey(items[index], index);
    return itemHeights.value.get(key) || estimatedItemHeight;
  }
  
  // 更新项的高度
  function updateItemHeight(index: number, height: number) {
    const key = getItemKey(items[index], index);
    if (itemHeights.value.get(key) !== height) {
      itemHeights.value.set(key, height);
    }
  }
  
  // 计算项的位置
  const positions = computed(() => {
    const result: { top: number; height: number }[] = [];
    let top = 0;
    
    for (let i = 0; i < items.length; i++) {
      const height = getItemHeight(i);
      result.push({ top, height });
      top += height;
    }
    
    return result;
  });
  
  // 总高度
  const totalHeight = computed(() => {
    if (positions.value.length === 0) return 0;
    const last = positions.value[positions.value.length - 1];
    return last.top + last.height;
  });
  
  // 二分查找起始索引
  function findStartIndex(scrollTop: number): number {
    let low = 0;
    let high = positions.value.length - 1;
    
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      const pos = positions.value[mid];
      
      if (pos.top + pos.height <= scrollTop) {
        low = mid + 1;
      } else if (pos.top > scrollTop) {
        high = mid - 1;
      } else {
        return mid;
      }
    }
    
    return Math.max(0, low);
  }
  
  // 可见范围
  const visibleRange = computed(() => {
    const start = findStartIndex(scrollTop.value);
    let end = start;
    let accHeight = 0;
    
    while (end < items.length && accHeight < containerHeight.value + 200) {
      accHeight += getItemHeight(end);
      end++;
    }
    
    // 添加缓冲
    const startIndex = Math.max(0, start - 3);
    const endIndex = Math.min(items.length, end + 3);
    
    return { startIndex, endIndex };
  });
  
  // 可见项
  const visibleItems = computed(() => {
    const { startIndex, endIndex } = visibleRange.value;
    return items.slice(startIndex, endIndex).map((item, i) => ({
      item,
      index: startIndex + i,
      style: {
        position: 'absolute' as const,
        top: `${positions.value[startIndex + i]?.top || 0}px`,
        left: 0,
        right: 0
      }
    }));
  });
  
  return {
    containerRef,
    visibleItems,
    totalHeight,
    updateItemHeight,
    handleScroll: (e: Event) => {
      scrollTop.value = (e.target as HTMLElement).scrollTop;
    }
  };
}

方案四:时间切片渲染

对于初始渲染大量数据的场景,可以使用时间切片避免阻塞主线程。

// utils/timeSlice.ts

// 将大任务分割成小块执行
async function timeSliceRender<T>(
  items: T[],
  renderFn: (item: T, index: number) => void,
  options: {
    chunkSize?: number;
    yieldInterval?: number;
  } = {}
) {
  const { chunkSize = 50, yieldInterval = 16 } = options;
  
  let index = 0;
  
  while (index < items.length) {
    const chunk = items.slice(index, index + chunkSize);
    
    // 渲染一批
    chunk.forEach((item, i) => {
      renderFn(item, index + i);
    });
    
    index += chunkSize;
    
    // 让出主线程
    if (index < items.length) {
      await yieldToMain(yieldInterval);
    }
  }
}

// 让出主线程控制权
function yieldToMain(ms: number = 0): Promise<void> {
  return new Promise(resolve => {
    if ('scheduler' in globalThis && 'yield' in (globalThis as any).scheduler) {
      // 使用 Scheduler API(如果可用)
      (globalThis as any).scheduler.yield().then(resolve);
    } else if (typeof requestIdleCallback !== 'undefined') {
      // 使用 requestIdleCallback
      requestIdleCallback(() => resolve());
    } else {
      // 降级到 setTimeout
      setTimeout(resolve, ms);
    }
  });
}

// Vue composable
export function useTimeSliceList<T>(items: Ref<T[]>, chunkSize = 50) {
  const renderedItems = ref<T[]>([]);
  const isRendering = ref(false);
  const progress = ref(0);
  
  async function render() {
    renderedItems.value = [];
    isRendering.value = true;
    progress.value = 0;
    
    await timeSliceRender(
      items.value,
      (item, index) => {
        renderedItems.value.push(item);
        progress.value = (index + 1) / items.value.length;
      },
      { chunkSize }
    );
    
    isRendering.value = false;
  }
  
  watch(items, render, { immediate: true });
  
  return {
    renderedItems,
    isRendering,
    progress
  };
}

方案五:使用 Web Worker

对于需要复杂计算的列表(如搜索、排序、筛选),可以将计算放到 Web Worker 中。

// workers/listWorker.ts
interface WorkerMessage {
  type: 'filter' | 'sort' | 'search';
  data: any[];
  params: any;
}

self.onmessage = (e: MessageEvent<WorkerMessage>) => {
  const { type, data, params } = e.data;
  
  let result: any[];
  
  switch (type) {
    case 'filter':
      result = filterData(data, params.filters);
      break;
    case 'sort':
      result = sortData(data, params.sortBy, params.order);
      break;
    case 'search':
      result = searchData(data, params.query, params.fields);
      break;
    default:
      result = data;
  }
  
  self.postMessage({ type, result });
};

function filterData(data: any[], filters: Record<string, any>) {
  return data.filter(item => {
    return Object.entries(filters).every(([key, value]) => {
      if (value === undefined || value === null || value === '') {
        return true;
      }
      return item[key] === value;
    });
  });
}

function sortData(data: any[], sortBy: string, order: 'asc' | 'desc') {
  return [...data].sort((a, b) => {
    const aVal = a[sortBy];
    const bVal = b[sortBy];
    const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
    return order === 'asc' ? comparison : -comparison;
  });
}

function searchData(data: any[], query: string, fields: string[]) {
  const lowerQuery = query.toLowerCase();
  return data.filter(item => {
    return fields.some(field => {
      const value = String(item[field] || '').toLowerCase();
      return value.includes(lowerQuery);
    });
  });
}

// composables/useListWorker.ts
export function useListWorker<T>() {
  const worker = new Worker(
    new URL('@/workers/listWorker.ts', import.meta.url)
  );
  
  const result = ref<T[]>([]);
  const isProcessing = ref(false);
  
  function process(type: string, data: T[], params: any): Promise<T[]> {
    return new Promise((resolve) => {
      isProcessing.value = true;
      
      worker.onmessage = (e) => {
        result.value = e.data.result;
        isProcessing.value = false;
        resolve(e.data.result);
      };
      
      worker.postMessage({ type, data, params });
    });
  }
  
  onUnmounted(() => {
    worker.terminate();
  });
  
  return {
    result,
    isProcessing,
    filter: (data: T[], filters: Record<string, any>) => 
      process('filter', data, { filters }),
    sort: (data: T[], sortBy: string, order: 'asc' | 'desc') => 
      process('sort', data, { sortBy, order }),
    search: (data: T[], query: string, fields: string[]) => 
      process('search', data, { query, fields })
  };
}

综合优化策略

列表项优化

<!-- components/OptimizedListItem.vue -->
<script setup lang="ts">
import { computed } from 'vue';

const props = defineProps<{
  item: any;
  isVisible: boolean;
}>();

// 只在可见时计算复杂内容
const expensiveData = computed(() => {
  if (!props.isVisible) return null;
  return processExpensiveData(props.item);
});

// 使用 v-once 渲染不变的内容
// 使用 v-memo 缓存条件渲染
</script>

<template>
  <div class="list-item" v-memo="[item.id, item.updatedAt]">
    <!-- 静态内容使用 v-once -->
    <div v-once class="static-content">
      {{ item.staticField }}
    </div>
    
    <!-- 动态内容 -->
    <div class="dynamic-content">
      {{ item.dynamicField }}
    </div>
    
    <!-- 延迟渲染复杂内容 -->
    <div v-if="expensiveData" class="expensive-content">
      {{ expensiveData }}
    </div>
  </div>
</template>

骨架屏占位

<!-- components/ListSkeleton.vue -->
<script setup lang="ts">
const props = defineProps<{
  count?: number;
  itemHeight?: number;
}>();

const skeletonItems = Array(props.count || 5).fill(null);
</script>

<template>
  <div class="skeleton-list">
    <div 
      v-for="(_, index) in skeletonItems"
      :key="index"
      class="skeleton-item"
      :style="{ height: `${itemHeight || 60}px` }"
    >
      <div class="skeleton-avatar" />
      <div class="skeleton-content">
        <div class="skeleton-title" />
        <div class="skeleton-text" />
      </div>
    </div>
  </div>
</template>

<style scoped>
.skeleton-item {
  display: flex;
  align-items: center;
  padding: 12px;
  gap: 12px;
}

.skeleton-avatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

.skeleton-content {
  flex: 1;
}

.skeleton-title, .skeleton-text {
  height: 12px;
  border-radius: 4px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

.skeleton-title {
  width: 60%;
  margin-bottom: 8px;
}

.skeleton-text {
  width: 80%;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
</style>

性能指标监控

// utils/listPerformanceMonitor.ts
class ListPerformanceMonitor {
  private metrics: {
    renderTime: number[];
    scrollFPS: number[];
    memoryUsage: number[];
  } = {
    renderTime: [],
    scrollFPS: [],
    memoryUsage: []
  };
  
  private lastScrollTime = 0;
  private scrollFrameCount = 0;
  
  // 测量渲染时间
  measureRender<T>(fn: () => T): T {
    const start = performance.now();
    const result = fn();
    const end = performance.now();
    
    this.metrics.renderTime.push(end - start);
    
    return result;
  }
  
  // 监控滚动帧率
  trackScrollFPS() {
    const now = performance.now();
    this.scrollFrameCount++;
    
    if (now - this.lastScrollTime >= 1000) {
      this.metrics.scrollFPS.push(this.scrollFrameCount);
      this.scrollFrameCount = 0;
      this.lastScrollTime = now;
    }
  }
  
  // 获取内存使用
  trackMemory() {
    if ('memory' in performance) {
      const memory = (performance as any).memory;
      this.metrics.memoryUsage.push(memory.usedJSHeapSize / 1024 / 1024);
    }
  }
  
  // 获取报告
  getReport() {
    const avg = (arr: number[]) => 
      arr.length ? arr.reduce((a, b) => a + b) / arr.length : 0;
    
    return {
      avgRenderTime: avg(this.metrics.renderTime).toFixed(2) + 'ms',
      avgScrollFPS: Math.round(avg(this.metrics.scrollFPS)),
      avgMemoryMB: avg(this.metrics.memoryUsage).toFixed(2) + 'MB',
      samples: {
        renderTime: this.metrics.renderTime.length,
        scrollFPS: this.metrics.scrollFPS.length,
        memory: this.metrics.memoryUsage.length
      }
    };
  }
}

export const listMonitor = new ListPerformanceMonitor();

最佳实践总结

大列表渲染最佳实践:

方案选择:
✓ 数据量 < 1000:考虑简单分页或懒加载
✓ 数据量 1000-10000:使用虚拟滚动
✓ 数据量 > 10000:虚拟滚动 + 服务端分页
✓ 需要复杂计算:结合 Web Worker

性能优化:
✓ 使用 key 帮助 Vue 追踪 DOM
✓ 使用 v-memo 缓存列表项
✓ 避免在列表项中进行复杂计算
✓ 对滚动事件进行节流
✓ 使用骨架屏改善感知性能

用户体验:
✓ 提供加载状态反馈
✓ 支持跳转到指定位置
✓ 保持滚动位置在刷新后恢复
✓ 提供搜索和筛选功能

监控和调试:
✓ 监控渲染时间和帧率
✓ 使用 Performance 面板分析
✓ 设置性能预算告警
✓ 在真实设备上测试

选择合适的大列表渲染方案,需要根据实际的数据量、交互需求和目标设备来综合考虑。虚拟滚动是解决超大列表的核心技术,但也要注意其复杂性和维护成本。