虚拟列表怎实现?

访客 性能优化 2

从原理到高并发优化实战

目录导读

  1. 什么是虚拟列表?为何需要它?
  2. 虚拟列表的核心实现原理
  3. 三步手写一个基础虚拟列表
  4. 性能优化关键点:滚动、缓存与复用
  5. 常见问题与解决方案(含问答)
  6. 企业级实践:动态高度、分组与空状态处理

什么是虚拟列表?为何需要它?

1 痛点场景

假设你需要渲染一个包含 10万条数据 的商品列表,如果直接使用 v-formap 渲染全部 DOM,浏览器会:

  • 卡顿数秒:页面无法滚动、点击无响应。
  • 内存飙升:DOM 节点数达到数万个,占用几百 MB 内存。
  • 用户体验极差:尤其移动端低端设备直接崩溃。

2 虚拟列表(Virtual List)的定义

虚拟列表 是一种只渲染 当前可视区域 内项目(加上少量缓冲区),而将不可见项目“虚拟化”为占位节点或根本不渲染的技术,它能将 DOM 节点数从 10万 降到 20-30 个,大幅提升性能。

核心公式渲染节点数 = 可视区域高度 / 项目平均高度 + 缓冲区数(10-20 个)


虚拟列表的核心实现原理

1 三大核心变量

  • 可视区高度:列表容器的高度(如 500px)。
  • 项目高度:每个列表项的高度(固定或动态)。
  • 滚动位置:用户当前滚动到的 scrollTop 值。

2 计算逻辑(以固定高度为例)

  1. 根据 scrollTop 计算起始索引startIndex = Math.floor(scrollTop / itemHeight)
  2. 根据可视区高度计算结束索引endIndex = startIndex + Math.ceil(containerHeight / itemHeight)
  3. 添加缓冲区:上下各额外渲染 3-5 个项目,防止滚动时出现白屏。
  4. 计算偏移量:通过 transform: translateY(offset)padding-top,让渲染的项目始终“对准”滚动位置。

数据结构示意

[
  { id: 1, content: '项目1', height: 50 },
  { id: 2, content: '项目2', height: 50 },
  ...
]

三步手写一个基础虚拟列表(附代码逻辑)

1 第一步:HTML 结构

<div id="list-container" style="height: 400px; overflow-y: auto;">
  <div id="list-phantom" style="height: 500000px;"> <!-- 总高度占位 -->
    <div id="list-content" style="transform: translateY(0);">
      <!-- 实时渲染的 20 个实际项目 -->
    </div>
  </div>
</div>
  • phantom(幻影层):设置 height = 总数据量 * 每项高度,产生正常的滚动条。
  • content(内容层):通过 translateY 控制实际渲染位置的偏移。

2 第二步:核心 JS 逻辑

const container = document.getElementById('list-container');
const phantom = document.getElementById('list-phantom');
const content = document.getElementById('list-content');
const totalItems = 100000;
const itemHeight = 50;
let startIndex = 0;
let endIndex = 0;
phantom.style.height = totalItems * itemHeight + 'px';
function render(scrollTop) {
  const visibleCount = Math.ceil(container.clientHeight / itemHeight);
  startIndex = Math.floor(scrollTop / itemHeight);
  endIndex = startIndex + visibleCount + 5; // 多渲染 5 个作为缓冲区
  // 生成当前需要渲染的项目数据
  const fragment = document.createDocumentFragment();
  for (let i = startIndex; i < endIndex && i < totalItems; i++) {
    const div = document.createElement('div');
    div.style.height = itemHeight + 'px';
    div.textContent = `项目 ${i}`;
    fragment.appendChild(div);
  }
  content.innerHTML = '';
  content.appendChild(fragment);
  content.style.transform = `translateY(${startIndex * itemHeight}px)`;
}
container.addEventListener('scroll', () => {
  render(container.scrollTop);
});
// 首次加载
render(0);

3 第三步:处理边界情况

  • startIndex < 0 时设为 0。
  • endIndex > totalItems 时设为 totalItems
  • 确保 scrollTop 不会导致索引溢出。

性能优化关键点:滚动、缓存与复用

1 滚动节流(Throttle)

问题:scroll 事件触发频率极高,每次调用 render 可能导致重排(Reflow)。
优化:使用 requestAnimationFrame 或 16ms 的节流。

let ticking = false;
container.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      render(container.scrollTop);
      ticking = false;
    });
    ticking = true;
  }
});

2 项目复用与差分更新

  • 复用 DOM:不要每次 innerHTML = ''append,维护一个“项目池”,只更新数据和样式。
  • 虚拟 DOM 对比:使用 IntersectionObserver 或手动 diff 来精准增删。

3 动态高度处理

当项目高度不固定时(如聊天记录),需要:

  1. 预估高度:首次渲染使用预估高度(如 50px)。
  2. 测量真实高度:渲染后通过 getBoundingClientRect() 获取实际高度,存入高度数组。
  3. 重新计算偏移:用累计高度数组 cumulativeHeight 替代 startIndex * itemHeight

核心算法(高度缓存)

const heights = new Array(totalItems).fill(0);
const cumulativeHeights = [0]; // cumulativeHeights[i] = 前 i 项总高度
function updateCumulative() {
  for (let i = 1; i <= totalItems; i++) {
    cumulativeHeights[i] = cumulativeHeights[i-1] + (heights[i-1] || estimatedHeight);
  }
}

常见问题与解决方案(以问答形式)

Q1:虚拟列表会导致顶部或底部出现白屏?

A:原因是缓冲区不足或渲染不及时。解决方案

  • 增加缓冲区数量(上下各 10-15 个)。
  • 使用 will-change: transform 让浏览器开启 GPU 加速。
  • 确保 scroll 事件的处理函数执行时间低于 16ms。

Q2:如何让虚拟列表支持“滚动到指定位置”?

A:使用 scrollTo 方法,计算对应索引的 scrollTop

function scrollToIndex(index) {
  const top = cumulativeHeights[index] || index * estimatedHeight;
  container.scrollTo({ top, behavior: 'smooth' });
}

Q3:虚拟列表中的图片加载导致滚动闪烁?

A:图片加载会改变项目高度,导致偏移错乱。解决方案

  1. 为图片预留固定宽高占位(推荐)。
  2. 使用 IntersectionObserver 仅在图片进入可视区时加载。
  3. 图片加载完成后触发重新计算高度。

Q4:如何实现“分组表头”或“粘性头部”?

A:将分组视为特殊项目,高度单独计算,粘性头部可通过 position: sticky 实现,但需注意:

  • 粘性头部在虚拟列表中子项目容易错位,建议将粘性头部作为独立层覆盖在内容层之上。
  • 当滚动到某个分组时,上方出现一个固定头部,内容继续滚动。

企业级实践:动态高度、分组与空状态处理

1 空状态与加载占位

  • 当数据为 0 时:不渲染任何项目,显示“暂无数据”。
  • 首次加载时:使用骨架屏(Skeleton)作为占位,高度设为预估高度。

2 内存泄漏预防

  • 确保 removeEventListener 被调用(例如使用 AbortController)。
  • 使用 WeakMap 存储 DOM 引用,避免闭包导致的项目对象无法回收。

3 实际项目案例

某电商平台使用虚拟列表实现商品瀑布流:

  • 数据量:50万条商品。
  • 性能指标:首次渲染时间 < 1s,滚动帧率稳定 60fps。
  • 技术栈:Vue + @tanstack/virtual(原 react-virtual,现跨框架)。

虚拟列表实现的“黄金法则”

场景 实现策略
固定高度 直接用 第3节 的代码
动态高度 需维护高度数组,滚动时重新计算累计高度
海量数据 + 高帧率 加节流、复用 DOM、使用 Web Worker 计算偏移量
移动端低端设备 减少缓冲区、使用 scroll 事件但不频繁渲染

最后一句:虚拟列表的核心是“以有限 DOM 模拟无限数据”,掌握了这一思想,你能轻松适应任何框架(React、Vue、原生 JS)的实现。

标签: 虚拟列表 窗口化

抱歉,评论功能暂时关闭!