虚拟列表实现原理与优化方案
# Vue3 虚拟列表实现原理与优化方案
# 一、什么是虚拟列表
虚拟列表(Virtual List)是一种性能优化技术,用于处理大量数据的滚动展示场景。其核心思想是:
- 只渲染可见区域的元素,而非渲染全部数据
- 通过撑高容器保持滚动条功能正常
- 根据滚动位置动态替换可见区域内容
Without virtualization: 1000 items = 1000 DOM nodes
With virtualization: 1000 items = ~20 DOM nodes (visible only)
# 二、实现原理
# 2.1 核心架构
┌─────────────────────────────┐
│ 容器 (fixed height) │
│ ┌───────────────────────┐ │
│ │ Top Spacer │ │ ← height = 上方累计高度
│ │ (topSpacer) │ │
│ │ ┌─────────────┐ │ │
│ │ │ Item ... │ ←visible (normal flow)
│ │ └─────────────┘ │ │
│ │ ┌─────────────┐ │ │
│ │ │ Item ... │ ←visible (normal flow)
│ │ └─────────────┘ │ │
│ │ Bottom Spacer │ │ ← height = 下方累计高度
│ │ (bottomSpacer) │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 2.2 关键技术点
# 1. 高度撑开
使用三段占位(不依赖绝对定位):
- 外层容器:固定高度 +
overflow-y: auto,负责滚动 - Top Spacer:高度 = 起始元素之前的累计高度(把内容“顶”到正确的滚动位置)
- Bottom Spacer:高度 = 结束元素之后的累计高度(把滚动条“撑”到总高度)
<div class="virtual-list" ref="containerRef"> <!-- 滚动发生在这里 -->
<div :style="{ height: topSpacer + 'px' }"></div>
<div v-for="item in visibleItems" class="row">...</div>
<div :style="{ height: bottomSpacer + 'px' }"></div>
</div>
2
3
4
5
# 2. 元素渲染(不定位)
可见元素按正常文档流渲染(不需要为每个 item 计算 top),通过上/下 spacer 把滚动位置对齐到正确的内容区间。
# 3. 滚动计算
滚动时的计算流程:
1. 获取 scrollTop (当前滚动位置)
2. 从第 0 项开始累加高度,找到第一个 > scrollTop 的元素 → start index
3. 从 start index 开始累加高度,找到第一个 > clientHeight 的元素 → end index
4. 加 buffer(预渲染缓冲区)得到渲染区间 [start', end']
5. topSpacer = sum(0..start'-1)
6. bottomSpacer = totalHeight - topSpacer - sum(start'..end')
7. 渲染 [start', end'] 范围内的元素(正常流)
2
3
4
5
6
7
# 2.3 不固定高度处理
由于每个元素高度可能不同,无法使用固定高度计算。解决方案:
- 首次测量缓存:首次访问元素时,通过临时 DOM 测量真实高度
- 缓存到对象:将高度存储在
heights[index]中,避免重复测量
const calculateElementHeight = (index) => {
// 1. 优先使用缓存
if (heights.value[index] !== undefined) {
return heights.value[index];
}
// 2. 临时创建元素测量高度
const temp = document.createElement('div');
// ... 设置样式并测量
const height = temp.offsetHeight;
// 3. 缓存结果
heights.value[index] = height;
return height;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 三、代码实现
# 3.1 响应式数据
const containerRef = ref(null); // 滚动容器
const items = ref([]); // 全部数据
const visibleItems = ref([]); // 可见元素
const heights = ref({}); // 高度缓存
const totalHeight = ref(0); // 总高度
const topSpacer = ref(0); // 上方占位高度
const bottomSpacer = ref(0); // 下方占位高度
const bufferSize = 5; // 预渲染缓冲区
2
3
4
5
6
7
8
# 3.2 滚动处理函数
const handleScroll = () => {
const scrollTop = container.scrollTop;
const clientHeight = container.clientHeight;
// 1. 找到起始位置(基础范围)
let offset = 0;
for (let i = 0; i < items.length; i++) {
const h = calculateElementHeight(i);
if (offset + h > scrollTop) {
startBase = i;
break;
}
offset += h;
}
// 2. 找到结束位置(基础范围)
let visibleHeight = 0;
for (let i = startBase; i < items.length; i++) {
visibleHeight += calculateElementHeight(i);
if (visibleHeight > clientHeight) {
endBase = i;
break;
}
}
// 3. buffer 扩展渲染范围,避免白屏
const start = Math.max(0, startBase - bufferSize);
const end = Math.min(items.length - 1, endBase + bufferSize);
// 4. 计算三段占位高度
topSpacer = sumHeight(0, start - 1);
const visibleTotal = sumHeight(start, end);
bottomSpacer = totalHeight - topSpacer - visibleTotal;
// 5. 生成可见项列表(正常文档流渲染)
visibleItems = items.slice(start, end + 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
# 四、优化方案
# 4.1 性能优化
# 1. 函数节流
滚动事件触发频繁,添加节流避免频繁计算:
const throttle = (fn, delay) => {
let lastTime = 0;
return () => {
const now = Date.now();
if (now - lastTime > delay) {
lastTime = now;
fn();
}
};
};
const handleScroll = throttle(() => {
// ... 滚动处理逻辑
}, 50); // 50ms 节流
2
3
4
5
6
7
8
9
10
11
12
13
14
# 2. 懒加载测量
不预先测量所有高度,而是在需要时才测量:
// 初始化时不调用 calculateTotalHeight
// 而是在 handleScroll 中按需计算
2
# 3. 回收机制
当元素滚动出视口后,可以延迟销毁(可选):
// 可以考虑保留最近访问的元素缓存
// 既保证性能,又避免内存浪费
2
# 4.2 用户体验优化
# 1.骨架屏
数据加载时显示骨架屏:
<div v-if="!items.length" class="skeleton">
<!-- 骨架屏内容 -->
</div>
2
3
# 2. 预渲染缓冲区
多渲染几个额外元素,避免滚动时出现白屏:
// 原始计算
const bufferSize = 5; // 缓冲区大小
for (let i = start - bufferSize; i <= end + bufferSize; i++) {
if (i >= 0 && i < items.length) {
result.push(items[i]);
}
}
2
3
4
5
6
7
# 3. 平滑过渡
添加动画效果:
.virtual-list-item {
transition: transform 0.1s ease-out;
}
2
3
# 4.3 内存优化
# 1. 高度缓存策略
- 超过一定数量后,清除旧缓存
- 使用 LRU 策略保留热点数据
# 2. 数据分页
超大数据集考虑分页加载:
const loadMore = () => {
// 滚动到底部时加载更多
if (scrollTop + clientHeight > totalHeight - threshold) {
// 加载下一页
}
};
2
3
4
5
6
# 4.4 精确性优化
# 1. 窗口 resize 处理
监听窗口大小变化,重新计算高度:
onMounted(() => {
window.addEventListener('resize', handleScroll);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', handleScroll);
});
2
3
4
5
6
7
# 五、性能对比
| 方案 | 1000 条数据 | 10000 条数据 |
|---|---|---|
| 普通渲染 | ~50ms | ~500ms |
| 虚拟列表 | ~5ms | ~10ms |
DOM 节点数量对比:
- 普通渲染:1000+ 个 DOM 节点
- 虚拟列表:约 20-30 个 DOM 节点(仅可见区域)
# 六、适用场景
# 适合使用虚拟列表
- 聊天记录展示
- 消息列表
- 商品列表
- 数据表格
# 不适合虚拟列表
- 元素高度完全不确定(如富文本)
- 需要频繁插入/删除中间元素
- 元素数量较少(< 50)
# 七、使用 Canvas 实现虚拟列表
Canvas 版本的“虚拟列表”本质仍然是 windowing:只渲染可视区。区别在于渲染目标不再是 DOM,而是在 canvas 上绘制文本/图形。
# 7.1 核心架构
- 滚动容器仍用 DOM:一个固定高度的
div(overflow: auto)负责滚动条与惯性滚动体验 - Spacer 撑总高度:在滚动容器内放一个
spacer,高度 (totalHeight) 用来让滚动条长度和“全量列表”一致 - Canvas 视口层:把
canvas作为“视口渲染层”(常见做法是position: sticky; top: 0固定在容器顶部),滚动时只重绘可视区
示意结构:
<div class="viewport" style="height: 500px; overflow: auto">
<div class="spacer" style="height: totalHeightpx">
<canvas class="canvas-layer"></canvas> <!-- 只画可视区 -->
<div class="overlay"></div> <!-- 透明层:接收点击/hover 做命中测试 -->
</div>
</div>
2
3
4
5
6
# 7.2 核心计算(固定高度版本)
固定行高是 Canvas 虚拟列表里性能最好、复杂度最低的方案:
- (startBase = \lfloor scrollTop / rowHeight \rfloor)
- (visibleCount = \lceil viewportHeight / rowHeight \rceil)
- 加 buffer:
start = max(0, startBase - buffer),end = min(n-1, startBase + visibleCount + buffer)
绘制坐标(把“全量列表坐标”转换成“视口坐标”):
y = i * rowHeight - scrollTop
# 7.3 不固定高度怎么办
Canvas 也可以做不固定高度,但复杂度和 DOM 虚拟列表一样,关键仍是:
- 高度缓存:
heights[i] - 前缀和:
prefix[i] = sum(0..i-1) - 用
scrollTop在prefix上 二分查找 找到start
注意:Canvas 不像 DOM 能“天然撑开每行高度”,你仍然要把不固定高度折算成可计算的累计高度体系。
# 7.4 注意点(很容易踩坑)
- 清晰度(DPR):必须按
devicePixelRatio放大 canvas 的内部像素并setTransform(dpr,0,0,dpr,0,0),否则文字会糊 - 命中测试:Canvas 没有 DOM 事件目标,要自己把鼠标坐标映射到行/列(例如
index = floor((scrollTop + y) / rowHeight)) - 文本裁剪/省略:需要自己处理截断、换行、测量
measureText,复杂文本成本很高 - 无障碍/可选中复制:Canvas 天然不具备可访问性语义和文本选择,需要额外做 ARIA 镜像层或放弃
- 图片/字体加载抖动:资源异步加载会影响测量与绘制,需要在加载完成后触发重绘
# 7.5 优化方案(Canvas 版本)
- 用 rAF 合帧:滚动事件里不要直接
draw,用requestAnimationFrame合并多次滚动回调,避免过度重绘 - 减少绘制量:只画
[start..end],并在区间不变时跳过重绘(可选) - 离屏缓存:对不变的元素(如图标/固定背景)用 offscreen canvas 预渲染,主画布直接
drawImage - 脏矩形(dirty rect):如果只是 hover/选中变化,优先只重绘受影响行,而不是整屏
clearRect - 分层绘制:背景层、文本层、选中层拆开,减少每帧需要重新绘制的内容
# 7.6 为什么 Canvas 更适合“超大数据集 + 样式简单”
Canvas 的优势来自“渲染模型”的差异:DOM 的强项是复杂布局与交互语义,但代价是持续维护样式/布局树;Canvas 则是把可视区当成一张画布,每帧按需重绘。
- 渲染成本更可控:虚拟化后的 DOM 每行仍可能包含多个节点与 CSS 规则匹配,滚动时容易在“样式计算/布局/绘制”上产生固定开销;Canvas 通常是“清屏 + 画 N 行”,开销更接近线性、可预测。
- 数据越大,结构性开销越明显:当数据量很大、滚动很频繁时,DOM 的布局树维护与回流压力更容易成为瓶颈;Canvas 没有布局树,很多结构性成本天然不存在。
- 样式简单时,Canvas 的缺点不显著:固定行高、简单文本、少量图标/高亮,这类内容用 Canvas 绘制很直接;但一旦需要复杂排版(自动换行、富文本、嵌套布局、图文混排),Canvas 需要自研布局与文本处理,复杂度会快速上升。
- 交互简单更划算:DOM 天生支持文本选择复制、表单控件、无障碍、焦点/输入法等;Canvas 这些要额外实现(命中测试、语义镜像层),因此更适合“展示型大列表”(日志、监控、行情、简单表格)。
# 八、参考实现
- Vue Virtual Scroll List (opens new window)
- React Virtualized (opens new window)
- TanStack Table (opens new window) - 支持虚拟滚动的表格库
# 九、面试口语版(怎么讲虚拟列表)
我一般会先说场景:列表数据很多时,如果一次性渲染所有 DOM,会导致首屏慢、滚动卡、内存占用高。虚拟列表的核心就是 只渲染可视区附近的少量元素,其它元素不进 DOM(或者不绘制)。
实现上我常用 三段占位:在滚动容器里放一个 top spacer、中间放“可见项 + buffer”、底部放一个 bottom spacer。
top spacer 的高度等于起始元素之前的累计高度,bottom spacer 等于总高度减去当前渲染区间的高度,这样滚动条长度和滚动位置都和“全量列表”一致,但 DOM 数量始终很少。
不固定高度时,关键是 高度缓存:每个 item 的真实高度测一次存起来(比如用临时 DOM/或 ResizeObserver),然后通过累计高度去定位 start/end。性能上会进一步做 前缀和 + 二分查找 来快速从 scrollTop 找到起始索引,并加上 buffer 预渲染 防止快速滚动白屏;滚动事件会 节流/requestAnimationFrame,窗口 resize 或内容变化会触发重新计算。
同样的思想也可以用 Canvas 来做:滚动条仍然交给 DOM(spacer 撑出 totalHeight),但是可视区内容不再创建 DOM,而是在 canvas 上按 start/end 把可见行画出来,绘制坐标一般是 y = i * rowHeight - scrollTop(固定行高时定位是 O(1))。
Canvas 版本的取舍我会补一句:它更适合“展示型、超大数据量、样式相对简单”的列表;但点击/hover 需要自己做命中测试,文字省略/换行、无障碍、复制选择这些都要额外成本,同时要注意 DPR 缩放保证清晰度,滚动绘制用 requestAnimationFrame 合帧减少重绘压力。