Kc's blog Kc's blog
首页
分类
标签
Timeline
收藏夹
关于
GitHub (opens new window)

kcqingfeng

前端小学生
首页
分类
标签
Timeline
收藏夹
关于
GitHub (opens new window)
  • 提前下班小妙招

  • node版本的切换

  • 面试

    • 面试问答
    • 面试随记
    • googleNotebooklm
    • 虚拟列表实现原理与优化方案
      • 一、什么是虚拟列表
      • 二、实现原理
        • 2.1 核心架构
        • 2.2 关键技术点
        • 2.3 不固定高度处理
      • 三、代码实现
        • 3.1 响应式数据
        • 3.2 滚动处理函数
      • 四、优化方案
        • 4.1 性能优化
        • 4.2 用户体验优化
        • 4.3 内存优化
        • 4.4 精确性优化
      • 五、性能对比
      • 六、适用场景
        • 适合使用虚拟列表
        • 不适合虚拟列表
      • 七、使用 Canvas 实现虚拟列表
        • 7.1 核心架构
        • 7.2 核心计算(固定高度版本)
        • 7.3 不固定高度怎么办
        • 7.4 注意点(很容易踩坑)
        • 7.5 优化方案(Canvas 版本)
        • 7.6 为什么 Canvas 更适合“超大数据集 + 样式简单”
      • 八、参考实现
      • 九、面试口语版(怎么讲虚拟列表)
  • 收藏
  • 面试
kc_shen
2026-04-07
目录

虚拟列表实现原理与优化方案

# 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)       │  │
│  └───────────────────────┘  │
└─────────────────────────────┘
1
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>
1
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'] 范围内的元素(正常流)
1
2
3
4
5
6
7

# 2.3 不固定高度处理

由于每个元素高度可能不同,无法使用固定高度计算。解决方案:

  1. 首次测量缓存:首次访问元素时,通过临时 DOM 测量真实高度
  2. 缓存到对象:将高度存储在 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;
};
1
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;                // 预渲染缓冲区
1
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);
};
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 节流
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 2. 懒加载测量

不预先测量所有高度,而是在需要时才测量:

// 初始化时不调用 calculateTotalHeight
// 而是在 handleScroll 中按需计算
1
2

# 3. 回收机制

当元素滚动出视口后,可以延迟销毁(可选):

// 可以考虑保留最近访问的元素缓存
// 既保证性能,又避免内存浪费
1
2

# 4.2 用户体验优化

# 1.骨架屏

数据加载时显示骨架屏:

<div v-if="!items.length" class="skeleton">
  <!-- 骨架屏内容 -->
</div>
1
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]);
  }
}
1
2
3
4
5
6
7

# 3. 平滑过渡

添加动画效果:

.virtual-list-item {
  transition: transform 0.1s ease-out;
}
1
2
3

# 4.3 内存优化

# 1. 高度缓存策略

  • 超过一定数量后,清除旧缓存
  • 使用 LRU 策略保留热点数据

# 2. 数据分页

超大数据集考虑分页加载:

const loadMore = () => {
  // 滚动到底部时加载更多
  if (scrollTop + clientHeight > totalHeight - threshold) {
    // 加载下一页
  }
};
1
2
3
4
5
6

# 4.4 精确性优化

# 1. 窗口 resize 处理

监听窗口大小变化,重新计算高度:

onMounted(() => {
  window.addEventListener('resize', handleScroll);
});

onBeforeUnmount(() => {
  window.removeEventListener('resize', handleScroll);
});
1
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>
1
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 合帧减少重绘压力。

编辑 (opens new window)
上次更新: 2026/04/07, 14:04:00
googleNotebooklm

← googleNotebooklm

最近更新
01
googleNotebooklm
04-07
02
面试随记
04-07
03
claude安装终端代理
04-06
更多文章>
Theme by Vdoing | Copyright © 2019-2026 kc shen | MIT License 豫ICP备2024074563号-3
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式