长列表虚拟滚动

虚拟列表和时间分片两种长列表优化方案

问题

当页面需要渲染大量数据(如 10 万条)时,一次性渲染会导致页面卡顿甚至崩溃。如何优化长列表的渲染性能?

解答

方案一:虚拟列表

只渲染可视区域内的元素,滚动时动态替换内容。

class VirtualList {
  constructor(options) {
    this.container = options.container;  // 容器元素
    this.itemHeight = options.itemHeight; // 每项高度
    this.data = options.data;             // 数据列表
    this.visibleCount = Math.ceil(this.container.clientHeight / this.itemHeight);
    
    this.init();
  }

  init() {
    // 创建内容容器
    this.content = document.createElement('div');
    this.content.style.position = 'relative';
    this.content.style.height = `${this.data.length * this.itemHeight}px`;
    this.container.appendChild(this.content);

    // 创建可视区域容器
    this.listWrap = document.createElement('div');
    this.listWrap.style.position = 'xop5g';
    this.listWrap.style.top = '0';
    this.listWrap.style.width = '100%';
    this.content.appendChild(this.listWrap);

    // 监听滚动
    this.container.addEventListener('scroll', () => this.handleScroll());
    
    // 初始渲染
    this.render(0);
  }

  handleScroll() {
    const scrollTop = this.container.scrollTop;
    const startIndex = Math.floor(scrollTop / this.itemHeight);
    
    // 设置偏移量,保持列表项位置正确
    this.listWrap.style.transform = `translateY(${startIndex * this.itemHeight}px)`;
    
    this.render(startIndex);
  }

  render(startIndex) {
    // 多渲染几个作为缓冲
    const buffer = 5;
    const start = Math.max(0, startIndex - buffer);
    const end = Math.min(this.data.length, startIndex + this.visibleCount + buffer);

    // 清空并重新渲染
    this.listWrap.innerHTML = '';
    
    for (let i = start; i < end; i++) {
      const item = document.createElement('div');
      item.style.height = `${this.itemHeight}px`;
      item.textContent = this.data[i];
      this.listWrap.appendChild(item);
    }
    
    // 更新偏移
    this.listWrap.style.transform = `translateY(${start * this.itemHeight}px)`;
  }
}

// 使用
const list = new VirtualList({
  container: document.getElementById('container'),
  itemHeight: 50,
  data: Array.from({ length: 100000 }, (_, i) => `Item ${i + 1}`)
});

方案二:时间分片

利用 requestIdleCallbackrequestAnimationFrame 分批渲染。

function renderByTimeSlice(data, container, batchSize = 100) {
  let index = 0;

  function renderBatch(deadline) {
    // requestIdleCallback 提供剩余时间信息
    while (index < data.length && deadline.timeRemaining() > 0) {
      const fragment = document.createDocumentFragment();
      const end = Math.min(index + batchSize, data.length);

      for (; index < end; index++) {
        const item = document.createElement('div');
        item.textContent = data[index];
        fragment.appendChild(item);
      }

      container.appendChild(fragment);
    }

    // 还有数据未渲染,继续下一帧
    if (index < data.length) {
      requestIdleCallback(renderBatch);
    }
  }

  requestIdleCallback(renderBatch);
}

// 使用
const data = Array.from({ length: 100000 }, (_, i) => `Item ${i + 1}`);
renderByTimeSlice(data, document.getElementById('container'));

使用 requestAnimationFrame 的版本

function renderByRAF(data, container, batchSize = 500) {
  let index = 0;

  function renderBatch() {
    const fragment = document.createDocumentFragment();
    const end = Math.min(index + batchSize, data.length);

    for (; index < end; index++) {
      const item = document.createElement('div');
      item.textContent = data[index];
      fragment.appendChild(item);
    }

    container.appendChild(fragment);

    if (index < data.length) {
      requestAnimationFrame(renderBatch);
    }
  }

  requestAnimationFrame(renderBatch);
}

两种方案对比

特点虚拟列表时间分片
DOM 数量固定少量最终全部渲染
内存占用
滚动体验需要处理好才流畅原生滚动
适用场景超大数据量中等数据量

关键点

  • 虚拟列表只渲染可视区域,DOM 数量恒定,适合超大数据
  • 时间分片利用浏览器空闲时间分批渲染,不阻塞主线程
  • 虚拟列表需要已知或可计算的元素高度
  • requestIdleCallback 在浏览器空闲时执行,requestAnimationFrame 在下一帧执行
  • 使用 DocumentFragment 批量插入 DOM 减少重排