Vue 中如何实现虚拟滚动处理超长列表
一句话总结:上万条数据别硬渲染——虚拟滚动只渲染可视区域内的几十条,DOM 数量恒定,滚动再长的列表也不卡。
目录
- 虚拟滚动是什么
- 5 个高频使用场景
- 万能兜底方案
- 预防 Checklist
- 一句话总结
虚拟滚动是什么
直接渲染 10000 条列表项,浏览器要创建 10000 个 DOM 节点,内存和渲染时间都爆炸。虚拟滚动(Virtual Scroll)的思路是:
效果:无论列表有 1 万条还是 100 万条,DOM 数量恒定在可视区域的量级。
| 直接 v-for | 10000+ | 卡顿数秒 | 小列表(< 200 条) |
| 分页加载 | 每页几十条 | 快,但需翻页 | 后台管理系统 |
| 虚拟滚动 | 恒定 ~30 条 | 毫秒级 | 聊天记录、日志、大数据表格 |
<!– 虚拟滚动的核心结构 –>
<template>
<div class="container" @scroll="onScroll" :style="{ height: '600px', overflow: 'auto' }">
<!– 占位层:撑起总高度 –>
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<!– 渲染层:只包含可视区域的项,绝对定位偏移 –>
<div :style="{ transform: `translateY(${offsetY}px)` }">
<div v-for="item in visibleItems" :key="item.id">…</div>
</div>
</div>
</div>
</template>
5 个高频使用场景
场景一:直接渲染上万条数据导致页面卡死
后端一次性返回全部数据,前端直接 v-for 渲染。
❌ 错误写法:全量渲染
<template>
<div class="list">
<div v-for="item in list" :key="item.id" class="item">
<span>{{ item.name }}</span>
<span>{{ item.content }}</span>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const list = ref([]);
onMounted(async () => {
const res = await fetch('/api/logs'); // 返回 50000 条日志
list.value = res.data;
// 页面卡死 5-10 秒,内存占用飙升
});
</script>
✅ 正确写法:用第三方虚拟滚动库
npm install vue-virtual-scroller
// main.js
import { createApp } from 'vue';
import VirtualScroller from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
createApp(App).use(VirtualScroller).mount('#app');
<template>
<RecycleScroller
:items="list"
:item-size="60"
key-field="id"
v-slot="{ item }"
class="scroller"
>
<div class="item">
<span>{{ item.name }}</span>
<span>{{ item.content }}</span>
</div>
</RecycleScroller>
</template>
<style scoped>
.scroller {
height: 600px;
}
.item {
height: 60px;
display: flex;
align-items: center;
}
</style>
<script setup>
import { ref, onMounted } from 'vue';
const list = ref([]);
onMounted(async () => {
const res = await fetch('/api/logs'); // 50000 条日志
list.value = res.data;
// 秒开,DOM 恒定 30 个左右
});
</script>
场景二:不定高度列表的虚拟滚动
列表项高度不一(有的单行、有的多行),固定 item-size 的方案失效。
❌ 错误写法:用固定高度的虚拟滚动库
<!– item-size 只能填一个固定值,但实际每项高度不同 –>
<RecycleScroller :items="list" :item-size="60" key-field="id">
<!– 有的项 60px,有的项 200px,滚动定位全乱了 –>
</RecycleScroller>
✅ 正确写法:用支持动态高度的 DynamicScroller
<template>
<DynamicScroller :items="list" :min-item-size="54" key-field="id" class="scroller">
<template v-slot="{ item, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:data-index="item.id"
:size-dependencies="[item.content]"
>
<div class="item">
<p>{{ item.name }}</p>
<p>{{ item.content }}</p> <!– 高度随内容变化 –>
</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</template>
场景三:手写虚拟滚动(不用第三方库)
项目不想引依赖,自己实现一个基础版虚拟滚动。
❌ 错误写法:滚动事件里直接操作每一条数据
const onScroll = (e) => {
const scrollTop = e.target.scrollTop;
// 每次滚动都重新计算并渲染整个列表
visibleItems.value = list.value.map(/* … */);
// 滚动 60fps 触发 60 次,每次全量计算,卡成PPT
};
✅ 正确写法:计算可视窗口的起止索引
<template>
<div class="container" @scroll="onScroll">
<div class="placeholder" :style="{ height: totalHeight + 'px' }">
<div class="render-list" :style="{ transform: `translateY(${offsetY}px)` }">
<div v-for="item in visibleItems" :key="item.id" class="item">
{{ item.name }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
items: { type: Array, default: () => [] },
itemHeight: { type: Number, default: 60 },
containerHeight: { type: Number, default: 600 }
});
const scrollTop = ref(0);
// 总高度 = 条数 × 单项高度
const totalHeight = computed(() => props.items.length * props.itemHeight);
// 可视区域内能容纳的条数(上下多渲染 5 条缓冲)
const visibleCount = computed(() =>
Math.ceil(props.containerHeight / props.itemHeight) + 10
);
// 起始索引(向上多渲染 5 条缓冲)
const startIndex = computed(() =>
Math.max(0, Math.floor(scrollTop.value / props.itemHeight) – 5)
);
// 可视项列表
const visibleItems = computed(() =>
props.items.slice(startIndex.value, startIndex.value + visibleCount.value)
);
// 渲染层偏移量
const offsetY = computed(() => startIndex.value * props.itemHeight);
const onScroll = (e) => {
scrollTop.value = e.target.scrollTop;
};
</script>
<style scoped>
.container {
height: 600px;
overflow: auto;
position: relative;
}
.render-list {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.item {
height: 60px;
display: flex;
align-items: center;
border-bottom: 1px solid #eee;
}
</style>
场景四:滚动加载到底部自动请求下一页
虚拟滚动 + 无限加载结合——滚动到底部时自动加载更多数据。
❌ 错误写法:监听 scroll 判断是否到底
const onScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
// 没有防抖,滚动过程中疯狂触发请求
if (scrollTop + clientHeight >= scrollHeight) {
loadMore(); // ❌ 一次滚动触发多次请求
}
};
✅ 正确写法:loading 锁 + RecycleScroller 事件
<template>
<RecycleScroller
:items="list"
:item-size="60"
key-field="id"
class="scroller"
@reach-end="loadMore"
v-slot="{ item }"
>
<div class="item">{{ item.name }}</div>
</RecycleScroller>
</template>
<script setup>
import { ref } from 'vue';
const list = ref([]);
const page = ref(1);
const loading = ref(false);
const hasMore = ref(true);
const loadMore = async () => {
if (loading.value || !hasMore.value) return; // ✅ 防重复请求锁
loading.value = true;
try {
const res = await fetch(`/api/logs?page=${page.value}&size=1000`);
const data = await res.json();
list.value.push(…data.items);
hasMore.value = data.hasMore;
page.value++;
} finally {
loading.value = false;
}
};
// 初始加载
loadMore();
</script>
场景五:表格场景用专业虚拟表格库
后台管理系统的表格有固定列、排序、筛选等复杂需求,普通虚拟列表搞不定。
❌ 错误写法:用 el-table 渲染大数据
<!– Element Plus 的 el-table 全量渲染 DOM –>
<el-table :data="hugeTableData">
<el-table-column prop="name" label="名称" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="createdAt" label="创建时间" />
</el-table>
<!– 10000 行数据直接卡死浏览器 –>
✅ 正确写法:用 vxe-table 虚拟滚动模式
npm install vxe-table
<template>
<vxe-table
:data="hugeTableData"
height="600"
:scroll-y="{ enabled: true, gt: 100 }"
>
<vxe-column field="name" title="名称" />
<vxe-column field="status" title="状态" />
<vxe-column field="createdAt" title="创建时间" />
</vxe-table>
</template>
<script setup>
// gt: 100 表示超过 100 行自动启用虚拟滚动
// 固定列、排序、筛选都正常工作
</script>
万能兜底方案
选择决策流程:
列表数据量多大?
├─ < 200 条 → 直接 v-for,别过度优化
├─ 200 – 5000 条
│ ├─ 后台页面 → 分页加载
│ └─ 需要连续滚动 → 虚拟滚动
└─ > 5000 条 → 必须虚拟滚动
├─ 项高度固定 → RecycleScroller / 手写
├─ 项高度不定 → DynamicScroller
└─ 表格场景 → vxe-table 虚拟模式
通用 Hook 封装:
// useVirtualList.js
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
export function useVirtualList(getList, options = {}) {
const { itemHeight = 60, bufferSize = 5 } = options;
const containerRef = ref(null);
const scrollTop = ref(0);
const containerHeight = ref(600);
const list = computed(() => getList());
const totalHeight = computed(() => list.value.length * itemHeight);
const visibleItems = computed(() => {
const start = Math.max(0, Math.floor(scrollTop.value / itemHeight) – bufferSize);
const count = Math.ceil(containerHeight.value / itemHeight) + bufferSize * 2;
return list.value.slice(start, start + count);
});
const offsetY = computed(() => {
const start = Math.max(0, Math.floor(scrollTop.value / itemHeight) – bufferSize);
return start * itemHeight;
});
const onScroll = (e) => {
// requestAnimationFrame 防抖
requestAnimationFrame(() => {
scrollTop.value = e.target.scrollTop;
});
};
onMounted(() => {
containerHeight.value = containerRef.value?.clientHeight || 600;
});
return {
containerRef,
totalHeight,
visibleItems,
offsetY,
onScroll
};
}
预防 Checklist
| ☐ 列表项高度是否固定 | 固定用 RecycleScroller,不定用 DynamicScroller |
| ☐ key 是否用了唯一 id | 虚拟滚动对 key 唯一性要求更高,重复 key 会渲染错乱 |
| ☐ 滚动处理有没有节流 | 用 requestAnimationFrame 或 passive 监听,避免掉帧 |
| ☐ 无限加载有没有防重锁 | loading 标志位防止重复请求 |
| ☐ 是否真的需要虚拟滚动 | 小于 200 条纯属过度优化 |
| ☐ 表格场景有没有用专业库 | vxe-table / ant-design 的虚拟表格比手写更稳 |
| ☐ 动态高度有没有声明依赖 | size-dependencies 要列出影响高度的字段 |
一句话总结
虚拟滚动的本质是"占位层撑高度 + 只渲染可视区"——固定高度用 RecycleScroller,动态高度用 DynamicScroller,表格场景用 vxe-table,超小列表别折腾。记住 DOM 数量恒定,多少条数据都不怕。
最后问候亲爱的朋友们,并邀请你们阅读我的全新著作

📚 《Vue.js 3企业级项目开发实战(微课视频版》

