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

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

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

"万级数据加载后,页面滚动像幻灯片?" "列表项含图片时,滚动到一半突然'跳位'?" "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 数量的大幅减少。理解了这个核心,不管遇到什么业务场景,都能灵活调整实现方案。总而言之,一键点赞、评论、喜欢收藏吧!这对我很重要!

相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60615 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅6 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼7 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax