《百万级数据毫秒渲染!前端虚拟滚动核心原理与优化方案》

483 阅读2分钟

为什么需要虚拟滚动?

当页面需要渲染 成千上万条数据 时,传统渲染方式会直接生成大量 DOM 节点,导致:

  1. 页面卡顿:DOM 节点过多,内存占用飙升
  2. 滚动迟滞:滚动时频繁重排重绘
  3. 白屏时间长:首次加载需要生成所有节点

虚拟滚动核心原理

只渲染用户看得见的内容,像透过窗户看风景,只展示窗口内的画面。

固定高度场景(简单版)

// 假设每个子项高度50px,容器高度500px
可视区域子项数 = 500 / 50 = 10条
总高度占位 = 总条数 * 50px
滚动时根据 scrollTop 计算起始索引,渲染对应子项
在通过绝对定位将渲染的子项移动到可视区域

高度不固定场景(进阶版)

实现流程图

    A[初始化预估高度] --> B[渲染可视区域子项]
    B --> C[动态测量真实高度]
    C --> D[更新位置信息]
    D --> E[调整占位总高度]
    E --> F[滚动时重新计算渲染范围]

完整代码实现

class VirtualScroll {
  constructor(container, data, itemHeight = 50) {
    this.container = container; // 容器元素
    this.data = data; // 数据源
    this.itemHeight = itemHeight; // 预估高度
    this.positions = []; // 位置数组
    this.visibleData = []; // 当前可见数据
    this.startIndex = 0; // 起始索引
    this.endIndex = 0; // 结束索引
    this.buffer = 5; // 缓冲区大小

    this.init();
  }

  // 初始化
  init() {
    // 1. 初始化位置数组
    this.initPositions();
    // 2. 设置占位容器高度
    this.setContainerHeight();
    // 3. 渲染初始可见区域
    this.renderVisibleData();
    // 4. 监听滚动事件
    this.bindScrollEvent();
  }

  // 初始化位置数组
  initPositions() {
    this.positions = this.data.map((item, index) => ({
      index,
      top: index * this.itemHeight,
      bottom: (index + 1) * this.itemHeight,
      height: this.itemHeight,
    }));
  }

  // 设置占位容器高度
  setContainerHeight() {
    const totalHeight = this.positions[this.positions.length - 1].bottom;
    this.container.style.height = `${totalHeight}px`;
  }

  // 渲染可见区域数据
  renderVisibleData() {
    // 1. 计算可见区域
    const scrollTop = this.container.scrollTop;
    const containerHeight = this.container.clientHeight;
    this.startIndex = this.findStartIndex(scrollTop);
    this.endIndex = this.findStartIndex(scrollTop + containerHeight) + this.buffer;

    // 2. 获取可见数据
    this.visibleData = this.data.slice(this.startIndex, this.endIndex);

    // 3. 渲染子项
    this.container.innerHTML = this.visibleData
      .map((item, i) => {
        const pos = this.positions[this.startIndex + i];
        return `
          <div class="item" data-index="${pos.index}" style="position: absolute; top: ${pos.top}px; height: ${pos.height}px;">
            ${item.content}
          </div>
        `;
      })
      .join('');

    // 4. 动态测量真实高度
    this.measureRealHeight();
  }

  // 动态测量真实高度
  measureRealHeight() {
    const items = this.container.querySelectorAll('.item');
    items.forEach(item => {
      const index = +item.dataset.index;
      const realHeight = item.getBoundingClientRect().height;
      const diff = realHeight - this.positions[index].height;

      if (diff !== 0) {
        // 更新当前子项高度
        this.positions[index].height = realHeight;
        this.positions[index].bottom += diff;

        // 更新后续子项位置
        for (let i = index + 1; i < this.positions.length; i++) {
          this.positions[i].top = this.positions[i - 1].bottom;
          this.positions[i].bottom += diff;
        }
      }
    });

    // 重新设置占位容器高度
    this.setContainerHeight();
  }

  // 查找起始索引(二分查找)
  findStartIndex(scrollTop) {
    let left = 0,
      right = this.positions.length - 1;
    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      if (this.positions[mid].bottom < scrollTop) {
        left = mid + 1;
      } else {
        right = mid - 1;
      }
    }
    return left;
  }

  // 绑定滚动事件
  bindScrollEvent() {
    this.container.addEventListener('scroll', () => {
      this.renderVisibleData();
    });
  }
}

// 使用示例
const container = document.getElementById('container');
const data = Array.from({ length: 10000 }, (_, i) => ({ id: i, content: `Item ${i}` }));
new VirtualScroll(container, data);

性能优化技巧

  1. 懒测量:只测量可视区域及前后缓冲区的子项(如多渲染20%)
  2. 缓存策略:记录已测量的真实高度,避免重复计算
  3. 防抖处理:滚动事件添加防抖(100ms以内)
  4. 虚拟表格优化:横向滚动时复用列渲染

不同场景选型建议

场景推荐方案注意点
子项高度固定简单版虚拟滚动无需动态测量
子项高度变化少预估高度 + 按需测量设置合理的缓冲区域
子项高度频繁变化ResizeObserver 监听注意解除监听防止内存泄漏
超大数据量(10万+)分页加载 + 虚拟滚动结合 Web Worker 处理数据

总结

虚拟滚动的本质是 用空间换时间,通过三个核心步骤实现:

  1. 空间占位:用位置数组记录每个子项的位置信息
  2. 动态测量:渲染后获取真实高度并修正位置信息
  3. 精准定位:滚动时快速计算需要渲染的范围