前言
如果你用过 Ant Design Vue 的 Table 组件渲染过上万条数据,你一定体验过那种"浏览器在思考人生"的感觉。页面卡顿、滚动掉帧,仿佛在对你说:"兄弟,我真的尽力了。"
问题的根源很简单:Ant Design Vue 的 Table 组件并不支持虚拟列表。当你塞给它 10 万条数据时,它会老老实实地把这 10 万个 DOM 节点全部渲染出来。浏览器:我谢谢你啊。
于是,这个项目诞生了------基于 Ant Design Vue 二次封装,让它也能享受虚拟列表的"涡轮增压"。
什么是虚拟列表?
简单来说,虚拟列表就是一种"障眼法"。用户看起来在滚动一个包含 10 万条数据的列表,但实际上浏览器只渲染了可视区域内的那几十条数据。当你滚动时,组件会动态计算应该显示哪些数据,然后快速替换 DOM。
这就像是火车车窗外的风景:你坐在车厢里,透过窗户看到的永远只是窗外那一小片景色,但随着火车移动,窗外的景色不断变化,给你一种穿越了整片大地的感觉。虚拟列表也是如此,可视区域就是那扇窗户,数据就是窗外的风景。
核心实现:站在巨人的肩膀上
这个项目的核心是 @vueuse/core 提供的 useVirtualList hook。VueUse 是一个优秀的 Vue 组合式 API 工具集,而 useVirtualList 就是其中专门用来实现虚拟列表的工具。
让我们看看关键代码:
vue
const { list, containerProps, wrapperProps } = useVirtualList(
computed(() => props.dataSource),
{
itemHeight: props.rowHeight,
overscan: 10,
},
)
必要参数解析
1. 数据源(第一个参数)
这里传入的是一个响应式的数据源。 computed(() => props.dataSource),当父组件传入的数据变化时,虚拟列表会自动更新。
2. itemHeight(行高)
这是虚拟列表的"灵魂参数"。它告诉 useVirtualList:"嘿,我的每一行有多高。"有了这个信息,它才能准确计算出:
- 可视区域能显示多少行
- 滚动到某个位置时应该显示哪些数据
- 整个列表的总高度是多少
重要提示:这个值必须尽可能准确。如果你设置的行高和实际渲染的行高不一致,滚动时就会出现"跳跃"或"错位"的问题。在我们的实现中,默认值是 55px,这是 Ant Design Vue Table 的默认行高。
3. overscan(预渲染数量)
这是一个性能优化参数。它的意思是:"除了可视区域的数据,我还要多渲染上下各 10 条数据。"
为什么要这么做?想象一下,如果只渲染可视区域的数据,当用户快速滚动时,新的数据可能来不及渲染,就会出现短暂的空白。通过预渲染一些数据,可以让滚动更加流畅。
当然,这个值也不能设置太大,否则就失去了虚拟列表的意义。10 是一个比较平衡的值。
返回值解析:虚拟列表的"三剑客"
useVirtualList 返回了三个关键对象,它们各司其职,共同完成虚拟列表的魔法。
1. list - 数据的"精选集"
这是当前应该渲染的数据列表。注意,这不是完整的数据源,而是经过计算后,当前可视区域(加上 overscan)应该显示的数据。
数据结构如下:
javascript
[
{ data: 原始数据, index: 在完整列表中的索引 },
{ data: 原始数据, index: 在完整列表中的索引 },
...
]
比如你有 10 万条数据,但 list 可能只包含 20-30 条数据,这就是虚拟列表的核心优势。
2. containerProps - 滚动的"指挥官"
这是绑定到外层容器的属性对象,它的结构大概是这样的:
javascript
{
ref: containerRef, // 容器的引用
onScroll: () => { // 滚动事件监听
calculateRange(); // 重新计算应该显示哪些数据
},
style: {
overflowY: "auto" // 允许垂直滚动
}
}
核心作用:
- ref:VueUse 需要获取容器的 DOM 引用,以便计算可视区域的大小和滚动位置
- onScroll :这是虚拟列表的"心跳"。每次滚动时,它会触发
calculateRange()函数,重新计算当前应该显示哪些数据 - style:确保容器可以滚动
在我们的实现中,这样使用:
vue
<div v-bind="containerProps" class="virtual-scroll-container">
<!-- 内容 -->
</div>
通过 v-bind 直接绑定,所有的属性和事件监听都会自动应用到容器上。
3. wrapperProps - 高度的"撑杆"
这是绑定到内层包裹元素的属性对象,它的结构是这样的:
javascript
{
width: "100%",
height: "5500000px", // 注意这个惊人的高度!
marginTop: "0px" // 动态调整,实现滚动偏移
}
为什么高度这么夸张?
假设你有 10 万条数据,每条高度 55px,那么完整列表的总高度就是:
ini
100000 × 55 = 5,500,000px = 5500000px
这个高度是计算出来的"虚拟高度"。虽然实际只渲染了几十条数据,但通过设置这个巨大的高度,可以让滚动条的长度和滚动范围与真实的 10 万条数据保持一致。
marginTop 的妙用:
当你滚动到列表中间时,比如滚动到第某条数据,marginTop 会动态调整为正值(比如 2915px),这样可以:
- 让当前渲染的数据显示在正确的位置
- 保持滚动条的位置准确
这就像是一个"移动的窗口",窗口内的内容在不断变化,但窗口的位置始终准确。通过动态调整 marginTop,VueUse 将实际渲染的少量数据"推"到了应该显示的位置,从而实现了虚拟滚动的效果。
在我们的实现中:
vue
<div v-bind="wrapperProps">
<a-table ... />
</div>
这个包裹层撑起了整个虚拟列表的"骨架",让浏览器以为真的有 10 万条数据在那里。
封装
1. 容器高度的控制
虚拟列表需要一个固定高度的容器来触发滚动。我们通过 CSS 变量动态绑定容器高度:
vue
<style scoped>
.virtual-scroll-container {
height: v-bind(containerHeight + 'px');
overflow-y: auto;
/* 其他样式... */
}
</style>
这样,父组件可以通过 container-height prop 灵活控制可视区域的高度。
2. 插槽的完整透传
为了保持 Ant Design Vue Table 的灵活性,我们需要把所有插槽都透传给内部的 Table 组件:
vue
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData || {}"></slot>
</template>
这样,父组件可以像使用原生 Table 一样使用自定义列、自定义单元格等功能。
3. 必须注意的细节
在使用这个组件时,有一个容易被忽略的细节:表格列的 ellipsis 必须设置为 true。
为什么?因为虚拟列表的行高是固定的,如果内容超出了单元格,就会撑高行高,导致计算错位。设置 ellipsis: true 可以确保内容超出时显示省略号,而不是换行。
javascript
const columns = [
{ title: '类型', key: 'type', width: 400, ellipsis: true },
// ...
]
性能对比
在测试页面中,我们生成了 10 万条数据:
javascript
const tableData = ref(generateMockData(100000))
如果用原生的 Ant Design Vue Table 渲染,浏览器会直接"去世"。但使用虚拟列表封装后,滚动依然丝滑流畅。
这就是虚拟列表的魅力:无论数据有多少,实际渲染的 DOM 节点永远只有那么几十个。
使用方式
使用这个组件非常简单,和原生 Table 几乎一样:
vue
<v-table
:data-source="tableData"
:columns="columns"
:row-height="55"
:container-height="600"
row-key="id"
/>
唯一的区别是多了两个参数:
row-height:行高,必须准确container-height:容器高度,决定可视区域大小
总结
这个项目的核心思路很简单:
- 借助 VueUse 的
useVirtualList实现虚拟列表逻辑 - 将虚拟列表的数据转换为 Ant Design Vue Table 需要的格式
- 通过 props 和插槽保持组件的灵活性
项目地址:ant-virtual-table