虚拟滚动实现表格渲染百万数据

虚拟滚动的实现,主要依靠三个“魔法”技巧,我们来一一拆解

魔法一:制造“假”的滚动条 (The Phantom / 幻影)

思考:如果我只渲染 10 条数据,那滚动条就会很短,用户怎么会感觉有一百万条数据呢?

解决方法:这就是 virtual-list-phantom (幻影元素)的作用。
我们先计算出“如果一百万条数据全部渲染出来,会有多高?”。这个计算很简单:总高度 = 单项高度 × 总数量 (例如: 40px * 1,000,000 = 40,000,000px)。然后,我们在页面上创建一个看不见的 div(就是这个幻影),强行把它的高度设置为这个巨大的计算值(4000万像素)。效果:这个巨大的、看不见的幻影 div 会把滚动容器撑开,浏览器看到这么高的内容,就会自动生成一个又细又长的、看起来能滚动很久的滚动条。至此,第一步成功了:我们从视觉上欺骗了用户,让他感觉内容真的有很多。

魔法二:监听滚动,实时计算 (The Calculator)

思考:用户开始滚动了,我怎么知道他现在想看哪几条数据呢?

解决方法:我们给滚动容器绑定一个 @scroll 事件。这个事件会实时告诉我们一个关键信息:scrollTop,也就是**“用户已经从顶部向下滚动了多少距离”**。得到 scrollTop 后,我们就能进行核心计算了。比如,用户向下滚动了 800px,而我们知道每一项的高度是 40px。开始的索引 (startIndex) = 滚动距离 / 单项高度 = 800px / 40px = 20。Aha! 我们立刻就算出,用户现在想看的是第 20 条数据。接着,我们再根据可视区域的高度,算出屏幕上大概能显示多少条,比如能显示 15 条。那么 结束的索引 (endIndex) = 20 + 15 = 35。至此,第二步成功了:我们知道了当前应该在屏幕上显示从第 20 条到第 35 条的数据。 我们会从一百万条总数据中,用 slice 方法把这一小片数据切出来,存到 visibleData 里。

魔法三:精确定位,无缝衔接 (The Transformer)

思考:我已经拿到了第 20 到 35 条数据,怎么把它们放到正确的位置上,让用户感觉滚动是平滑的呢?

解决方法:这就是 transform: translateY() 的舞台了。我们有一个专门用于渲染真实内容的 div 或 table(就是 .virtual-list-content)。
我们需要计算这个内容区应该向上偏移多少。这个偏移量(startOffset)其实就是已经被滚出屏幕上方那部分内容的总高度。偏移量 (startOffset) = 开始的索引 × 单项高度 = 20 * 40px = 800px。然后,我们给内容区设置 CSS 样式 transform: translateY(800px)。
效果:这会将我们当前渲染的这 15 条数据,精确地向下推 800px,正好出现在用户滚动到的位置。从用户的视角看,他向下滚动了 800px,然后内容就完美地出现在了那里,整个过程天衣无缝。

逻辑总结 (一句话流程)

用一个“假”的超高元素撑出滚动条 → 监听滚动位置 → 计算出该位置应该显示哪几条数据 → 只把这几条数据渲染出来,并用 transform 把它们推到正确的位置上

直接上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
<template>
<div class="virtual-list-container" ref="containerRef" @scroll="handleScroll">
<table class="sticky-header-table" ref="headerRef">
<colgroup>
<col v-for="column in columns" :key="column.key" :style="{ width: column.width + 'px' }" />
</colgroup>
<thead>
<tr>
<th v-for="column in columns" :key="column.key">
{{ column.title }}
</th>
</tr>
</thead>
</table>

<div class="virtual-list-body">
<div class="virtual-list-phantom" :style="{ height: phantomHeight + 'px' }"></div>

<table class="virtual-table-content" :style="{ transform: `translateY(${startOffset}px)` }">
<colgroup>
<col v-for="column in columns" :key="column.key" :style="{ width: column.width + 'px' }" />
</colgroup>
<tbody>
<tr
class="virtual-list-item"
v-for="item in visibleData"
:key="item.id"
:style="{ height: itemHeight + 'px' }"
>
<td v-for="column in columns" :key="column.key">
{{ item[column.key] }}
</td>
</tr>
</tbody>
</table>
</div>

</div>
</template>

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

// --- 组件的输入属性 (Props) ---
const props = defineProps({
// 全部列表数据 (例如一百万条)
allData: { type: Array, required: true },
// 每一项的固定高度,这是计算的基础
itemHeight: { type: Number, default: 40 },
// 用于定义表格列的配置数组
columns: { type: Array, required: true },
});


// --- 核心响应式状态 (Refs) ---

// 用于获取 DOM 元素的引用
const containerRef = ref(null); // 指向最外层滚动容器
const headerRef = ref(null); // 指向固定的表头表格

// 核心的动态数据
const scrollTop = ref(0); // 记录用户已经滚动了多少距离 (px)
const containerHeight = ref(0); // 记录可视区域的高度 (px)


// --- 核心计算属性 (Computed) - 这里是所有魔法的计算逻辑 ---

// 计算幻影元素应该有的总高度
const phantomHeight = computed(() => props.allData.length * props.itemHeight);

// 根据滚动距离,计算当前可视区顶部的项目索引
const startIndex = computed(() => Math.floor(scrollTop.value / props.itemHeight));

// 为了防止快速滚动时出现白屏,我们在可视区域的上下方额外渲染一些数据作为“缓冲区”
const bufferCount = 5;

// 计算总共需要渲染多少个 DOM 节点(可视区数量 + 上下缓冲区数量)
const visibleItemCount = computed(() => {
return Math.ceil(containerHeight.value / props.itemHeight) + bufferCount * 2;
});

// 计算考虑了上方缓冲区后,我们应该从总数据中截取的真正开始索引
const effectiveStartIndex = computed(() => {
// 保证 startIndex 不会是负数
return Math.max(0, startIndex.value - bufferCount);
});

// 计算截取数据的结束索引
const endIndex = computed(() => {
return effectiveStartIndex.value + visibleItemCount.value;
});

// 从全部数据中,切片出当前需要渲染的一小部分数据
const visibleData = computed(() => {
return props.allData.slice(effectiveStartIndex.value, endIndex.value);
});

// 计算真实内容区应该向下偏移的距离(px),以保证它出现在正确的位置
const startOffset = computed(() => {
// 偏移量 = 真实开始的索引 * 单项高度
return effectiveStartIndex.value * props.itemHeight;
});


// --- 事件处理 ---

// 滚动事件的处理函数
function handleScroll(event) {
// 当用户滚动时,从事件对象中获取最新的 scrollTop 值,并更新我们的响应式状态
scrollTop.value = event.target.scrollTop;
}


// --- 生命周期钩子 ---

onMounted(() => {
// 当组件被挂载到页面上后,我们需要获取一些元素的实际尺寸
if (containerRef.value && headerRef.value) {
// 获取表头的实际高度
const headerHeight = headerRef.value.clientHeight;
// 计算出真正可用于滚动的内容区域的高度 = 容器总高度 - 表头的高度
containerHeight.value = containerRef.value.clientHeight - headerHeight;
}
});
</script>

<style scoped>
/* 最外层滚动容器 */
.virtual-list-container {
height: 100%;
width: 100%;
overflow: auto; /* 这是产生滚动条的原因 */
}

/* 固定表头的表格 */
.sticky-header-table {
position: sticky; /* 核心CSS属性,实现“粘性”定位 */
top: 0; /* 当滚动到顶部时,粘在 top: 0 的位置 */
left: 0;
z-index: 10; /* 提高层级,确保它在滚动内容之上 */
width: 100%;
background-color: #ffffff; /* 设置背景色,防止下方滚动的内容透过来 */
border-collapse: collapse;
}

.sticky-header-table th {
background-color: #f5f7fa;
padding: 8px 12px;
text-align: left;
border-bottom: 2px solid #e0e0e0;
}

/* 滚动内容的主体区域 */
.virtual-list-body {
position: relative; /* 作为内部绝对定位元素的定位父级 */
}

/* 幻影元素,用于撑开滚动条 */
.virtual-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1; /* 把它藏在最底层,我们不需要看见它 */
}

/* 真实渲染内容的表格 */
.virtual-table-content {
position: absolute; /* 绝对定位,脱离文档流,以便用 transform 控制位置 */
top: 0;
left: 0;
width: 100%;
border-collapse: collapse;
}

/* 列表项(表格行)的样式 */
.virtual-list-item td {
padding: 8px 12px;
border-bottom: 1px solid #eee;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<template>
<div class="app-container">
<VirtualTable
:all-data="tableData"
:columns="columns"
:item-height="45"
/>
</div>
</template>

<script setup>
import { ref } from 'vue';
import VirtualTable from "@/components/VirtualList.vue";

// 1. 定义表格的列
const columns = ref([
{ key: 'id', title: 'ID', width: 100 },
{ key: 'name', title: '姓名', width: 200 },
{ key: 'email', title: '邮箱', width: 300 },
{ key: 'address', title: '地址', width: 400 },
]);

// 2. 生成十万条模拟数据
const tableData = ref([]);
for (let i = 0; i < 100000; i++) {
tableData.value.push({
id: i,
name: `用户-${i}`,
email: `user_${i}@example.com`,
address: `虚拟城市虚拟街道 ${i} 号`,
});
}
</script>

<style>
.app-container {
height: 600px;
width: 1000px;
border: 1px solid #ccc;
margin: 20px;
}
</style>