如何实现自定义的虚拟列表

1 阅读10分钟

从零实现一个虚拟列表,支持固定高度与动态高度两种场景

在大数据列表渲染场景中,虚拟列表是提升性能的利器。本文将从原理到实践,带你手动实现一个支持固定高度和动态高度的虚拟列表组件。

前言

当页面需要展示成千上万条数据时,如果直接全部渲染到 DOM 中,会导致:

  • DOM 节点过多:浏览器渲染压力大,页面卡顿
  • 内存占用高:每个 DOM 节点都占用内存
  • 滚动性能差:大量节点的重排重绘消耗性能

虚拟列表的核心思想:只渲染可视区域内的元素,通过动态计算和位置定位,实现海量数据的高性能渲染。

核心原理

1. 基本概念

虚拟列表的实现基于以下几个关键点:

┌─────────────────────────────────────┐
│          Container (可视区域)        │
│  ┌─────────────────────────────┐    │
│  │     可见列表项 (实际渲染)     │    │
│  │                             │    │
│  │        Item 3               │    │
│  │        Item 4               │    │
│  │        Item 5               │    │
│  │        Item 6               │    │
│  │        Item 7               │    │
│  └─────────────────────────────┘    │
│                                     │
│  ↑ 缓冲区 (预渲染)                   │
│  ↓ 缓冲区 (预渲染)                   │
└─────────────────────────────────────┘
│          Phantom (撑开容器)          │  ← 总高度 = 所有项高度之和
└─────────────────────────────────────┘
  • Container:固定高度的容器,设置 overflow: auto 实现滚动
  • Phantom:一个占位元素,高度等于所有列表项高度之和,用于撑开滚动条
  • Visible Items:只渲染可视区域 + 缓冲区内的列表项
  • Buffer:上下缓冲区,防止快速滚动时出现白屏

2. 两种场景对比

特性固定高度动态高度
位置计算index * itemHeight,O(1) 复杂度需要累积计算,O(n) 复杂度
实现难度简单较复杂
适用场景列表项高度一致列表项高度不一致
性能极高较高(需要缓存和测量)

实现方案

核心点一:位置计算

固定高度模式
// 固定高度:直接计算,O(1) 复杂度
function calculatePositions(data, itemHeight) {
  return data.map((_, index) => ({
    top: index * itemHeight,
    height: itemHeight
  }));
}
动态高度模式
// 动态高度:需要累积计算
function calculatePositions(data, heightCache, estimateHeight) {
  const positions = [];
  let currentTop = 0;

  for (let i = 0; i < data.length; i++) {
    // 优先使用已测量的高度,否则使用预估高度
    const height = heightCache.get(i) ?? estimateHeight(data[i], i);
    
    positions.push({
      top: currentTop,
      height
    });
    
    currentTop += height;
  }
  
  return positions;
}

核心点二:二分查找定位可视区域

当列表项数量巨大时,线性查找可视区域的起始和结束索引效率太低。使用二分查找可以将时间复杂度从 O(n) 降到 O(log n)。

/**
 * 二分查找:找到第一个顶部位置 >= scrollTop 的项索引
 * 时间复杂度:O(log n)
 */
function binarySearchFirstVisible(positions, scrollTop) {
  let left = 0;
  let right = positions.length - 1;
  let result = 0;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    const midBottom = positions[mid].top + positions[mid].height;

    if (midBottom <= scrollTop) {
      left = mid + 1;
    } else {
      result = mid;
      right = mid - 1;
    }
  }

  return result;
}

/**
 * 二分查找:找到第一个底部位置 > scrollBottom 的项索引
 */
function binarySearchLastVisible(positions, scrollBottom) {
  let left = 0;
  let right = positions.length - 1;
  let result = positions.length - 1;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);

    if (positions[mid].top < scrollBottom) {
      left = mid + 1;
    } else {
      result = mid;
      right = mid - 1;
    }
  }

  return result;
}

核心点三:缓冲区机制

快速滚动时,如果只渲染可视区域内的元素,会出现短暂的白屏。缓冲区机制通过预渲染可视区域上下额外的元素来解决这一问题。

/**
 * 计算缓冲区大小
 * 快速滚动时增大缓冲区,减少白屏
 */
function getBufferSize(containerHeight, bufferRatio, isScrolling) {
  // 滚动中时增加缓冲区
  return isScrolling 
    ? containerHeight * bufferRatio * 2 
    : containerHeight * bufferRatio;
}

/**
 * 获取可视区域的范围(含缓冲区)
 */
function getVisibleRange(positions, scrollTop, containerHeight, bufferSize, overscan) {
  const scrollTopWithBuffer = Math.max(0, scrollTop - bufferSize);
  const scrollBottomWithBuffer = scrollTop + containerHeight + bufferSize;

  // 二分查找可视区域
  let start = binarySearchFirstVisible(positions, scrollTopWithBuffer);
  let end = binarySearchLastVisible(positions, scrollBottomWithBuffer);

  // 添加 overscan 预渲染项
  start = Math.max(0, start - overscan);
  end = Math.min(positions.length - 1, end + overscan);

  return { start, end };
}

核心点四:动态高度测量与缓存

动态高度的难点在于:渲染前无法知道元素的实际高度。解决方案:

  1. 初始预估:使用 estimateHeight 函数预估初始高度
  2. 渲染后测量:使用 getBoundingClientRect() 测量实际高度
  3. 缓存更新:将测量结果缓存,避免重复测量
  4. 批量更新:所有测量完成后统一更新位置,避免频繁重算
// 渲染可视区域的元素
function render() {
  const { start, end } = getVisibleRange();
  
  // 记录需要测量高度的元素
  const pendingMeasure = [];

  for (let i = start; i <= end; i++) {
    if (!renderedItems.has(i)) {
      const item = data[i];
      const position = positions[i];

      // 创建并定位元素
      const el = document.createElement('div');
      el.style.position = 'absolute';
      el.style.top = `${position.top}px`;
      el.innerHTML = renderItem(item, i);
      container.appendChild(el);
      renderedItems.set(i, el);

      // 动态高度:记录需要测量的元素
      if (!isFixedHeight && !heightCache.has(i)) {
        pendingMeasure.push({ el, index: i });
      }
    }
  }

  // 批量测量高度,避免频繁更新位置
  if (pendingMeasure.length > 0) {
    requestAnimationFrame(() => {
      let hasUpdate = false;
      
      pendingMeasure.forEach(({ el, index }) => {
        const actualHeight = el.getBoundingClientRect().height;
        heightCache.set(index, actualHeight);
        hasUpdate = true;
      });
      
      // 所有高度测量完成后统一更新一次
      if (hasUpdate) {
        updatePositions();
        rerenderVisible();
      }
    });
  }
}

核心点五:滚动优化

滚动事件触发频繁,需要优化性能:

function bindEvents() {
  let rafId = null;
  let scrollTimer = null;

  container.addEventListener('scroll', (e) => {
    scrollTop = e.target.scrollTop;

    // 快速滑动检测
    isScrolling = true;
    
    if (scrollTimer) {
      clearTimeout(scrollTimer);
    }
    
    // 滚动停止后 150ms 重置状态
    scrollTimer = setTimeout(() => {
      isScrolling = false;
    }, 150);

    // 使用 requestAnimationFrame 优化渲染
    if (rafId) {
      cancelAnimationFrame(rafId);
    }
    
    rafId = requestAnimationFrame(() => {
      render();
    });
  });
}

效果演示

固定高度模式

每项高度固定为 50px,列表滚动流畅,渲染项数稳定。切换到固定高度模式后,可以看到所有列表项高度一致,适合用于简单列表场景。

image.png

image.png

动态高度模式

不同类型的内容高度不同,通过颜色标签区分:

  • 🔵 蓝色(单行):约 45px,简短内容
  • 🟢 绿色(中等):约 85px,2-3 行内容
  • 🟠 橙色(较长):约 155px,5-6 行内容
  • 🔴 红色(超长):约 285px,包含多段内容
  • 🟣 紫色(随机):约 60-120px,高度随机波动

image.png

image.png

性能优化总结

优化点说明效果
二分查找定位可视区域O(log n) 查找效率
缓冲区上下预渲染减少快速滚动白屏
高度缓存避免重复测量每项只测量一次
批量更新统一更新位置减少频繁重算
rAF 节流requestAnimationFrame平滑滚动渲染
滚动检测快速滚动时增大缓冲区提升用户体验

完整代码

原生 JavaScript 实现(可直接运行)

以下是完整的 HTML 文件,保存后可直接在浏览器中打开运行:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>虚拟列表 Demo</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: #f5f5f5;
      padding: 20px;
    }
    .demo-container {
      max-width: 900px;
      margin: 0 auto;
      background: #fff;
      border-radius: 12px;
      box-shadow: 0 2px 12px rgba(0,0,0,0.1);
      padding: 24px;
    }
    h1 { font-size: 24px; margin-bottom: 20px; color: #333; }
    .control-panel {
      display: flex;
      gap: 24px;
      margin-bottom: 24px;
      padding: 16px;
      background: #fafafa;
      border-radius: 8px;
      flex-wrap: wrap;
      align-items: center;
    }
    .control-group { display: flex; align-items: center; gap: 8px; }
    .control-group label { font-size: 14px; color: #666; font-weight: 500; }
    .control-group select {
      padding: 6px 12px;
      border: 1px solid #d9d9d9;
      border-radius: 6px;
      font-size: 14px;
      background: #fff;
      cursor: pointer;
      min-width: 120px;
    }
    .stats {
      margin-left: auto;
      display: flex;
      gap: 16px;
      font-size: 13px;
      color: #999;
    }
    .stats span {
      padding: 4px 12px;
      background: #e6f7ff;
      border-radius: 4px;
      color: #1890ff;
    }
    .list-wrapper {
      border: 1px solid #e8e8e8;
      border-radius: 8px;
      overflow: hidden;
      margin-bottom: 24px;
    }
    .virtual-list-container {
      height: 600px;
      overflow: auto;
      position: relative;
      background: #fff;
    }
    .virtual-list-phantom { position: relative; }
    .virtual-list-item {
      position: absolute;
      left: 0;
      right: 0;
      border-bottom: 1px solid #f0f0f0;
    }
    .virtual-list-item:hover { background: #f5f5f5; }
    .fixed-item {
      height: 100%;
      padding: 0 16px;
      display: flex;
      align-items: center;
    }
    .fixed-item .index { width: 80px; color: #999; font-size: 13px; }
    .fixed-item .content { flex: 1; }
    .dynamic-item { padding: 12px 16px; }
    .dynamic-item .header {
      font-weight: 600;
      margin-bottom: 8px;
      color: #1890ff;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    .dynamic-item .text { color: #666; line-height: 1.6; font-size: 14px; white-space: pre-line; }
    .mode-tag {
      display: inline-block;
      padding: 2px 8px;
      background: #52c41a;
      color: #fff;
      border-radius: 4px;
      font-size: 12px;
      margin-left: 8px;
    }
    .mode-tag.dynamic { background: #722ed1; }
  </style>
</head>
<body>
  <div class="demo-container">
    <h1>虚拟列表 Demo <span class="mode-tag dynamic" id="modeTag">动态高度</span></h1>
    <div class="control-panel">
      <div class="control-group">
        <label>模式:</label>
        <select id="modeSelect">
          <option value="fixed">固定高度</option>
          <option value="dynamic" selected>动态高度</option>
        </select>
      </div>
      <div class="control-group">
        <label>数据量:</label>
        <select id="countSelect">
          <option value="1000">1,000 条</option>
          <option value="10000" selected>10,000 条</option>
          <option value="100000">100,000 条</option>
        </select>
      </div>
      <div class="control-group">
        <label>缓冲区:</label>
        <select id="bufferSelect">
          <option value="0">无缓冲</option>
          <option value="0.25">25%</option>
          <option value="0.5" selected>50%</option>
          <option value="1">100%</option>
        </select>
      </div>
      <div class="stats">
        <span id="renderCount">渲染: 0 项</span>
        <span id="scrollPos">滚动: 0px</span>
      </div>
    </div>
    <div class="list-wrapper">
      <div class="virtual-list-container" id="container">
        <div class="virtual-list-phantom" id="phantom"></div>
      </div>
    </div>
  </div>

  <script>
    // 配置参数
    const CONFIG = {
      containerHeight: 600,
      fixedItemHeight: 50,
      bufferRatio: 0.5,
      overscan: 3,
      mode: 'dynamic',
      itemCount: 10000
    };

    // DOM 元素
    const container = document.getElementById('container');
    const phantom = document.getElementById('phantom');
    const renderCountEl = document.getElementById('renderCount');
    const scrollPosEl = document.getElementById('scrollPos');
    const modeTag = document.getElementById('modeTag');

    // 数据生成
    function generateData(count, mode) {
      const result = [];
      for (let i = 0; i < count; i++) {
        if (mode === 'fixed') {
          result.push({ id: i, text: `列表项 ${i + 1}`, index: i });
        } else {
          const heightType = i % 5;
          let content = '', tag = '';
          switch (heightType) {
            case 0: content = '简短内容'; tag = '单行'; break;
            case 1: content = '这是一段中等长度的内容,占据两到三行的空间。'; tag = '中等'; break;
            case 2: content = '这是一段较长的内容,用于展示需要更多空间的信息展示场景。'; tag = '较长'; break;
            case 3: content = '这是一段非常长的内容,模拟真实业务场景中的富文本展示。\n\n在实际开发中,列表项可能包含各种复杂内容。'; tag = '超长'; break;
            case 4: content = Array(3).fill('这是随机内容行。').join('\n'); tag = '随机'; break;
          }
          result.push({ id: i, text: `列表项 ${i + 1}`, content, tag, heightType, index: i });
        }
      }
      return result;
    }

    // 虚拟列表类
    class VirtualList {
      constructor(options) {
        this.container = options.container;
        this.phantom = options.phantom;
        this.data = options.data || [];
        this.itemHeight = options.itemHeight;
        this.containerHeight = options.containerHeight;
        this.bufferRatio = options.bufferRatio || 0.5;
        this.overscan = options.overscan || 3;
        this.renderItem = options.renderItem;
        this.estimateHeight = options.estimateHeight;
        this.isFixedHeight = this.itemHeight !== undefined;
        this.heightCache = new Map();
        this.positions = [];
        this.scrollTop = 0;
        this.isScrolling = false;
        this.renderedItems = new Map();
        this.init();
      }

      init() {
        this.updatePositions();
        this.render();
        this.bindEvents();
      }

      updatePositions() {
        this.positions = [];
        let currentTop = 0;
        for (let i = 0; i < this.data.length; i++) {
          let height;
          if (this.isFixedHeight) {
            height = this.itemHeight;
          } else {
            height = this.heightCache.get(i) ?? (this.estimateHeight?.(this.data[i], i) ?? 50);
          }
          this.positions.push({ top: currentTop, height });
          currentTop += height;
        }
        this.totalHeight = currentTop;
        this.phantom.style.height = `${this.totalHeight}px`;
      }

      binarySearchStart(scrollTop) {
        let left = 0, right = this.positions.length - 1, result = 0;
        while (left <= right) {
          const mid = Math.floor((left + right) / 2);
          const midBottom = this.positions[mid].top + this.positions[mid].height;
          if (midBottom <= scrollTop) { left = mid + 1; } 
          else { result = mid; right = mid - 1; }
        }
        return result;
      }

      binarySearchEnd(scrollBottom) {
        let left = 0, right = this.positions.length - 1, result = this.positions.length - 1;
        while (left <= right) {
          const mid = Math.floor((left + right) / 2);
          if (this.positions[mid].top < scrollBottom) { left = mid + 1; } 
          else { result = mid; right = mid - 1; }
        }
        return result;
      }

      getBufferSize() {
        return this.isScrolling ? this.containerHeight * this.bufferRatio * 2 : this.containerHeight * this.bufferRatio;
      }

      getVisibleRange() {
        if (this.positions.length === 0) return { start: 0, end: 0 };
        const bufferSize = this.getBufferSize();
        const scrollTopWithBuffer = Math.max(0, this.scrollTop - bufferSize);
        const scrollBottomWithBuffer = this.scrollTop + this.containerHeight + bufferSize;
        let start = this.binarySearchStart(scrollTopWithBuffer);
        let end = this.binarySearchEnd(scrollBottomWithBuffer);
        start = Math.max(0, start - this.overscan);
        end = Math.min(this.positions.length - 1, end + this.overscan);
        return { start, end };
      }

      render() {
        const { start, end } = this.getVisibleRange();
        this.renderedItems.forEach((el, index) => {
          if (index < start || index > end) { el.remove(); this.renderedItems.delete(index); }
        });
        const pendingMeasure = [];
        for (let i = start; i <= end; i++) {
          if (!this.renderedItems.has(i)) {
            const item = this.data[i];
            const position = this.positions[i];
            const el = document.createElement('div');
            el.className = 'virtual-list-item';
            el.style.cssText = `position: absolute; top: ${position.top}px; left: 0; right: 0;`;
            if (this.isFixedHeight) el.style.height = `${this.itemHeight}px`;
            el.innerHTML = this.renderItem(item, i, this.isFixedHeight);
            this.phantom.appendChild(el);
            this.renderedItems.set(i, el);
            if (!this.isFixedHeight && !this.heightCache.has(i)) pendingMeasure.push({ el, index: i });
          }
        }
        if (pendingMeasure.length > 0) {
          requestAnimationFrame(() => {
            let hasUpdate = false;
            pendingMeasure.forEach(({ el, index }) => {
              if (this.renderedItems.has(index)) {
                this.heightCache.set(index, el.getBoundingClientRect().height);
                hasUpdate = true;
              }
            });
            if (hasUpdate) { this.updatePositions(); this.rerenderVisible(); }
          });
        }
        renderCountEl.textContent = `渲染: ${end - start + 1} 项`;
      }

      rerenderVisible() {
        this.renderedItems.forEach((el, index) => {
          const position = this.positions[index];
          if (position) el.style.top = `${position.top}px`;
        });
      }

      bindEvents() {
        let rafId = null, scrollTimer = null;
        this.container.addEventListener('scroll', (e) => {
          this.scrollTop = e.target.scrollTop;
          scrollPosEl.textContent = `滚动: ${Math.round(this.scrollTop)}px`;
          this.isScrolling = true;
          if (scrollTimer) clearTimeout(scrollTimer);
          scrollTimer = setTimeout(() => { this.isScrolling = false; }, 150);
          if (rafId) cancelAnimationFrame(rafId);
          rafId = requestAnimationFrame(() => this.render());
        });
      }

      setData(data) {
        this.data = data;
        this.heightCache.clear();
        this.renderedItems.forEach(el => el.remove());
        this.renderedItems.clear();
        this.scrollTop = 0;
        this.container.scrollTop = 0;
        this.updatePositions();
        this.render();
      }

      updateConfig(options) {
        if ('itemHeight' in options) {
          this.itemHeight = options.itemHeight;
          this.isFixedHeight = options.itemHeight !== undefined && options.itemHeight !== null;
        }
        if ('estimateHeight' in options) this.estimateHeight = options.estimateHeight;
        if (options.bufferRatio !== undefined) this.bufferRatio = options.bufferRatio;
        if (options.overscan !== undefined) this.overscan = options.overscan;
        this.heightCache.clear();
        this.renderedItems.forEach(el => el.remove());
        this.renderedItems.clear();
        this.scrollTop = 0;
        this.container.scrollTop = 0;
        this.updatePositions();
      }
    }

    // 渲染函数
    function renderItem(item, index, isFixed) {
      if (isFixed) {
        const bgColor = index % 2 === 0 ? '#fff' : '#fafafa';
        return `<div class="fixed-item" style="background: ${bgColor}"><span class="index">#${index + 1}</span><span class="content">${item.text}</span></div>`;
      } else {
        const colors = {
          0: { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' },
          1: { bg: '#f6ffed', border: '#52c41a', tag: '#52c41a' },
          2: { bg: '#fff7e6', border: '#fa8c16', tag: '#fa8c16' },
          3: { bg: '#fff1f0', border: '#f5222d', tag: '#f5222d' },
          4: { bg: '#f9f0ff', border: '#722ed1', tag: '#722ed1' },
        };
        const c = colors[item.heightType] || colors[0];
        return `<div class="dynamic-item" style="background: ${c.bg}; border-left: 3px solid ${c.border};"><div class="header"><span>#${index + 1} - ${item.text}</span><span style="background: ${c.tag}; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 12px;">${item.tag}</span></div><div class="text">${item.content}</div></div>`;
      }
    }

    function estimateHeight(item) {
      return { 0: 45, 1: 85, 2: 155, 3: 285, 4: 100 }[item.heightType] || 60;
    }

    // 初始化
    let data = generateData(CONFIG.itemCount, CONFIG.mode);
    const virtualList = new VirtualList({
      container, phantom, data,
      itemHeight: CONFIG.mode === 'fixed' ? CONFIG.fixedItemHeight : undefined,
      containerHeight: CONFIG.containerHeight,
      bufferRatio: CONFIG.bufferRatio,
      overscan: CONFIG.overscan,
      renderItem,
      estimateHeight: CONFIG.mode === 'dynamic' ? estimateHeight : undefined
    });

    // 事件绑定
    document.getElementById('modeSelect').addEventListener('change', (e) => {
      CONFIG.mode = e.target.value;
      modeTag.textContent = CONFIG.mode === 'fixed' ? '固定高度' : '动态高度';
      modeTag.className = `mode-tag ${CONFIG.mode === 'dynamic' ? 'dynamic' : ''}`;
      virtualList.updateConfig({
        itemHeight: CONFIG.mode === 'fixed' ? CONFIG.fixedItemHeight : undefined,
        estimateHeight: CONFIG.mode === 'dynamic' ? estimateHeight : undefined
      });
      data = generateData(CONFIG.itemCount, CONFIG.mode);
      virtualList.setData(data);
    });

    document.getElementById('countSelect').addEventListener('change', (e) => {
      CONFIG.itemCount = parseInt(e.target.value);
      data = generateData(CONFIG.itemCount, CONFIG.mode);
      virtualList.setData(data);
    });

    document.getElementById('bufferSelect').addEventListener('change', (e) => {
      CONFIG.bufferRatio = parseFloat(e.target.value);
      virtualList.updateConfig({ bufferRatio: CONFIG.bufferRatio });
    });
  </script>
</body>
</html>

React 版本实现

React 版本使用 Hooks 实现,支持 TypeScript 类型,完全参照原生 JavaScript 版本的实现逻辑:

/**
 * 虚拟列表完整实现 - React 版本
 * 支持固定高度和动态高度两种模式
 */

import React, { useRef, useState, useEffect, useCallback, useMemo } from 'react';

// ============================================
// 类型定义
// ============================================

interface VirtualListProps<T> {
  data: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T, index: number) => string | number;
  containerHeight: number;
  itemHeight?: number;  // 固定高度模式:传入此项则使用固定高度
  estimateItemHeight?: (item: T, index: number) => number;  // 动态高度预估函数
  bufferRatio?: number;
  overscan?: number;
}

// ============================================
// 二分查找函数:O(log n) 定位可视区域
// ============================================

function binarySearchStart(
  positions: { top: number; height: number }[],
  scrollTop: number
): number {
  let left = 0;
  let right = positions.length - 1;
  let result = 0;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    const midBottom = positions[mid].top + positions[mid].height;

    if (midBottom <= scrollTop) {
      left = mid + 1;
    } else {
      result = mid;
      right = mid - 1;
    }
  }

  return result;
}

function binarySearchEnd(
  positions: { top: number; height: number }[],
  scrollBottom: number
): number {
  let left = 0;
  let right = positions.length - 1;
  let result = positions.length - 1;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);

    if (positions[mid].top < scrollBottom) {
      left = mid + 1;
    } else {
      result = mid;
      right = mid - 1;
    }
  }

  return result;
}

// ============================================
// 核心组件:虚拟列表
// ============================================

function VirtualList<T>({
  data,
  renderItem,
  keyExtractor,
  containerHeight,
  itemHeight,
  estimateItemHeight,
  bufferRatio = 0.5,
  overscan = 3,
}: VirtualListProps<T>) {
  // 判断是否固定高度模式
  const isFixedHeight = itemHeight !== undefined;

  // Refs:使用 ref 存储可变值,避免频繁触发重渲染
  const containerRef = useRef<HTMLDivElement>(null);
  const phantomRef = useRef<HTMLDivElement>(null);
  const itemsRef = useRef<Map<number, HTMLDivElement>>(new Map());
  const heightCacheRef = useRef<Map<number, number>>(new Map());
  const positionsRef = useRef<{ top: number; height: number }[]>([]);
  const scrollTopRef = useRef(0);
  const isScrollingRef = useRef(false);
  const scrollTimerRef = useRef<ReturnType<typeof setTimeout>>();

  // 状态
  const [, forceUpdate] = useState(0);
  const [isScrolling, setIsScrolling] = useState(false);

  // ============================================
  // 核心点1:计算所有项的位置信息
  // ============================================
  const updatePositions = useCallback(() => {
    const positions: { top: number; height: number }[] = [];
    let currentTop = 0;

    for (let i = 0; i < data.length; i++) {
      let height: number;

      if (isFixedHeight) {
        height = itemHeight!;
      } else {
        if (heightCacheRef.current.has(i)) {
          height = heightCacheRef.current.get(i)!;
        } else if (estimateItemHeight) {
          height = estimateItemHeight(data[i], i);
        } else {
          height = 50;
        }
      }

      positions.push({
        top: currentTop,
        height,
      });

      currentTop += height;
    }

    positionsRef.current = positions;

    // 更新 phantom 高度
    if (phantomRef.current) {
      phantomRef.current.style.height = `${currentTop}px`;
    }
  }, [data, isFixedHeight, itemHeight, estimateItemHeight]);

  // ============================================
  // 核心点2:计算缓冲区大小
  // ============================================
  const getBufferSize = useCallback(() => {
    // 快速滚动时增大缓冲区,减少白屏
    return isScrolling
      ? containerHeight * bufferRatio * 2
      : containerHeight * bufferRatio;
  }, [containerHeight, bufferRatio, isScrolling]);

  // ============================================
  // 核心点3:获取可视区域的项目(二分查找)
  // ============================================
  const getVisibleRange = useCallback(() => {
    const positions = positionsRef.current;
    if (positions.length === 0) {
      const defaultEnd = Math.min(20, data.length - 1);
      return { start: 0, end: Math.max(0, defaultEnd) };
    }

    const bufferSize = getBufferSize();
    const scrollTop = scrollTopRef.current;
    const scrollTopWithBuffer = Math.max(0, scrollTop - bufferSize);
    const scrollBottomWithBuffer = scrollTop + containerHeight + bufferSize;

    // 二分查找可视区域
    let start = binarySearchStart(positions, scrollTopWithBuffer);
    let end = binarySearchEnd(positions, scrollBottomWithBuffer);

    // 添加预渲染项
    start = Math.max(0, start - overscan);
    end = Math.min(positions.length - 1, end + overscan);

    return { start, end };
  }, [containerHeight, getBufferSize, overscan, data.length]);

  // ============================================
  // 核心点4:重新渲染可见区域位置
  // ============================================
  const rerenderVisible = useCallback(() => {
    const positions = positionsRef.current;
    itemsRef.current.forEach((el, index) => {
      const position = positions[index];
      if (position) {
        el.style.top = `${position.top}px`;
      }
    });
  }, []);

  // 初始化和更新
  useEffect(() => {
    updatePositions();
    forceUpdate((prev) => prev + 1);
  }, [updatePositions]);

  // 监听 itemHeight 变化(模式切换)
  const prevItemHeightRef = useRef(itemHeight);
  useEffect(() => {
    // 检测模式切换(固定高度 <-> 动态高度)
    if ((prevItemHeightRef.current === undefined) !== (itemHeight === undefined)) {
      // 模式切换时重置所有状态
      // 注意:不要直接操作 DOM,让 React 自己处理 DOM 的更新
      heightCacheRef.current.clear();
      itemsRef.current.clear();
      scrollTopRef.current = 0;
      if (containerRef.current) {
        containerRef.current.scrollTop = 0;
      }
      updatePositions();
      forceUpdate((prev) => prev + 1);
    }
    prevItemHeightRef.current = itemHeight;
  }, [itemHeight, updatePositions]);

  // ============================================
  // 核心点5:滚动事件处理
  // ============================================
  const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
    scrollTopRef.current = e.currentTarget.scrollTop;

    isScrollingRef.current = true;
    setIsScrolling(true);

    if (scrollTimerRef.current) {
      clearTimeout(scrollTimerRef.current);
    }

    // 滚动停止后重置状态
    scrollTimerRef.current = setTimeout(() => {
      isScrollingRef.current = false;
      setIsScrolling(false);
    }, 150);

    forceUpdate((prev) => prev + 1);
  }, []);

  // 清理定时器
  useEffect(() => {
    return () => {
      if (scrollTimerRef.current) {
        clearTimeout(scrollTimerRef.current);
      }
    };
  }, []);

  // 数据变化时重置
  const prevDataLengthRef = useRef(data.length);
  useEffect(() => {
    if (data.length !== prevDataLengthRef.current) {
      heightCacheRef.current.clear();
      itemsRef.current.clear();
      scrollTopRef.current = 0;
      prevDataLengthRef.current = data.length;
      if (containerRef.current) {
        containerRef.current.scrollTop = 0;
      }
      updatePositions();
    }
  }, [data.length, updatePositions]);

  // ============================================
  // 计算可视数据
  // ============================================
  const { start, end } = getVisibleRange();
  const visibleData = useMemo(() => {
    return data.slice(start, end + 1).map((item, i) => ({
      item,
      index: start + i,
    }));
  }, [data, start, end]);

  const totalHeight = useMemo(() => {
    const positions = positionsRef.current;
    if (positions.length === 0) return 0;
    const last = positions[positions.length - 1];
    return last.top + last.height;
  }, [data.length, forceUpdate]);

  // ============================================
  // 动态高度测量:使用 requestAnimationFrame 批量更新
  // ============================================
  useEffect(() => {
    if (isFixedHeight) return;

    const pendingMeasure: { el: HTMLDivElement; index: number }[] = [];

    itemsRef.current.forEach((el, index) => {
      if (!heightCacheRef.current.has(index)) {
        pendingMeasure.push({ el, index });
      }
    });

    if (pendingMeasure.length > 0) {
      requestAnimationFrame(() => {
        let hasUpdate = false;
        pendingMeasure.forEach(({ el, index }) => {
          if (itemsRef.current.has(index)) {
            const actualHeight = el.getBoundingClientRect().height;
            heightCacheRef.current.set(index, actualHeight);
            hasUpdate = true;
          }
        });

        if (hasUpdate) {
          updatePositions();
          rerenderVisible();
        }
      });
    }
  }, [visibleData, isFixedHeight, updatePositions, rerenderVisible]);

  // ============================================
  // 渲染
  // ============================================
  return (
    <div
      ref={containerRef}
      style={{
        height: containerHeight,
        overflow: 'auto',
        position: 'relative',
      }}
      onScroll={handleScroll}
    >
      <div
        ref={phantomRef}
        style={{
          height: totalHeight,
          position: 'relative',
        }}
      >
        {visibleData.map(({ item, index }) => {
          const position = positionsRef.current[index];
          return (
            <div
              key={keyExtractor(item, index)}
              ref={(el) => {
                if (el) {
                  itemsRef.current.set(index, el);
                } else {
                  itemsRef.current.delete(index);
                }
              }}
              style={{
                position: 'absolute',
                top: position?.top ?? 0,
                left: 0,
                right: 0,
                height: isFixedHeight ? itemHeight : 'auto',
              }}
            >
              {renderItem(item, index)}
            </div>
          );
        })}
      </div>
    </div>
  );
}

export default VirtualList;

// ============================================
// 使用示例
// ============================================

/**
 * 示例1:固定高度列表
 */
export const FixedHeightExample = () => {
  const data = Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    text: `列表项 ${i + 1}`,
  }));

  return (
    <VirtualList
      data={data}
      containerHeight={600}
      itemHeight={50}
      keyExtractor={(item) => item.id}
      renderItem={(item) => (
        <div
          style={{
            height: '100%',
            padding: '0 16px',
            display: 'flex',
            alignItems: 'center',
            borderBottom: '1px solid #eee',
          }}
        >
          {item.text}
        </div>
      )}
    />
  );
};

/**
 * 示例2:动态高度列表
 */
export const DynamicHeightExample = () => {
  const data = Array.from({ length: 10000 }, (_, i) => {
    const heightType = i % 5;
    let content = '';
    let tag = '';

    switch (heightType) {
      case 0:
        content = '简短内容';
        tag = '单行';
        break;
      case 1:
        content = '这是一段中等长度的内容,占据两到三行的空间。';
        tag = '中等';
        break;
      case 2:
        content = '这是一段较长的内容,用于展示需要更多空间的信息展示场景。';
        tag = '较长';
        break;
      case 3:
        content = `这是一段非常长的内容,模拟真实业务场景中的富文本展示。

在实际开发中,列表项可能包含:
• 用户详细信息
• 商品卡片
• 订单摘要`;
        tag = '超长';
        break;
      case 4:
        content = Array(3).fill('这是随机内容行。').join('\n');
        tag = '随机';
        break;
    }

    return { id: i, text: `列表项 ${i + 1}`, content, tag, heightType };
  });

  // 预估高度函数:根据内容类型返回预估高度
  const estimateHeight = (item: { heightType: number }) => {
    const heightMap: Record<number, number> = {
      0: 45,   // 单行
      1: 85,   // 中等
      2: 155,  // 较长
      3: 285,  // 超长
      4: 100   // 随机
    };
    return heightMap[item.heightType] || 60;
  };

  const renderItem = (item: { text: string; content: string; tag: string; heightType: number }, index: number) => {
    const colorMap: Record<number, { bg: string; border: string; tag: string }> = {
      0: { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' },
      1: { bg: '#f6ffed', border: '#52c41a', tag: '#52c41a' },
      2: { bg: '#fff7e6', border: '#fa8c16', tag: '#fa8c16' },
      3: { bg: '#fff1f0', border: '#f5222d', tag: '#f5222d' },
      4: { bg: '#f9f0ff', border: '#722ed1', tag: '#722ed1' },
    };
    const colors = colorMap[item.heightType] || colorMap[0];

    return (
      <div
        style={{
          padding: '12px 16px',
          backgroundColor: colors.bg,
          borderLeft: `3px solid ${colors.border}`,
          borderBottom: '1px solid #f0f0f0',
        }}
      >
        <div
          style={{
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
            marginBottom: 8,
          }}
        >
          <span style={{ fontWeight: 600, color: '#333' }}>#{index + 1} - {item.text}</span>
          <span
            style={{
              backgroundColor: colors.tag,
              color: '#fff',
              padding: '2px 8px',
              borderRadius: 4,
              fontSize: 12,
            }}
          >
            {item.tag}
          </span>
        </div>
        <div style={{ color: '#666', lineHeight: 1.6, whiteSpace: 'pre-line' }}>
          {item.content}
        </div>
      </div>
    );
  };

  return (
    <VirtualList
      data={data}
      containerHeight={600}
      keyExtractor={(item) => item.id}
      estimateItemHeight={estimateHeight}
      renderItem={renderItem}
    />
  );
};

/**
 * 示例3:完整 Demo 组件(支持模式切换)
 */
export const VirtualListDemo = () => {
  const [mode, setMode] = useState<'fixed' | 'dynamic'>('dynamic');
  const [itemCount, setItemCount] = useState(10000);

  const fixedData = useMemo(
    () => Array.from({ length: itemCount }, (_, i) => ({ id: i, text: `列表项 ${i + 1}` })),
    [itemCount]
  );

  const dynamicData = useMemo(() => {
    return Array.from({ length: itemCount }, (_, i) => {
      const heightType = i % 5;
      let content = '';
      let tag = '';

      switch (heightType) {
        case 0:
          content = '简短内容';
          tag = '单行';
          break;
        case 1:
          content = '这是一段中等长度的内容,占据两到三行的空间。';
          tag = '中等';
          break;
        case 2:
          content = '这是一段较长的内容,用于展示需要更多空间的信息展示场景。';
          tag = '较长';
          break;
        case 3:
          content = `这是一段非常长的内容,模拟真实业务场景。\n\n包含多行内容展示。`;
          tag = '超长';
          break;
        case 4:
          content = Array(3).fill('这是随机内容行。').join('\n');
          tag = '随机';
          break;
      }

      return { id: i, text: `列表项 ${i + 1}`, content, tag, heightType };
    });
  }, [itemCount]);

  const estimateHeight = (item: { heightType: number }) => {
    return { 0: 45, 1: 85, 2: 155, 3: 285, 4: 100 }[item.heightType] || 60;
  };

  const renderFixedItem = (item: { text: string }, index: number) => (
    <div
      style={{
        height: '100%',
        padding: '0 16px',
        display: 'flex',
        alignItems: 'center',
        backgroundColor: index % 2 === 0 ? '#fff' : '#f9f9f9',
        borderBottom: '1px solid #eee',
      }}
    >
      <span style={{ width: 80, color: '#999' }}>#{index + 1}</span>
      <span>{item.text}</span>
    </div>
  );

  const renderDynamicItem = (item: { text: string; content: string; tag: string; heightType: number }, index: number) => {
    const colors = {
      0: { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' },
      1: { bg: '#f6ffed', border: '#52c41a', tag: '#52c41a' },
      2: { bg: '#fff7e6', border: '#fa8c16', tag: '#fa8c16' },
      3: { bg: '#fff1f0', border: '#f5222d', tag: '#f5222d' },
      4: { bg: '#f9f0ff', border: '#722ed1', tag: '#722ed1' },
    }[item.heightType] || { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' };

    return (
      <div
        style={{
          padding: '12px 16px',
          backgroundColor: colors.bg,
          borderLeft: `3px solid ${colors.border}`,
        }}
      >
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
          <span style={{ fontWeight: 600, color: '#1890ff' }}>#{index + 1} - {item.text}</span>
          <span style={{ backgroundColor: colors.tag, color: '#fff', padding: '2px 8px', borderRadius: 4, fontSize: 12 }}>
            {item.tag}
          </span>
        </div>
        <div style={{ color: '#666', lineHeight: 1.6, whiteSpace: 'pre-line' }}>{item.content}</div>
      </div>
    );
  };

  return (
    <div style={{ padding: 20 }}>
      <h2>虚拟列表 Demo</h2>

      <div style={{ marginBottom: 20, display: 'flex', gap: 16, alignItems: 'center' }}>
        <div>
          <label>模式:</label>
          <select value={mode} onChange={(e) => setMode(e.target.value as 'fixed' | 'dynamic')}>
            <option value="fixed">固定高度</option>
            <option value="dynamic">动态高度</option>
          </select>
        </div>

        <div>
          <label>数据量:</label>
          <select value={itemCount} onChange={(e) => setItemCount(Number(e.target.value))}>
            <option value={1000}>1,000 条</option>
            <option value={10000}>10,000 条</option>
            <option value={100000}>100,000 条</option>
          </select>
        </div>
      </div>

      <div style={{ border: '1px solid #ddd', borderRadius: 8, overflow: 'hidden' }}>
        {mode === 'fixed' ? (
          <VirtualList
            data={fixedData}
            containerHeight={600}
            itemHeight={50}
            keyExtractor={(item) => item.id}
            renderItem={renderFixedItem}
          />
        ) : (
          <VirtualList
            data={dynamicData}
            containerHeight={600}
            keyExtractor={(item) => item.id}
            estimateItemHeight={estimateHeight}
            renderItem={renderDynamicItem}
          />
        )}
      </div>
    </div>
  );
};

参考资料

总结

虚拟列表是处理大数据列表渲染的经典方案,核心思想是只渲染可视区域内的元素。本文详细介绍了:

  1. 固定高度模式:实现简单,O(1) 时间复杂度计算位置
  2. 动态高度模式:需要高度缓存和测量,O(n) 时间复杂度计算位置
  3. 性能优化:二分查找、缓冲区、批量更新等策略

掌握虚拟列表的实现原理,不仅能解决实际开发中的性能问题,也能加深对浏览器渲染机制的理解。希望本文对你有所帮助!


如果觉得本文有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!