博客 / 詳情

返回

深度解讀虛擬列表:從原理到實戰,解決長列表渲染性能難題

深度解讀虛擬列表:從原理到實戰,解決長列表渲染性能難題

前言:被長列表 “卡崩” 的前端日常

“萬級數據加載後,頁面滾動像幻燈片?”
“列表項含圖片時,滾動到一半突然‘跳位’?”
“DOM 數量破萬後,瀏覽器直接提示‘頁面無響應’?”

做前端開發的你,大概率遇到過這些場景。這不是代碼能力的問題 —— 瀏覽器的渲染瓶頸擺在那裏:每新增一個 DOM 元素,都會增加重排重繪的計算成本,當 DOM 數量突破 5000 時,多數設備都會出現明顯卡頓。

而虛擬列表(Virtual List),正是為解決這個痛點而生。它的核心邏輯極其簡潔:只渲染當前可視區域內的列表項,非可視區域內容完全不渲染。通過 “用空間換時間” 的思路,把 DOM 數量牢牢控制在幾十到幾百的常量級別,哪怕數據量達到十萬級,頁面也能保持絲滑滾動。

本文將完全圍繞下面提供的 “可變高度虛擬列表(可配置版)”Demo 展開,從核心原理拆解、關鍵步驟實現,到 Demo 的實戰亮點、落地避坑,幫你把虛擬列表從 “面試知識點” 變成 “業務可用的工具”。

給你附上完整demo (這還不值得你一鍵三連嗎?!)


<!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 在這一步做了很靈活的配置化設計:


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方法實現:


// 計算前綴和: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):


// 二分查找:找到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(緩衝項)—— 這是避免滾動空白的關鍵:


// 計算結束項:從起始項開始,找到超過滾動底部的項
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定位。


// 更新可視區域渲染內容
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方法校準真實高度,這是解決可變高度的關鍵:


// 校準真實高度:用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 全配置化設計(靈活適配業務)

你把預估高度、緩衝項數量、最大緩存數等關鍵參數都做成了外部可配置:


// 初始化時可自定義所有核心參數
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 考慮到了這個場景,加了圖片加載監聽:


// 監聽圖片加載,重新校準高度
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 數量的大幅減少。理解了這個核心,不管遇到什麼業務場景,都能靈活調整實現方案。總而言之,一鍵點贊、評論、喜歡收藏吧!這對我很重要!

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.