前端开发攻略---vue3长列表性能优化终极指南:虚拟滚动、分页加载、时间分片等6种方案详解与代码实现

方案一:虚拟滚动(最推荐、效果最明显)

虚拟滚动的核心思想是:无论列表有多长,只渲染用户可视区域的那几条数据。 当用户滚动时,动态计算并更新渲染的数据。

在 Vue 3 中,推荐使用 vue-virtual-scroller@vueuse/core 结合组合式 API 手动实现。

1. 使用第三方库:vue-virtual-scroller

这是一个专门用于虚拟滚动的库,适配 Vue 3。

  • 安装

    javascript 复制代码
    npm install vue-virtual-scroller@next
    # 或者
    yarn add vue-virtual-scroller@next
  • 引入样式和组件

    main.js 中全局引入或直接在组件内引入。

    javascript 复制代码
    // main.js
    import { createApp } from 'vue'
    import VueVirtualScroller from 'vue-virtual-scroller'
    import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' // 必须引入样式
    
    import App from './App.vue'
    
    const app = createApp(App)
    app.use(VueVirtualScroller)
    app.mount('#app')
  • 组件中使用

    javascript 复制代码
    <template>
      <div class="demo-container">
        <!-- RecycleListScroller: 复用DOM,性能最佳 -->
        <RecycleListScroller
          class="scroller"
          :items="list"
          :item-size="50"
          key-field="id"
          v-slot="{ item }"
        >
          <div class="item">
            <span>ID: {{ item.id }} - 内容: {{ item.content }}</span>
          </div>
        </RecycleListScroller>
      </div>
    </template>
    
    <script setup>
    import { ref } from 'vue';
    
    // 生成1万条测试数据
    const list = ref(Array.from({ length: 10000 }, (_, index) => ({
      id: index,
      content: `这是第 ${index} 条数据`
    })));
    </script>
    
    <style scoped>
    .demo-container {
      height: 500px; /* 父容器必须有固定高度 */
      border: 1px solid #ccc;
    }
    .scroller {
      height: 100%;
    }
    .item {
      height: 50px; /* 必须与 item-size 一致 */
      line-height: 50px;
      padding: 0 10px;
      border-bottom: 1px solid #eee;
    }
    </style>

    关键点

    • 必须设置容器高度。

    • :item-size 必须等于每个列表项的高度(如果是动态高度,需要使用 DynamicScroller)。

2. 手动实现简易虚拟滚动(理解原理)

如果不想引入第三方库,可以使用 @vueuse/core 结合滚动事件手动实现。

javascript 复制代码
<template>
  <div
    ref="containerRef"
    class="virtual-list-container"
    @scroll="handleScroll"
  >
    <!-- 占位元素,用于撑开滚动条 -->
    <div :style="{ height: totalHeight + 'px' }"></div>

    <!-- 绝对定位的可见区域 -->
    <div
      class="virtual-list-phantom"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div
        v-for="item in visibleData"
        :key="item.id"
        class="list-item"
        :style="{ height: itemSize + 'px' }"
      >
        {{ item.content }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue';

const props = defineProps({
  listData: { type: Array, default: () => [] },
  itemSize: { type: Number, default: 50 }, // 每个项目固定高度
  containerHeight: { type: Number, default: 500 } // 容器高度
});

// 生成数据
const allData = ref(Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  content: `Item ${i}`
})));

const containerRef = ref(null);
const scrollTop = ref(0);

// 计算总高度
const totalHeight = computed(() => allData.value.length * props.itemSize);

// 计算可见区域显示的数量
const visibleCount = computed(() =>
  Math.ceil(props.containerHeight / props.itemSize)
);

// 计算起始索引
const startIndex = computed(() =>
  Math.floor(scrollTop.value / props.itemSize)
);

// 计算结束索引(多渲染几个作为缓冲,防止白屏)
const endIndex = computed(() =>
  Math.min(
    startIndex.value + visibleCount.value + 2, // +2 作为缓冲
    allData.value.length
  )
);

// 偏移量,让列表永远紧贴顶部
const offsetY = computed(() => startIndex.value * props.itemSize);

// 实际渲染的数据
const visibleData = computed(() =>
  allData.value.slice(startIndex.value, endIndex.value)
);

// 滚动事件处理
const handleScroll = (e) => {
  scrollTop.value = e.target.scrollTop;
};
</script>

<style scoped>
.virtual-list-container {
  height: v-bind(containerHeight + 'px');
  overflow-y: auto;
  position: relative;
  border: 1px solid #ccc;
}
.virtual-list-phantom {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}
.list-item {
  display: flex;
  align-items: center;
  border-bottom: 1px solid #f0f0f0;
  padding: 0 10px;
  background-color: white;
}
</style>

方案二:分页加载(传统但有效)

如果长列表的目的就是让用户浏览全部数据,但用户通常只看前几页,可以使用分页 + 懒加载(上拉加载更多)。

javascript 复制代码
<template>
  <div class="infinite-list" @scroll="handleScroll">
    <div v-for="item in displayedData" :key="item.id" class="list-item">
      {{ item.content }}
    </div>

    <!-- 加载中状态 -->
    <div v-if="loading" class="loading">加载中...</div>

    <!-- 没有更多了 -->
    <div v-if="!hasMore" class="no-more">没有更多数据了</div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue';

// 原始数据(假设有1万条)
const allData = ref(Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  content: `Item ${i}`
})));

// 分页状态
const pageSize = 20;
const currentPage = ref(1);
const loading = ref(false);

// 当前渲染的数据(计算属性,只显示当前页的数据)
const displayedData = computed(() => {
  return allData.value.slice(0, currentPage.value * pageSize);
});

// 是否还有更多
const hasMore = computed(() => displayedData.value.length < allData.value.length);

// 模拟加载更多
const loadMore = () => {
  if (loading.value || !hasMore.value) return;
  loading.value = true;

  // 模拟异步加载
  setTimeout(() => {
    currentPage.value++;
    loading.value = false;
  }, 300);
};

// 滚动到底部加载
const handleScroll = (e) => {
  const { scrollTop, scrollHeight, clientHeight } = e.target;
  if (scrollTop + clientHeight >= scrollHeight - 10) {
    loadMore();
  }
};
</script>

<style scoped>
.infinite-list {
  height: 500px;
  overflow-y: auto;
  border: 1px solid #ccc;
}
.list-item {
  height: 50px;
  line-height: 50px;
  border-bottom: 1px solid #eee;
  padding: 0 10px;
}
.loading, .no-more {
  text-align: center;
  padding: 10px;
  color: #999;
}
</style>

方案三:使用 v-oncekeep-alive(静态数据优化)

如果列表项中大部分内容是不变的(静态文案、图片链接),可以使用 v-once 指令,让 Vue 只渲染一次,之后视为静态内容跳过更新。

html 复制代码
<template>
  <div v-for="item in hugeList" :key="item.id" v-once>
    <!-- 这部分内容不会随数据变化而更新 -->
    <img :src="item.img" alt="static">
    <span>{{ item.name }}</span> 
    <!-- 注意:如果 item.name 后续会变,这里不会更新,需慎用 -->
  </div>
</template>

适用场景:日志展示、历史记录、静态配置项等。


方案四:使用 shallowRef / shallowReactive(减少深层响应式监听)

Vue 3 的响应式系统默认是深度的。对于长列表,如果我们只关心列表整体替换,而不关心列表内某个对象属性的变化,可以使用 浅层响应式

html 复制代码
<script setup>
import { shallowRef } from 'vue';

// 使用 shallowRef 替代 ref
// allData.value 的变化会被监听到,但 allData.value[0].name 的变化不会被监听到
const allData = shallowRef([]);

// 获取数据
const fetchData = async () => {
  const res = await fetch('/api/long-list');
  const data = await res.json();
  
  // 整体替换,触发视图更新
  allData.value = data;
};

// 如果某个子项内部的属性变化,Vue 不会自动检测
// 如果需要更新某一项,必须整体替换数组或对象
const updateItem = (index, newItem) => {
  const newArray = [...allData.value];
  newArray[index] = newItem;
  allData.value = newArray; // 触发更新
};

fetchData();
</script>

优点 :省去了对一万个对象内部属性的 getter/setter 初始化开销,内存占用更低,初始化更快。


方案五:时间分片(requestIdleCallback

如果必须一次性渲染大量 DOM(例如报表导出预览),为了避免阻塞主线程导致页面卡死,可以将渲染任务拆分成多个小任务,在浏览器空闲时执行。

注意:Vue 3 的异步渲染队列已经做了一些优化,但手动分片可以进一步优化。

html 复制代码
<template>
  <div>
    <div v-for="item in renderedItems" :key="item.id">
      {{ item.content }}
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';

const allItems = ref([]);
const renderedItems = ref([]);

onMounted(() => {
  // 假设有 10w 条数据
  const bigData = Array.from({ length: 100000 }, (_, i) => ({ id: i, content: `Item ${i}` }));
  allItems.value = bigData;

  // 分片渲染
  const total = bigData.length;
  const chunkSize = 100; // 每次渲染 100 条
  let index = 0;

  function appendChunk() {
    if (index >= total) return;

    // 使用 requestIdleCallback 在空闲时执行,或者使用 setTimeout
    // 这里使用 setTimeout 兼容性更好
    setTimeout(() => {
      const chunk = bigData.slice(index, index + chunkSize);
      renderedItems.value.push(...chunk);
      index += chunkSize;
      appendChunk(); // 递归处理下一块
    }, 0); // 或者使用 requestIdleCallback
  }

  appendChunk();
});
</script>

综合对比与选择建议

方案 适用场景 复杂度 性能提升
虚拟滚动 极长列表(1000+),需要流畅滚动 ⭐⭐⭐⭐⭐
分页加载 列表不是必须一次性展示完,适合移动端 ⭐⭐⭐
v-once 静态内容、纯展示列表 ⭐⭐
shallowRef 数据只整体替换,不修改内部属性 ⭐⭐⭐
时间分片 初始化时需要渲染巨量DOM,且必须全渲染 ⭐⭐⭐

最佳实践组合

  1. 首选虚拟滚动 + shallowRef。既能保证滚动流畅,又能降低响应式系统开销。

  2. 如果业务无法接受虚拟滚动(例如需要 SEO 或需要精确的高度计算),退而求其次使用分页加载

相关推荐
未完成的歌~2 小时前
前端 AJAX 详解 + 动态页面爬虫实战思路
前端·爬虫·ajax
Mintopia2 小时前
时间源不统一 + 网络延迟 + 客户端时钟偏移
前端·架构
不甜情歌2 小时前
拆解JS原型核心:显式原型(prototype)+ 隐式原型(__proto__)+原型链,解锁JS继承的关键密码
前端·javascript
香草泡芙2 小时前
解锁AI Agent潜能:基于Langchain组件库的落地指南(2)
前端·javascript·人工智能
wuhen_n2 小时前
函数式组件 vs 有状态组件:何时使用更高效?
前端·javascript·vue.js
小码哥_常2 小时前
Kotlin开发秘籍:解锁Android编程新姿势
前端
ETA82 小时前
页面卡顿的元凶:可能是你没搞懂事件循环(面试可用)
前端·浏览器
毛骗导演2 小时前
OpenClaw 技能系统源码解析:一个 Markdown 文件是怎么变成 AI 的能力的
前端·架构