大列表渲染性能方案完全指南
大列表渲染的挑战
在现代 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 面板分析
✓ 设置性能预算告警
✓ 在真实设备上测试
选择合适的大列表渲染方案,需要根据实际的数据量、交互需求和目标设备来综合考虑。虚拟滚动是解决超大列表的核心技术,但也要注意其复杂性和维护成本。


