深度解读虚拟列表:从原理到实战,解决长列表渲染性能难题

深度解读虚拟列表:从原理到实战,解决长列表渲染性能难题

前言:被长列表 "卡崩" 的前端日常

"万级数据加载后,页面滚动像幻灯片?" "列表项含图片时,滚动到一半突然'跳位'?" "DOM 数量破万后,浏览器直接提示'页面无响应'?"

做前端开发的你,大概率遇到过这些场景。这不是代码能力的问题 ------ 浏览器的渲染瓶颈摆在那里:每新增一个 DOM 元素,都会增加重排重绘的计算成本,当 DOM 数量突破 5000 时,多数设备都会出现明显卡顿。

而虚拟列表(Virtual List),正是为解决这个痛点而生。它的核心逻辑极其简洁:只渲染当前可视区域内的列表项,非可视区域内容完全不渲染。通过 "用空间换时间" 的思路,把 DOM 数量牢牢控制在几十到几百的常量级别,哪怕数据量达到十万级,页面也能保持丝滑滚动。

本文将完全围绕下面提供的 "可变高度虚拟列表(可配置版)"Demo 展开,从核心原理拆解、关键步骤实现,到 Demo 的实战亮点、落地避坑,帮你把虚拟列表从 "面试知识点" 变成 "业务可用的工具"。

给你附上完整demo (这还不值得你一键三连吗?!)

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>可变高度虚拟列表(可配置版)</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      padding: 20px;
      font-family: Arial, sans-serif;
      background: #f5f5f5;
    }

    .container {
      display: flex;
      gap: 30px;
      max-width: 1200px;
      margin: 0 auto;
    }

    /* 虚拟列表样式 */
    .virtual-list-container {
      height: 600px; /* 可视区域高度 */
      overflow-y: auto;
      position: relative;
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      background: white;
      width: 600px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.05);
    }

    .virtual-list-placeholder {
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      z-index: -1; /* 不影响滚动 */
    }

    .virtual-list-content {
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      padding: 0 16px;
    }

    .virtual-list-item {
      margin: 12px 0;
      padding: 16px;
      border-radius: 6px;
      background: #fafafa;
      border: 1px solid #eee;
      transition: background 0.2s;
    }

    .virtual-list-item:hover {
      background: #f0f9ff;
      border-color: #e1f5fe;
    }

    /* 调试面板样式 */
    .debug-panel {
      flex: 1;
      min-width: 300px;
      background: white;
      border-radius: 8px;
      padding: 20px;
      border: 1px solid #e0e0e0;
      box-shadow: 0 2px 8px rgba(0,0,0,0.05);
    }

    .debug-panel h3 {
      margin-bottom: 20px;
      color: #2d3748;
      border-bottom: 1px solid #eee;
      padding-bottom: 10px;
    }

    .debug-item {
      margin-bottom: 12px;
      display: flex;
      justify-content: space-between;
    }

    .debug-label {
      color: #4a5568;
      font-size: 14px;
    }

    .debug-value {
      color: #2563eb;
      font-weight: 600;
      font-size: 14px;
      min-width: 60px;
      text-align: right;
    }

    /* 配置输入区域样式 */
    .config-group {
      margin: 20px 0;
      padding: 16px;
      background: #f8fafc;
      border-radius: 6px;
      border: 1px solid #e2e8f0;
    }

    .config-group h4 {
      margin-bottom: 12px;
      color: #2d3748;
      font-size: 15px;
    }

    .config-item {
      margin-bottom: 12px;
      display: flex;
      align-items: center;
      gap: 10px;
    }

    .config-item label {
      flex: 1;
      color: #4a5568;
      font-size: 14px;
    }

    .config-item input {
      flex: 1;
      padding: 8px 10px;
      border: 1px solid #cbd5e1;
      border-radius: 4px;
      font-size: 14px;
      width: 100px;
    }

    .config-item input:focus {
      outline: none;
      border-color: #2563eb;
      box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
    }

    .btn-apply {
      width: 100%;
      padding: 10px;
      margin-top: 8px;
      border: none;
      border-radius: 4px;
      background: #10b981;
      color: white;
      font-size: 14px;
      cursor: pointer;
      transition: background 0.2s;
    }

    .btn-apply:hover {
      background: #059669;
    }

    .control-group {
      margin-top: 20px;
      padding-top: 20px;
      border-top: 1px solid #eee;
    }

    .control-group button {
      padding: 8px 16px;
      margin-right: 10px;
      margin-bottom: 10px;
      border: none;
      border-radius: 4px;
      background: #2563eb;
      color: white;
      cursor: pointer;
      transition: background 0.2s;
    }

    .control-group button:hover {
      background: #1d4ed8;
    }

    .control-group button.reset {
      background: #94a3b8;
    }

    .control-group button.reset:hover {
      background: #64748b;
    }

    .info-text {
      margin-top: 10px;
      font-size: 12px;
      color: #718096;
      line-height: 1.5;
    }

    .error-text {
      color: #dc2626;
      font-size: 12px;
      margin-top: 4px;
      height: 16px;
    }
  </style>
</head>
<body>
  <div class="container">
    <!-- 虚拟列表容器 -->
    <div class="virtual-list-container">
      <div class="virtual-list-placeholder"></div>
      <div class="virtual-list-content"></div>
    </div>

    <!-- 调试面板 -->
    <div class="debug-panel">
      <h3>虚拟列表调试信息</h3>

      <div class="debug-item">
        <span class="debug-label">总列表项数:</span>
        <span class="debug-value" id="total-count">0</span>
      </div>

      <div class="debug-item">
        <span class="debug-label">已渲染项数:</span>
        <span class="debug-value" id="rendered-count">0</span>
      </div>

      <div class="debug-item">
        <span class="debug-label">可视起始索引:</span>
        <span class="debug-value" id="start-index">0</span>
      </div>

      <div class="debug-item">
        <span class="debug-label">可视结束索引:</span>
        <span class="debug-value" id="end-index">0</span>
      </div>

      <div class="debug-item">
        <span class="debug-label">滚动位置(scrollTop):</span>
        <span class="debug-value" id="scroll-top">0</span>
      </div>

      <div class="debug-item">
        <span class="debug-label">列表总高度:</span>
        <span class="debug-value" id="total-height">0</span>
      </div>

      <div class="debug-item">
        <span class="debug-label">预估高度:</span>
        <span class="debug-value" id="estimate-height">80</span>
      </div>

      <div class="debug-item">
        <span class="debug-label">缓冲项数量:</span>
        <span class="debug-value" id="buffer-count">2</span>
      </div>

      <div class="debug-item">
        <span class="debug-label">最大缓存列表项条数:</span>
        <span class="debug-value" id="max-cache-size">100</span>
      </div>

      <!-- 新增:配置输入区域 -->
      <div class="config-group">
        <h4>自定义配置</h4>
        <div class="config-item">
          <label for="custom-total">列表总条数:</label>
          <input type="number" id="custom-total" placeholder="默认1000" min="1" max="100000">
        </div>
        <div class="config-item">
          <label for="custom-buffer">缓冲项数量:</label>
          <input type="number" id="custom-buffer" placeholder="默认2" min="0" max="10">
        </div>
        <div class="config-item">
          <label for="custom-estimate">预估高度(px):</label>
          <input type="number" id="custom-estimate" placeholder="默认80" min="20" max="500">
        </div>
        <div class="config-item">
          <label for="custom-maxCacheSize">最大缓存列表项条数:</label>
          <input type="number" id="custom-maxCacheSize" placeholder="默认100" min="0" max="200">
        </div>
        <div class="error-text" id="config-error"></div>
        <button class="btn-apply" id="apply-config">应用配置</button>
      </div>

      <div class="control-group">
        <button id="refresh-data">刷新测试数据</button>
        <button id="reset" class="reset">重置默认配置</button>

        <div class="info-text">
          说明:<br>
          1. 支持手动输入列表总数(1-100000)、缓冲数(0-10)、预估高度(20-500px)、缓存条数(0-200)<br>
          2. 列表项高度随机(含部分图片),滚动时自动校准真实高度<br>
          3. 缓冲数越大,滚动越流畅但渲染DOM越多;缓冲数为0可能出现空白<br>
          3. 缓存数越大,滚动越流畅但渲染DOM越多;复用列表项,不会重新渲染<br>
          4. 总数建议不超过10万,避免内存占用过高
        </div>
      </div>
    </div>
  </div>

  <script>
    class VariableHeightVirtualList {
      constructor(options) {
        // 配置参数
        this.container = options.container;
        this.data = options.data;
        this.estimateHeight = options.estimateHeight || 80;
        this.buffer = options.buffer || 2;
        this.maxCacheSize = options.maxCacheSize || 100;
        this.defaultTotal = this.data.length;
        this.defaultEstimateHeight = this.estimateHeight;
        this.defaultBuffer = this.buffer;
        this.defaultMaxCacheSize = this.maxCacheSize;


        // 核心数据
        this.itemHeights = new Array(this.data.length).fill(this.estimateHeight);
        this.prefixHeights = [0];
        this.containerHeight = this.container.clientHeight;
        this.scrollTop = 0;
        this.currentStartIndex = 0;
        this.currentEndIndex = 0;
        this.cacheElements = [];
        this.cacheElementsRecord = [];

        // DOM元素
        this.placeholder = this.container.querySelector('.virtual-list-placeholder');
        this.content = this.container.querySelector('.virtual-list-content');

        // 调试DOM
        this.debugElements = {
          totalCount: document.getElementById('total-count'),
          renderedCount: document.getElementById('rendered-count'),
          startIndex: document.getElementById('start-index'),
          endIndex: document.getElementById('end-index'),
          scrollTop: document.getElementById('scroll-top'),
          totalHeight: document.getElementById('total-height'),
          estimateHeight: document.getElementById('estimate-height'),
          bufferCount: document.getElementById('buffer-count'),
          maxCacheSize: document.getElementById('max-cache-size')
        };

        // 配置输入DOM
        this.configElements = {
          customTotal: document.getElementById('custom-total'),
          customBuffer: document.getElementById('custom-buffer'),
          customEstimate: document.getElementById('custom-estimate'),
          customMaxCacheSize: document.getElementById('custom-maxCacheSize'),
          configError: document.getElementById('config-error'),
          applyBtn: document.getElementById('apply-config')
        };

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

      // 初始化
      init() {
        this.calcPrefixHeights();
        this.updatePlaceholderHeight();
        this.updateVisibleItems();
        this.updateDebugInfo(); // 初始化调试信息
        this.bindEvents();
        this.bindConfigEvents(); // 绑定配置相关事件
      }

      // 计算前缀和
      calcPrefixHeights() {
        for (let i = 0; i < this.data.length; i++) {
          this.prefixHeights[i + 1] = this.prefixHeights[i] + this.itemHeights[i];
        }
      }

      // 更新占位高度
      updatePlaceholderHeight() {
        const totalHeight = this.prefixHeights[this.data.length];
        this.placeholder.style.height = `${totalHeight}px`;
        // 更新调试信息中的总高度
        this.debugElements.totalHeight.textContent = Math.round(totalHeight);
      }

      // 二分查找起始索引
      findStartIndex() {
        const scrollTop = this.scrollTop;
        let low = 0, high = this.prefixHeights.length - 1;

        while (low <= high) {
          const mid = Math.floor((low + high) / 2);
          if (this.prefixHeights[mid] <= scrollTop) {
            low = mid + 1;
          } else {
            high = mid - 1;
          }
        }
        return Math.max(0, low - 1);
      }

      // 计算结束索引
      findEndIndex(startIndex) {
        const scrollBottom = this.scrollTop + this.containerHeight;
        let endIndex = startIndex;

        while (endIndex < this.data.length && this.prefixHeights[endIndex + 1] <= scrollBottom) {
          endIndex++;
        }

        endIndex = Math.min(this.data.length, endIndex + this.buffer);
        return endIndex;
      }

      // 渲染可见项
      updateVisibleItems() {
        this.currentStartIndex = this.findStartIndex();
        this.currentEndIndex = this.findEndIndex(this.currentStartIndex);
        const visibleData = this.data.slice(this.currentStartIndex, this.currentEndIndex);

        // 渲染项(包含索引和高度信息,方便调试)
        this.content.innerHTML = '';
        visibleData.forEach((item, idx) => {
          const realIndex = this.currentStartIndex + idx;
          this.cacheElementsRecord = this.cacheElementsRecord.filter(i => i !== realIndex);
          this.cacheElementsRecord.unshift(realIndex);
          if(this.cacheElementsRecord.length > this.maxCacheSize){
            const removeIndex = this.cacheElementsRecord.pop();
            delete this.cacheElements[removeIndex];
          }
          if(this.cacheElements[realIndex]){
            this.content.appendChild(this.cacheElements[realIndex]);
            return;
          }
          const itemHeight = this.itemHeights[realIndex];
          const element = document.createElement('div');
          element.innerHTML = `
            <div class="virtual-list-item" data-index="${realIndex}">
              <div style="margin-bottom: 8px; color: #64748b; font-size: 12px;">
                索引: ${realIndex} | 高度: ${itemHeight}px
              </div>
              <div style="color: #2d3748; line-height: 1.6;">
                ${item.content}
              </div>
            </div>
          `;
          this.cacheElements[realIndex] = element;
          this.content.appendChild(element);
        });

        // 定位内容区
        const offsetTop = this.prefixHeights[this.currentStartIndex];
        this.content.style.transform = `translateY(${offsetTop}px)`;

        // 校准高度
        this.calibrateHeights();

        // 更新调试信息
        this.updateDebugInfo();
      }

      // 校准真实高度
      calibrateHeights() {
        const items = this.content.querySelectorAll('.virtual-list-item');
        let isHeightChanged = false;

        items.forEach(item => {
          const index = parseInt(item.dataset.index);
          const realHeight = item.offsetHeight;

          if (this.itemHeights[index] !== realHeight) {
            this.itemHeights[index] = realHeight;
            isHeightChanged = true;
            // 实时更新项内的高度显示(调试用)
            item.querySelector('div:first-child').textContent = 
              `索引: ${index} | 高度: ${realHeight}px (已校准)`;
          }
        });

        if (isHeightChanged) {
          this.calcPrefixHeights();
          this.updatePlaceholderHeight();
          this.updateVisibleItems();
        }
      }

      // 更新调试信息
      updateDebugInfo() {
        this.debugElements.totalCount.textContent = this.data.length;
        this.debugElements.renderedCount.textContent = this.currentEndIndex - this.currentStartIndex;
        this.debugElements.startIndex.textContent = this.currentStartIndex;
        this.debugElements.endIndex.textContent = this.currentEndIndex - 1; // 显示最后一个可见索引
        this.debugElements.scrollTop.textContent = Math.round(this.scrollTop);
        this.debugElements.estimateHeight.textContent = this.estimateHeight;
        this.debugElements.bufferCount.textContent = this.buffer;
        this.debugElements.maxCacheSize.textContent = this.maxCacheSize;

        // 同步输入框默认值(显示当前配置)
        this.configElements.customTotal.placeholder = this.data.length;
        this.configElements.customBuffer.placeholder = this.buffer;
        this.configElements.customMaxCacheSize.placeholder = this.maxCacheSize;
        this.configElements.customEstimate.placeholder = this.estimateHeight;
      }

      // 绑定基础事件(滚动、resize等)
      bindEvents() {
        // 滚动事件(添加防抖,优化性能)
        let scrollTimer = null;
        this.container.addEventListener('scroll', () => {
          clearTimeout(scrollTimer);
          scrollTimer = setTimeout(() => {
            this.scrollTop = this.container.scrollTop;
            this.updateVisibleItems();
          }, 10); // 10ms防抖
        });

        // 窗口resize
        window.addEventListener('resize', () => {
          this.containerHeight = this.container.clientHeight;
          this.updateVisibleItems();
        });

        // 图片加载完成后校准高度(如果项内有图片)
        this.content.addEventListener('load', (e) => {
          if (e.target.tagName === 'IMG') {
            this.calibrateHeights();
          }
        }, true);
      }

      // 绑定配置相关事件
      bindConfigEvents() {
        // 应用配置按钮点击事件
        this.configElements.applyBtn.addEventListener('click', () => {
          this.applyCustomConfig();
        });

        // 输入框回车触发应用配置
        [this.configElements.customTotal, this.configElements.customBuffer, this.configElements.customEstimate, this.configElements.customMaxCacheSize]
          .forEach(input => {
            input.addEventListener('keydown', (e) => {
              if (e.key === 'Enter') {
                this.applyCustomConfig();
              }
            });
          });
      }

      // 应用自定义配置
      applyCustomConfig() {
        const customTotal = this.configElements.customTotal.value.trim();
        const customBuffer = this.configElements.customBuffer.value.trim();
        const customEstimate = this.configElements.customEstimate.value.trim();
        const customMaxCacheSize = this.configElements.customMaxCacheSize.value.trim();
        const errorEl = this.configElements.configError;

        // 验证输入
        let errorMsg = '';
        let newTotal = this.data.length;
        let newBuffer = this.buffer;
        let newEstimate = this.estimateHeight;
        let newMaxCacheSize = this.maxCacheSize;

        // 验证总数
        if (customTotal) {
          const num = parseInt(customTotal);
          if (isNaN(num) || num < 1 || num > 100000) {
            errorMsg = '列表总数必须是1-100000的数字';
          } else {
            newTotal = num;
          }
        }

        // 验证缓冲数(如果输入了)
        if (!errorMsg && customBuffer) {
          const num = parseInt(customBuffer);
          if (isNaN(num) || num < 0 || num > 10) {
            errorMsg = '缓冲数必须是0-10的数字';
          } else {
            newBuffer = num;
          }
        }

        // 验证预估高度(如果输入了)
        if (!errorMsg && customEstimate) {
          const num = parseInt(customEstimate);
          if (isNaN(num) || num < 20 || num > 500) {
            errorMsg = '预估高度必须是20-500的数字';
          } else {
            newEstimate = num;
          }
        }

        // 验证最大缓存数(如果输入了)
        if (!errorMsg && customMaxCacheSize) {
          const num = parseInt(customMaxCacheSize);
          if (isNaN(num) || num < 0 || num > 200) {
            errorMsg = '最大缓存列表项数必须是0-200的数字';
          } else {
            newMaxCacheSize = num;
          }
        }

        // 处理错误
        if (errorMsg) {
          errorEl.textContent = errorMsg;
          errorEl.style.color = '#fc5430';
          setTimeout(() => {
            errorEl.textContent = '';
          }, 3000);
          return;
        }

        // 生成新数据(如果总数变化)
        let newData = this.data;
        if (newTotal !== this.data.length) {
          newData = generateMockData(newTotal);
        }

        // 更新配置和数据
        this.updateConfig({
          buffer: newBuffer,
          estimateHeight: newEstimate,
          maxCacheSize: newMaxCacheSize
        });
        this.updateData(newData);

        // 清空输入框
        this.configElements.customTotal.value = '';
        this.configElements.customBuffer.value = '';
        this.configElements.customEstimate.value = '';
        this.configElements.customMaxCacheSize.value = '';

        // 提示成功
        errorEl.textContent = '配置应用成功!';
        errorEl.style.color = '#10b981';
        setTimeout(() => {
          errorEl.textContent = '';
        }, 2000);
      }

      // 外部API:更新数据
      updateData(newData) {
        this.data = newData;
        this.itemHeights = new Array(this.data.length).fill(this.estimateHeight);
        this.prefixHeights = [0];
        this.cacheElements = [];
        this.cacheElementsRecord = [];
        this.calcPrefixHeights();
        this.updatePlaceholderHeight();
        this.updateVisibleItems();
      }

      // 外部API:修改配置
      updateConfig(config) {
        if (config.estimateHeight) this.estimateHeight = config.estimateHeight;
        if (config.buffer !== undefined) this.buffer = config.buffer;
        if (config.maxCacheSize !== undefined) this.maxCacheSize = config.maxCacheSize;
        this.itemHeights = new Array(this.data.length).fill(this.estimateHeight);
        this.prefixHeights = [0];
        this.cacheElements = [];
        this.cacheElementsRecord = [];
        this.calcPrefixHeights();
        this.updatePlaceholderHeight();
        this.updateVisibleItems();
      }

      reset() {
        this.updateConfig({
          estimateHeight: this.defaultEstimateHeight,
          buffer: this.defaultBuffer,
          maxCacheSize: this.defaultMaxCacheSize
        });
        this.updateData(generateMockData(this.defaultTotal));
      }
    }

    // ---------------- 测试数据生成 ----------------
    function generateMockData(count = 1000) {
      // 随机内容长度,模拟不同高度
      const contentLengths = [1, 2, 3, 4, 5, 6, 8, 10];
      return Array.from({ length: count }, (_, i) => {
        const length = contentLengths[Math.floor(Math.random() * contentLengths.length)];
        return {
          content: `可变高度列表项 ${i + 1} 
            ${'------ 测试内容重复'.repeat(length)} 
            ${Math.random() > 0.7 ? '<br><img src="https://picsum.photos/200/80?random=' + i + '" style="max-width:100%;border-radius:4px;margin-top:8px;" alt="测试图">' : ''}`
        };
      });
    }

    // ---------------- 初始化 + 调试控制 ----------------
    const initialData = generateMockData(1000);
    const virtualList = new VariableHeightVirtualList({
      container: document.querySelector('.virtual-list-container'),
      data: initialData,
      estimateHeight: 80,
      buffer: 2,
      maxCacheSize: 10
    });

    // 刷新数据按钮
    document.getElementById('refresh-data').addEventListener('click', () => {
      const currentTotal = virtualList.data.length;
      const newData = generateMockData(currentTotal);
      virtualList.updateData(newData);
      alert(`已刷新数据,当前共 ${currentTotal} 条`);
    });

    // 重置按钮
    document.getElementById('reset').addEventListener('click', () => {
      virtualList.reset();
      alert(`已重置默认配置:总数=${virtualList.defaultTotal},预估高度=${virtualList.defaultEstimateHeight}px,缓冲项=${virtualList.defaultBuffer},最大缓存列表项=${virtualList.defaultMaxCacheSize}`);
    });
  </script>
</body>
</html>

一、先搞懂:虚拟列表的核心逻辑与分类

在写一行代码前,先理清虚拟列表的底层逻辑 ------ 这是避免后续 "越写越乱" 的关键。

1.1 虚拟列表的 3 个核心问题

不管是固定高度还是可变高度,所有虚拟列表都要解决 3 个核心问题,Demo 也不例外:

  1. 范围确定:滚动时,如何精准计算 "哪些列表项在可视区域内"? 比如可视区域高度 500px,列表项高度 100px,就需要知道当前该显示第 3-7 项。

  2. 平滑滚动:只渲染部分项,如何让用户感觉是在滚动 "完整列表"? 不能让用户看到 "跳着走" 的卡顿感,需要通过定位模拟完整滚动效果。

  3. 高度适配:列表项高度不固定时(如含图片、富文本),如何避免定位错位? 这是最复杂的问题 ------ Demo 正是针对这个场景设计的。

1.2 虚拟列表的 2 种核心分类

根据列表项高度是否固定,虚拟列表可分为两类,适用场景天差地别:

类型 核心特点 实现难度 适用场景
固定高度虚拟列表 所有项高度一致,可视范围可通过公式直接计算 表格数据、固定卡片(如商品列表)
可变高度虚拟列表 项高度动态变化,需预估 + 校准真实高度 评论列表、富文本内容、含图片列表
Demo 属于 "可变高度虚拟列表"------ 这也是实际业务中最常用、最能体现技术深度的类型。接下来,我们就以 Demo 为蓝本,拆解它的实现逻辑。

二、原理拆解:可变高度虚拟列表的 5 步实现(基于Demo)

Demo 把可变高度虚拟列表的实现拆解成了 5 个环环相扣的步骤,每个步骤都对应解决一个核心问题。我们一步步来看:

2.1 步骤 1:初始化配置与核心数据定义

一切从VariableHeightVirtualList类的构造函数开始 ------ 这里定义了整个虚拟列表的 "骨架",Demo 在这一步做了很灵活的配置化设计:

javascript 复制代码
constructor(options) {
  // 1. 外部可配置参数(灵活适配不同业务)
  this.container = options.container; // 虚拟列表容器(可视区域DOM)
  this.data = options.data; // 完整列表数据(万级/十万级)
  this.estimateHeight = options.estimateHeight || 80; // 预估高度(默认80px)
  this.buffer = options.buffer || 2; // 缓冲项数量(避免滚动空白)
  this.maxCacheSize = options.maxCacheSize || 100; // 最大DOM缓存数(防内存溢出)

  // 2. 高度相关核心数据(解决可变高度的关键)
  this.itemHeights = new Array(this.data.length).fill(this.estimateHeight); // 存储真实高度
  this.prefixHeights = [0]; // 高度前缀和:prefixHeights[i] = 前i项总高度
  this.containerHeight = this.container.clientHeight; // 可视区域高度
  this.scrollTop = 0; // 当前滚动位置(px)

  // 3. 可视区域范围数据
  this.currentStartIndex = 0; // 可视区域起始项索引
  this.currentEndIndex = 0; // 可视区域结束项索引

  // 4. DOM缓存(性能优化:复用已渲染DOM,减少重排)
  this.cacheElements = []; // 缓存DOM元素的数组
  this.cacheElementsRecord = []; // 记录缓存的索引,控制缓存大小
}

这一步有 3 个 "灵魂数据",直接决定了后续能否处理可变高度:

  • estimateHeight (预估高度):初始化时不知道真实高度,先假设一个值(如 80px),用于计算初始的可视范围和列表总高度。

  • itemHeights (真实高度数组):长度和列表数据一致,初始化时用预估高度填充,后续会通过 DOM 实际高度校准。

  • prefixHeights (高度前缀和) :比如prefixHeights[3] = 前 3 项总高度,通过它能快速定位滚动位置对应的列表项(后面会详细说)。

2.2 步骤 2:计算高度前缀和(快速定位的核心)

前缀和数组prefixHeights是虚拟列表的 "导航地图"------ 没有它,就无法快速找到滚动位置对应的列表项。Demo 里用calcPrefixHeights方法实现:

javascript 复制代码
// 计算前缀和:prefixHeights[i+1] = prefixHeights[i] + itemHeights[i]
calcPrefixHeights() {
  for (let i = 0; i < this.data.length; i++) {
    this.prefixHeights[i + 1] = this.prefixHeights[i] + this.itemHeights[i];
  }
  // 更新列表总高度(用于占位,让滚动条长度正确)
  this.updatePlaceholderHeight();
}

// 更新占位容器高度(模拟完整列表高度)
updatePlaceholderHeight() {
  const totalHeight = this.prefixHeights[this.data.length];
  this.placeholder.style.height = `${totalHeight}px`;
}

举个具体例子理解: 如果有 3 个列表项,真实高度分别是 80px、120px、100px,那么:

  • prefixHeights = [0, 80, 200, 300]

  • 第 2 项(索引 1)的顶部位置 = prefixHeights[1] = 80px

  • 第 2 项的底部位置 = prefixHeights[2] = 200px

  • 列表总高度 = prefixHeights[3] = 300px

有了这个数组,后续不管滚动到哪个位置,都能快速找到对应的列表项。

2.3 步骤 3:确定可视区域范围(滚动时的 "导航")

当用户滚动列表时,第一步要做的就是 "确定当前该显示哪些项"------ 这需要两个关键方法:findStartIndex(找起始项)和findEndIndex(找结束项)。

2.3.1 用二分查找找起始项(性能优化)

起始项是 "当前滚动位置对应的第一个可见项"。如果直接遍历前缀和数组,十万级数据会很慢,Demo 用了二分查找,把时间复杂度从 O (n) 降到 O (log n):

javascript 复制代码
// 二分查找:找到scrollTop对应的起始项索引
findStartIndex() {
  const scrollTop = this.scrollTop;
  let low = 0, high = this.prefixHeights.length - 1;
  
  while (low <= high) {
    const mid = Math.floor((low + high) / 2);
    // 如果mid项的总高度 <= 滚动位置,说明起始项在mid右边
    if (this.prefixHeights[mid] <= scrollTop) {
      low = mid + 1;
    } else {
      // 否则在mid左边
      high = mid - 1;
    }
  }
  // low-1就是第一个顶部位置<=scrollTop的项(起始项)
  return Math.max(0, low - 1);
}

还是用前面的例子:如果滚动位置scrollTop = 150px,二分查找会发现:

  • prefixHeights[1] = 80px ≤ 150px

  • prefixHeights[2] = 200px > 150px 所以起始项索引是1(第 2 项)------ 精准且高效。

2.3.2 计算结束项(加缓冲防空白)

结束项是 "可视区域最后一个可见项",Demo 还加了buffer(缓冲项)------ 这是避免滚动空白的关键:

javascript 复制代码
// 计算结束项:从起始项开始,找到超过滚动底部的项
findEndIndex(startIndex) {
  const scrollBottom = this.scrollTop + this.containerHeight; // 可视区域底部位置
  let endIndex = startIndex;
  
  // 找到第一个底部位置>scrollBottom的项
  while (endIndex < this.data.length && this.prefixHeights[endIndex + 1] <= scrollBottom) {
    endIndex++;
  }
  
  // 加缓冲项(比如buffer=2,就多渲染前后2项)
  endIndex = Math.min(this.data.length, endIndex + this.buffer);
  return endIndex;
}

比如buffer=2,即使用户快速滚动,也会提前渲染 2 个 "备用项",不会因为渲染不及时出现空白 ------ 这是很多新手实现虚拟列表时容易忽略的优化点。

2.4 步骤 4:渲染可视区域项 + 滚动定位

确定了起始和结束项,就可以渲染这部分列表项了。Demo 在这里做了两个关键优化:DOM 缓存复用和transform定位。

javascript 复制代码
// 更新可视区域渲染内容
updateVisibleItems() {
  // 1. 先算当前可视范围
  this.currentStartIndex = this.findStartIndex();
  this.currentEndIndex = this.findEndIndex(this.currentStartIndex);
  // 2. 取可视区域的数据
  const visibleData = this.data.slice(this.currentStartIndex, this.currentEndIndex);

  // 3. 渲染可视项(复用缓存DOM,减少重排)
  this.content.innerHTML = ''; // 清空内容区(但缓存还在)
  visibleData.forEach((item, idx) => {
    const realIndex = this.currentStartIndex + idx; // 真实数据索引
    
    // 优化1:复用已缓存的DOM,不用重新创建
    if (this.cacheElements[realIndex]) {
      this.content.appendChild(this.cacheElements[realIndex]);
      return;
    }

    // 优化2:未缓存则创建新DOM,并加入缓存
    const element = document.createElement('div');
    element.className = 'virtual-list-item';
    element.dataset.index = realIndex; // 记录真实索引,后续校准高度用
    element.innerHTML = `
      <div>索引: ${realIndex} | 高度: ${this.itemHeights[realIndex]}px</div>
      <div>${item.content}</div>
    `;
    
    // 加入缓存,控制缓存大小(防内存溢出)
    this.cacheElements[realIndex] = element;
    this.cacheElementsRecord.push(realIndex);
    if (this.cacheElementsRecord.length > this.maxCacheSize) {
      // 缓存超限时,删除最早的缓存项
      const oldIndex = this.cacheElementsRecord.shift();
      delete this.cacheElements[oldIndex];
    }

    this.content.appendChild(element);
  });

  // 4. 定位内容区:用transform模拟滚动(比top性能好,不触发重排)
  const offsetTop = this.prefixHeights[this.currentStartIndex];
  this.content.style.transform = `translateY(${offsetTop}px)`;

  // 5. 关键步骤:校准真实高度(解决可变高度问题)
  this.calibrateHeights();
}

这里有两个必须注意的细节:

  • DOM 缓存复用 :避免滚动时反复创建 / 销毁 DOM------ 这是性能优化的核心,Demo 还通过maxCacheSize控制缓存大小,防止内存溢出。

  • transform 定位 :用translateY代替top定位,因为transform属于 "合成层操作",不会触发浏览器重排,滚动更流畅。

2.5 步骤 5:校准真实高度(可变高度的 "灵魂")

前面用了预估高度,但实际列表项高度可能和预估不同(比如图片加载后高度增加)。Demo 用calibrateHeights方法校准真实高度,这是解决可变高度的关键:

javascript 复制代码
// 校准真实高度:用DOM实际高度更新数据
calibrateHeights() {
  const items = this.content.querySelectorAll('.virtual-list-item');
  let isHeightChanged = false; // 标记高度是否有变化

  items.forEach(item => {
    const realIndex = parseInt(item.dataset.index);
    const realHeight = item.offsetHeight; // 获取DOM真实高度

    // 如果真实高度和记录的不一致,更新数据
    if (this.itemHeights[realIndex] !== realHeight) {
      this.itemHeights[realIndex] = realHeight;
      isHeightChanged = true;
      // 实时更新项内的高度显示(调试友好)
      item.querySelector('div:first-child').textContent = 
        `索引: ${realIndex} | 高度: ${realHeight}px (已校准)`;
    }
  });

  // 高度变化后,重新计算前缀和和列表总高度
  if (isHeightChanged) {
    this.calcPrefixHeights();
    this.updateVisibleItems(); // 重新渲染,确保定位准确
  }
}

比如预估高度 80px,实际 DOM 高度 120px------ 校准后,itemHeights数组会更新为 120px,前缀和也会重新计算,后续滚动定位就不会错位了。Demo 还在项内实时显示校准后的高度,非常方便调试。

三、实战亮点:Demo 做对了这些事

所提供的 "可变高度虚拟列表(可配置版)"Demo,不只是实现了核心功能,还加了很多贴近业务的设计,这些细节让它能直接落地到项目中:

3.1 全配置化设计(灵活适配业务)

你把预估高度、缓冲项数量、最大缓存数等关键参数都做成了外部可配置:

javascript 复制代码
// 初始化时可自定义所有核心参数
const virtualList = new VariableHeightVirtualList({
  container: document.querySelector('.virtual-list-container'),
  data: initialData, // 业务数据
  estimateHeight: 100, // 按业务调整预估高度
  buffer: 3, // 缓冲项3个,更流畅
  maxCacheSize: 150 // 缓存150个DOM,平衡性能和内存
});

// 还支持运行时更新配置
virtualList.updateConfig({
  estimateHeight: 120,
  buffer: 2
});

这种设计让虚拟列表能适配不同业务场景 ------ 比如商品列表用 80px 预估高度,评论列表用 120px,不用修改核心代码。

3.2 调试面板(开发友好)

Demo 右侧加了调试面板,实时显示总项数、已渲染项数、可视范围、滚动位置等核心数据:

  • 开发时能直观看到 "可视范围是否正确""渲染项数是否合理";

  • 测试时能快速定位问题 ------ 比如滚动时起始索引是否跳变,高度校准是否生效。

这是很多开源虚拟列表库都没有的细节,对开发和调试太友好了。

3.3 图片加载后重新校准(解决实际痛点)

列表项含图片时,图片加载后高度会变化 ------ Demo 考虑到了这个场景,加了图片加载监听:

javascript 复制代码
// 监听图片加载,重新校准高度
listenImageLoad() {
  this.content.addEventListener('load', (e) => {
    if (e.target.tagName === 'IMG') {
      this.calibrateHeights(); // 图片加载后重新校准
    }
  }, true);
}

这一个小细节,就避免了 "图片加载后列表错位" 的常见问题 ------ 很多新手实现的虚拟列表,就是因为没处理这个场景,导致上线后出现 bug。

四、避坑指南:虚拟列表落地的 6 个高频问题

结合Demo 和实际业务经验,总结了 6 个最容易踩的坑,每个坑都有对应的解决方案:

4.1 坑点 1:滚动时出现空白区域

原因 :缓冲项数量不足,或预估高度与真实高度偏差太大。 解决方案(Demo 已实现):

  • 缓冲项buffer设为 2-3(根据滚动速度调整);

  • 预估高度尽量贴近真实高度(比如按业务数据统计平均高度);

  • 图片加载后重新校准高度。

4.2 坑点 2:滚动定位错位(项的位置不对)

原因 :没及时校准真实高度,或前缀和计算错误。 解决方案

  • 渲染完成后必须调用calibrateHeights

  • 检查前缀和计算逻辑:确保prefixHeights[i+1] = prefixHeights[i] + itemHeights[i]

  • 避免在滚动事件中做耗时操作,导致校准延迟。

4.3 坑点 3:DOM 缓存导致内存溢出

原因 :缓存的 DOM 数量太多,尤其是十万级数据时。 解决方案(Demo 已实现):

  • maxCacheSize控制缓存大小(建议 100-200,根据项复杂度调整);

  • 缓存超限时,删除最早的缓存项(cacheElementsRecord记录索引,先进先出)。

4.4 坑点 4:滚动卡顿(不流畅)

原因 :滚动事件触发太频繁,或渲染逻辑太重。 解决方案

  • 给滚动事件加 10-20ms 防抖(Demo 用了 10ms);

  • transform代替top定位(避免重排);

  • 减少列表项内的 DOM 嵌套(越简单越好)。

4.5 坑点 5:初始化时滚动条长度不对

原因 :用预估高度计算的列表总高度,和真实总高度偏差太大。 解决方案(Demo 已实现):

  • placeholder(占位容器)显示列表总高度;

  • 高度校准后,及时更新placeholder的高度(updatePlaceholderHeight)。

4.6 坑点 6:列表项点击事件错位

原因 :DOM 复用后,事件绑定的索引没更新。 解决方案

  • 给每个列表项加data-index记录真实索引(Demo 已做);

  • 点击事件中通过e.target.closest('.virtual-list-item').dataset.index获取真实索引,不要依赖循环变量。

五、落地建议:从 Demo 到生产环境

Demo 已经实现了核心功能,要落地到项目中,还需要补充这些细节:

5.1 兼容性处理

  • 低版本浏览器offsetHeighttransform在 IE11 中可用,但forEachslice等方法需要 polyfill;

  • 移动端 :监听touchmove事件(配合touchend),避免滚动延迟。

5.2 异常场景处理

  • 数据为空:显示 "暂无数据" 占位,不要渲染空列表;

  • 数据加载中:加加载动画,避免用户以为 "列表没出来";

  • 数据更新 :数据变化后,重置itemHeightsprefixHeights,重新初始化。

5.3 性能测试

在不同场景下测试性能,确保满足业务需求:

  • 数据量测试:分别测试 1 万、5 万、10 万条数据的滚动流畅度;

  • 设备测试:在低端安卓机、iPhone 旧机型上测试,避免性能瓶颈;

  • 内存测试:滚动 10 分钟后,通过 Chrome DevTools 查看内存占用,确保无泄漏。

六、总结:虚拟列表不是银弹,但能解决大问题

虚拟列表的核心价值是 "解决长列表的性能问题",但它不是万能的:

  • 适合场景:数据量≥1000 条、列表项高度不固定、对滚动流畅度要求高;

  • 不适合场景:数据量≤500 条(直接渲染更简单,没必要用虚拟列表)。

提供的"可变高度虚拟列表(可配置版)"Demo,已经覆盖了虚拟列表的核心难点:可变高度校准、DOM 缓存复用、缓冲防空白,再补充一些兼容性和异常处理,就能直接落地到生产环境。

最后记住:虚拟列表的本质是 "取舍"------ 用少量计算成本,换取 DOM 数量的大幅减少。理解了这个核心,不管遇到什么业务场景,都能灵活调整实现方案。总而言之,一键点赞、评论、喜欢收藏吧!这对我很重要!

相关推荐
在下历飞雨1 小时前
Kuikly基础之动画实战:让孤寡青蛙“活”过来
前端·ios·harmonyos
2***c4351 小时前
nginx服务器实现上传文件功能_使用nginx-upload-module模块
服务器·前端·nginx
p***93031 小时前
Java进阶之泛型
android·前端·后端
木易 士心1 小时前
Element UI 多级菜单缩进的动态控制:从原理到工程化实践
前端·vue.js·ui
狮子座的男孩1 小时前
js函数高级:03、详解原型与原型链(原型、显式原型与隐式原型、原型链、原型链属性、探索instanceof、案例图解)及相关面试题
前端·javascript·经验分享·显示原型与隐式原型·原型链及属性·探索instanceof·原型与原型链图解
烛阴1 小时前
C#继承与多态全解析,让你的对象“活”起来
前端·c#
狗哥哥1 小时前
Swagger对接MCP服务:赋能AI编码的高效落地指南
前端·后端
zl_vslam1 小时前
SLAM中的非线性优-3D图优化之相对位姿Between Factor(六)
前端·人工智能·算法·计算机视觉·slam se2 非线性优化
申阳1 小时前
Day 18:01. 基于 SpringBoot4 开发后台管理系统-快速了解一下 SpringBoot4 新特性
前端·后端·程序员