在前端开发中,长列表渲染一直是性能优化的重点场景。当列表数据达到万级甚至十万级时,直接渲染所有 DOM 节点会导致页面卡顿、内存占用过高,严重影响用户体验。本文将通过一个完整的实战案例,详解虚拟化列表的实现原理与代码落地,帮助你轻松应对百万级数据渲染难题。

一、为什么需要虚拟化列表?

先看一个直观的对比:当渲染 10 万条列表数据时,传统渲染方式与虚拟化列表的性能差异堪称天壤之别。

渲染方式 DOM 节点数量 初始渲染时间 滚动流畅度 内存占用
传统渲染 100,000+ 3000ms+ 严重卡顿 极高
虚拟化列表 20-30 个 50ms 以内 60fps 满帧 极低

核心痛点:浏览器对 DOM 节点的渲染和重排存在性能上限,当节点数量超过 1000 时,页面就会出现明显卡顿。而虚拟化列表的核心思想是只渲染可视区域内的元素,通过 “动态复用” DOM 节点,将节点数量控制在固定范围,从而突破性能瓶颈。

二、虚拟化列表核心原理拆解

实现虚拟化列表需要解决三个关键问题:可视区域计算元素定位动态更新。下面用通俗的语言拆解每个环节的实现逻辑。

1. 可视区域计算:确定 “该渲染哪些元素”

首先需要明确两个关键参数:

  • 可视区域高度:列表容器的可见高度(如示例中固定 600px)
  • 滚动位置:容器滚动条距离顶部的距离(scrollTop

结合列表项的固定高度(如示例中 80px),可以计算出:

  • 可视区域内可容纳的最大项数 = 可视区域高度 / 列表项高度(示例中为 600/80 ≈ 8 项)
  • 起始索引 = 滚动位置 / 列表项高度(向下取整,确定当前该渲染哪一项开始的元素)
  • 结束索引 = 起始索引 + 可视项数 + 缓冲区大小(缓冲区避免滚动时出现空白)

2. 元素定位:让元素 “出现在正确的位置”

虚拟化列表的容器需要 “感知” 整个列表的高度,才能正常滚动。这里有两种常用方案:

  • 方案一:padding 占位:通过给容器设置paddingToppaddingBottom,模拟整个列表的高度(paddingTop = 起始索引 * 项高paddingBottom = 剩余项数 * 项高
  • 方案二:transform 偏移:将可视区域内的元素通过transform: translateY(偏移量)定位到正确位置,同时给内容容器设置真实的总高度(总高度 = 总数据量 * 项高

示例中采用了方案二,因为transform不会触发重排,性能更优。

3. 动态更新:滚动时 “实时切换渲染内容”

通过监听容器的scroll事件,实时更新起始 / 结束索引,并重新渲染可视区域内的元素。为了避免滚动事件触发过于频繁,需要用节流函数控制频率(示例中设置为 16ms,对应 60fps 的刷新频率)。

三、完整代码实现与解析

下面基于原生 JavaScript + Tailwind CSS 实现一个可直接运行的虚拟化列表,支持 10 万条数据渲染,包含加载状态、统计信息等完整功能。

1. 页面结构设计

首先搭建 HTML 骨架,包含列表容器、加载指示器、统计信息区域:

<div class="container mx-auto px-4 py-8 max-w-5xl">
  <!-- 头部标题 -->
  <header class="mb-8 text-center">
    <h1 class="text-[clamp(1.8rem,4vw,2.5rem)] font-bold text-gray-800 mb-2">前端虚拟化列表示例</h1>
    <p class="text-gray-600 max-w-2xl mx-auto">
      高效渲染10万条数据,只渲染可视区域内容,显著提升性能
    </p>
  </header>

  <!-- 列表容器 -->
  <div class="bg-white rounded-lg shadow-md overflow-hidden">
    <!-- 列表头部 -->
    <div class="p-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
      <div class="flex items-center gap-2">
        <i class="fa fa-list-ul text-primary text-xl"></i>
        <h2 class="font-semibold text-lg">虚拟化列表演示</h2>
      </div>
      <div class="text-sm text-gray-600">
        总数据量: <span id="total-count" class="font-medium text-primary">100,000</span> 条
      </div>
    </div>

    <!-- 虚拟化列表核心容器 -->
    <div class="relative" style="height: 600px;">
      <!-- 滚动容器 -->
      <div id="virtual-list-container" class="absolute inset-0 overflow-y-auto scrollbar-hide">
        <!-- 动态内容容器 -->
        <div id="virtual-list-wrapper" class="relative"></div>
      </div>

      <!-- 加载状态 -->
      <div id="loading-indicator" class="absolute inset-0 bg-white bg-opacity-80 flex items-center justify-center hidden">
        <div class="flex flex-col items-center">
          <div class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
          <p class="mt-3 text-gray-600">加载中...</p>
        </div>
      </div>
    </div>

    <!-- 统计信息 -->
    <div class="p-4 border-t border-gray-200 bg-gray-50 text-sm text-gray-600 flex justify-between">
      <div>
        可视区域内项目: <span id="visible-count" class="font-medium text-primary">0</span> 条
      </div>
      <div>
        当前滚动位置: <span id="scroll-position" class="font-medium text-primary">0</span> px
      </div>
    </div>
  </div>
</div>

2. 核心逻辑实现(JavaScript)

通过VirtualList类封装虚拟化列表的核心逻辑,包含初始化、滚动处理、渲染更新等功能:

2.1 数据生成函数

首先生成 10 万条测试数据,模拟真实业务场景:

// 生成大量测试数据
function generateData(count) {
  return Array.from({ length: count }, (_, i) => ({
    id: i + 1,
    title: `列表项 #${i + 1}`,
    description: `这是第 ${i + 1} 条列表项的描述信息,用于演示虚拟化列表的效果`,
    timestamp: new Date(Date.now() - Math.floor(Math.random() * 30) * 86400000).toLocaleDateString()
  }));
}
2.2 VirtualList 类实现
class VirtualList {
  constructor(containerId, wrapperId, options) {
    // 容器元素
    this.container = document.getElementById(containerId);
    this.wrapper = document.getElementById(wrapperId);
    // 配置参数
    this.data = options.data || [];
    this.itemHeight = options.itemHeight || 80; // 列表项固定高度
    this.bufferSize = options.bufferSize || 5; // 缓冲区大小(避免滚动空白)
    // 状态变量
    this.startIndex = 0; // 起始渲染索引
    this.endIndex = 0; // 结束渲染索引
    this.scrollTop = 0; // 滚动位置
    // 性能优化:滚动事件节流
    this.handleScroll = this.throttle(this.handleScroll.bind(this), 16);
    
    // 初始化
    this.init();
  }

  // 初始化列表
  init() {
    // 1. 计算基础参数
    this.containerHeight = this.container.clientHeight; // 可视区域高度
    this.visibleCount = Math.ceil(this.containerHeight / this.itemHeight); // 可视项数
    this.totalHeight = this.data.length * this.itemHeight; // 列表总高度

    // 2. 绑定事件
    this.container.addEventListener('scroll', this.handleScroll);

    // 3. 初始渲染
    this.updateVisibleItems();

    // 4. 更新统计信息
    this.updateStats();
  }

  // 滚动事件处理
  handleScroll() {
    this.scrollTop = this.container.scrollTop;
    this.updateVisibleItems(); // 更新可视区域元素
    this.updateStats(); // 更新统计信息
  }

  // 更新可视区域元素
  updateVisibleItems() {
    // 1. 计算当前需要渲染的索引范围
    let startIndex = Math.floor(this.scrollTop / this.itemHeight) - this.bufferSize;
    startIndex = Math.max(0, startIndex); // 避免负索引
    let endIndex = startIndex + this.visibleCount + this.bufferSize * 2;
    endIndex = Math.min(this.data.length - 1, endIndex); // 避免超出总长度

    // 2. 索引无变化时不重复渲染(性能优化)
    if (startIndex === this.startIndex && endIndex === this.endIndex) {
      return;
    }
    this.startIndex = startIndex;
    this.endIndex = endIndex;

    // 3. 定位可视区域元素(通过transform偏移)
    const offsetY = startIndex * this.itemHeight;
    this.wrapper.style.transform = `translateY(${offsetY}px)`;

    // 4. 渲染当前可视区域的元素
    this.renderVisibleItems();

    // 5. 设置内容容器总高度(让滚动条正常显示)
    this.wrapper.style.height = `${this.totalHeight}px`;
  }

  // 渲染可视区域元素
  renderVisibleItems() {
    this.wrapper.innerHTML = ''; // 清空现有内容
    // 循环渲染当前索引范围内的元素
    for (let i = this.startIndex; i <= this.endIndex; i++) {
      const item = this.data[i];
      const element = this.createItemElement(item, i);
      this.wrapper.appendChild(element);
    }
  }

  // 创建单个列表项元素
  createItemElement(item, index) {
    const element = document.createElement('div');
    // 交替行样式(提升可读性)
    element.className = `virtual-item ${index % 2 === 0 ? 'virtual-item-even' : ''}`;
    element.style.height = `${this.itemHeight}px`; // 固定高度
    // 列表项内容
    element.innerHTML = `
      <div class="flex justify-between h-full items-center">
        <div>
          <div class="font-medium text-gray-900">${item.title}</div>
          <div class="text-sm text-gray-500 mt-1">${item.description}</div>
        </div>
        <div class="text-right">
          <div class="text-xs text-gray-400">${item.timestamp}</div>
          <div class="w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center mt-1">
            ${item.id}
          </div>
        </div>
      </div>
    `;
    return element;
  }

  // 更新统计信息
  updateStats() {
    document.getElementById('visible-count').textContent = this.endIndex - this.startIndex + 1;
    document.getElementById('scroll-position').textContent = Math.floor(this.scrollTop);
  }

  // 节流函数(控制滚动事件触发频率)
  throttle(func, limit) {
    let lastCall = 0;
    return function (...args) {
      const now = Date.now();
      if (now - lastCall >= limit) {
        lastCall = now;
        func.apply(this, args);
      }
    };
  }
}
2.3 页面加载初始化
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', () => {
  // 显示加载状态
  const loadingIndicator = document.getElementById('loading-indicator');
  loadingIndicator.classList.remove('hidden');

  // 模拟数据加载延迟(真实场景替换为接口请求)
  setTimeout(() => {
    const data = generateData(100000); // 生成10万条数据
    // 初始化虚拟化列表
    new VirtualList('virtual-list-container', 'virtual-list-wrapper', {
      data: data,
      itemHeight: 80,
      bufferSize: 5
    });
    // 隐藏加载状态
    loadingIndicator.classList.add('hidden');
  }, 800);
});

3. 样式优化(Tailwind CSS)

通过 Tailwind 的工具类和自定义工具类,优化列表的视觉效果和滚动体验:

@layer utilities {
  .content-auto {
    content-visibility: auto;
  }
  .scrollbar-hide {
    -ms-overflow-style: none;
    scrollbar-width: none;
  }
  .scrollbar-hide::-webkit-scrollbar {
    display: none;
  }
  .virtual-item {
    @apply py-3 px-4 border-b border-gray-100 hover:bg-blue-50 transition-colors duration-150;
  }
  .virtual-item-even {
    @apply bg-gray-50;
  }
}

4. 整体代码展示

<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>前端虚拟化列表示例</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">

    <!-- Tailwind 配置 -->
    <script>
        tailwind.config = {
            theme: {
                extend: {
                    colors: {
                        primary: '#3b82f6',
                        secondary: '#64748b',
                        neutral: '#f1f5f9',
                    },
                    fontFamily: {
                        inter: ['Inter', 'system-ui', 'sans-serif'],
                    },
                },
            }
        }
    </script>

    <style type="text/tailwindcss">
        @layer utilities {
      .content-auto {
        content-visibility: auto;
      }
      .scrollbar-hide {
        -ms-overflow-style: none;
        scrollbar-width: none;
      }
      .scrollbar-hide::-webkit-scrollbar {
        display: none;
      }
      .virtual-item {
        @apply py-3 px-4 border-b border-gray-100 hover:bg-blue-50 transition-colors duration-150;
      }
      .virtual-item-even {
        @apply bg-gray-50;
      }
    }
  </style>
</head>

<body class="font-inter bg-gray-50 text-gray-800 min-h-screen">
    <div class="container mx-auto px-4 py-8 max-w-5xl">
        <header class="mb-8 text-center">
            <h1 class="text-[clamp(1.8rem,4vw,2.5rem)] font-bold text-gray-800 mb-2">前端虚拟化列表示例</h1>
            <p class="text-gray-600 max-w-2xl mx-auto">
                这个示例展示了如何高效渲染大量数据(100,000条),只渲染可视区域内的内容,显著提升性能
            </p>
        </header>

        <div class="bg-white rounded-lg shadow-md overflow-hidden">
            <div class="p-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
                <div class="flex items-center gap-2">
                    <i class="fa fa-list-ul text-primary text-xl"></i>
                    <h2 class="font-semibold text-lg">虚拟化列表演示</h2>
                </div>
                <div class="text-sm text-gray-600">
                    总数据量: <span id="total-count" class="font-medium text-primary">100,000</span> 条
                </div>
            </div>

            <!-- 虚拟化列表容器 -->
            <div class="relative" style="height: 600px;">
                <div id="virtual-list-container" class="absolute inset-0 overflow-y-auto scrollbar-hide">
                    <!-- 内容将通过JavaScript动态生成 -->
                    <div id="virtual-list-wrapper" class="relative"></div>
                </div>

                <!-- 加载状态指示器 -->
                <div id="loading-indicator"
                    class="absolute inset-0 bg-white bg-opacity-80 flex items-center justify-center hidden">
                    <div class="flex flex-col items-center">
                        <div class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin">
                        </div>
                        <p class="mt-3 text-gray-600">加载中...</p>
                    </div>
                </div>
            </div>

            <div class="p-4 border-t border-gray-200 bg-gray-50 text-sm text-gray-600 flex justify-between">
                <div>
                    可视区域内项目: <span id="visible-count" class="font-medium text-primary">0</span> 条
                </div>
                <div>
                    当前滚动位置: <span id="scroll-position" class="font-medium text-primary">0</span> px
                </div>
            </div>
        </div>

        <div class="mt-8 bg-white rounded-lg shadow-md p-6">
            <h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
                <i class="fa fa-info-circle text-primary"></i>
                虚拟化列表原理
            </h3>
            <ul class="space-y-2 text-gray-700">
                <li class="flex items-start gap-2">
                    <i class="fa fa-check-circle text-green-500 mt-1"></i>
                    <span>只渲染可视区域内的列表项,而非全部数据</span>
                </li>
                <li class="flex items-start gap-2">
                    <i class="fa fa-check-circle text-green-500 mt-1"></i>
                    <span>通过计算滚动位置确定需要渲染的项目范围</span>
                </li>
                <li class="flex items-start gap-2">
                    <i class="fa fa-check-circle text-green-500 mt-1"></i>
                    <span>使用padding或transform创建滚动容器的高度感知</span>
                </li>
                <li class="flex items-start gap-2">
                    <i class="fa fa-check-circle text-green-500 mt-1"></i>
                    <span>添加缓冲区避免滚动时的空白闪烁问题</span>
                </li>
            </ul>
        </div>
    </div>

    <script>
        // 生成大量测试数据
        function generateData(count) {
            return Array.from({ length: count }, (_, i) => ({
                id: i + 1,
                title: `列表项 #${i + 1}`,
                description: `这是第 ${i + 1} 条列表项的描述信息,用于演示虚拟化列表的效果`,
                timestamp: new Date(Date.now() - Math.floor(Math.random() * 30) * 86400000).toLocaleDateString()
            }));
        }

        class VirtualList {
            constructor(containerId, wrapperId, options) {
                this.container = document.getElementById(containerId);
                this.wrapper = document.getElementById(wrapperId);
                this.data = options.data || [];
                this.itemHeight = options.itemHeight || 80; // 每项固定高度
                this.bufferSize = options.bufferSize || 5; // 缓冲区大小

                // 状态变量
                this.startIndex = 0;
                this.endIndex = 0;
                this.scrollTop = 0;

                // 性能优化:使用requestAnimationFrame处理滚动
                this.handleScroll = this.throttle(this.handleScroll.bind(this), 16);

                // 初始化
                this.init();
            }

            // 初始化列表
            init() {
                // 设置容器高度
                this.containerHeight = this.container.clientHeight;

                // 计算可显示的项目数量
                this.visibleCount = Math.ceil(this.containerHeight / this.itemHeight);

                // 计算总高度
                this.totalHeight = this.data.length * this.itemHeight;

                // 监听滚动事件
                this.container.addEventListener('scroll', this.handleScroll);

                // 初始渲染
                this.updateVisibleItems();

                // 更新统计信息
                this.updateStats();
            }

            // 处理滚动事件
            handleScroll() {
                this.scrollTop = this.container.scrollTop;
                this.updateVisibleItems();
                this.updateStats();
            }

            // 更新可视区域项目
            updateVisibleItems() {
                // 计算起始索引(减去缓冲区)
                let startIndex = Math.floor(this.scrollTop / this.itemHeight) - this.bufferSize;
                startIndex = Math.max(0, startIndex);

                // 计算结束索引(加上可视数量和缓冲区)
                let endIndex = startIndex + this.visibleCount + this.bufferSize * 2;
                endIndex = Math.min(this.data.length - 1, endIndex);

                // 如果索引范围没有变化,不重新渲染
                if (startIndex === this.startIndex && endIndex === this.endIndex) {
                    return;
                }

                this.startIndex = startIndex;
                this.endIndex = endIndex;

                // 计算偏移量,让内容正确定位
                const offsetY = startIndex * this.itemHeight;
                this.wrapper.style.transform = `translateY(${offsetY}px)`;

                // 渲染可见项目
                this.renderVisibleItems();

                // 设置容器的总高度(通过paddingBottom实现)
                this.wrapper.style.height = `${this.totalHeight}px`;
            }

            // 渲染可见项目
            renderVisibleItems() {
                // 清空当前内容
                this.wrapper.innerHTML = '';

                // 只渲染可见范围内的项目
                for (let i = this.startIndex; i <= this.endIndex; i++) {
                    const item = this.data[i];
                    const element = this.createItemElement(item, i);
                    this.wrapper.appendChild(element);
                }
            }

            // 创建列表项元素
            createItemElement(item, index) {
                const element = document.createElement('div');
                element.className = `virtual-item ${index % 2 === 0 ? 'virtual-item-even' : ''}`;
                element.style.height = `${this.itemHeight}px`;
                element.innerHTML = `
          <div class="flex justify-between">
            <div>
              <div class="font-medium text-gray-900">${item.title}</div>
              <div class="text-sm text-gray-500 mt-1">${item.description}</div>
            </div>
            <div class="text-right">
              <div class="text-xs text-gray-400">${item.timestamp}</div>
              <div class="w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center mt-1">
                ${item.id}
              </div>
            </div>
          </div>
        `;
                return element;
            }

            // 更新统计信息
            updateStats() {
                document.getElementById('visible-count').textContent =
                    this.endIndex - this.startIndex + 1;
                document.getElementById('scroll-position').textContent =
                    Math.floor(this.scrollTop);
            }

            // 节流函数,优化滚动性能
            throttle(func, limit) {
                let lastCall = 0;
                return function (...args) {
                    const now = Date.now();
                    if (now - lastCall >= limit) {
                        lastCall = now;
                        func.apply(this, args);
                    }
                };
            }
        }

        // 页面加载完成后初始化
        document.addEventListener('DOMContentLoaded', () => {
            // 显示加载状态
            const loadingIndicator = document.getElementById('loading-indicator');
            loadingIndicator.classList.remove('hidden');

            // 生成10万条数据(模拟大量数据)
            setTimeout(() => {
                const data = generateData(100000);

                // 初始化虚拟化列表
                const virtualList = new VirtualList('virtual-list-container', 'virtual-list-wrapper', {
                    data: data,
                    itemHeight: 80,
                    bufferSize: 5
                });

                // 隐藏加载状态
                loadingIndicator.classList.add('hidden');
            }, 800); // 模拟加载延迟
        });
    </script>
</body>

</html>

四、关键优化点解析

1. 节流函数优化滚动事件

滚动事件的触发频率非常高(每秒可达数十次),通过节流函数将触发间隔控制在 16ms(对应 60fps 的屏幕刷新率),避免频繁渲染导致的性能损耗。

2. 缓冲区设计避免空白

在可视区域上下各增加 5 个缓冲区元素(bufferSize: 5),当用户快速滚动时,缓冲区元素可以提前渲染,避免出现 “滚动到边缘时空白” 的情况,提升用户体验。

3. 索引无变化不重复渲染

updateVisibleItems方法中,先判断当前索引范围是否与上一次一致,一致则不执行渲染逻辑,减少不必要的 DOM 操作。

4. transform 定位替代 top 定位

使用transform: translateY()进行元素定位,相比top定位不会触发浏览器的重排(reflow),只触发重绘(repaint),性能更优。

五、扩展场景:动态高度列表

本文实现的是固定高度的虚拟化列表,而实际业务中常遇到列表项高度不固定的场景(如包含富文本、图片等)。针对动态高度,可以采用以下方案:

  1. 预估高度 + 实际修正

    • 先给每个列表项设置一个预估高度(如 100px)
    • 首次渲染后,获取每个元素的真实高度并存储
    • 后续滚动时,使用真实高度计算索引范围,避免定位偏差
  2. 使用成熟库

    • 动态高度实现复杂,可直接使用成熟的开源库,如:
      • React 生态:react-virtualizedreact-window
      • Vue 生态:vue-virtual-scroller
      • 原生生态:vue-virtual-scroller(也支持原生)

六、总结

虚拟化列表是前端性能优化的 “大杀器”,尤其适用于数据量庞大的列表场景(如下拉选择器、表格、聊天记录等)。本文通过原生 JavaScript 实现了一个完整的虚拟化列表,核心是 “只渲染可视区域元素”,通过数学计算和动态定位,将 DOM 节点数量控制在最小范围。

掌握虚拟化列表的实现原理后,你可以根据业务场景灵活调整参数(如项高、缓冲区大小),或扩展到动态高度、横向列表等复杂场景。对于生产环境,建议使用成熟的开源库,避免重复造轮子,同时关注库的性能和兼容性。

希望本文能帮助你解决长列表渲染的性能难题,让你的页面在百万级数据下依然保持流畅!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐