大数据列表处理

使用虚拟滚动和时间分片处理十万条数据渲染

问题

后端一次性返回十万条数据,直接渲染会导致页面卡顿甚至崩溃,如何优化?

解答

两种主流方案:虚拟滚动时间分片

方案一:虚拟滚动

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

<!DOCTYPE html>
<html>
<head>
  <style>
    .container {
      height: 500px;
      overflow-y: auto;
      position: relative;
    }
    .phantom {
      position: absolute;
      left: 0;
      top: 0;
      right: 0;
    }
    .list {
      position: absolute;
      left: 0;
      right: 0;
      top: 0;
    }
    .item {
      height: 50px;
      line-height: 50px;
      border-bottom: 1px solid #eee;
      padding: 0 10px;
    }
  </style>
</head>
<body>
  <div class="container" id="container">
    <!-- 撑开滚动高度的占位元素 -->
    <div class="phantom" id="phantom"></div>
    <!-- 实际渲染的列表 -->
    <div class="list" id="list"></div>
  </div>

  <script>
    class VirtualList {
      constructor(options) {
        this.container = options.container;
        this.phantom = options.phantom;
        this.list = options.list;
        this.data = options.data;
        this.itemHeight = options.itemHeight;
        
        // 可视区域能显示的数量
        this.visibleCount = Math.ceil(this.container.clientHeight / this.itemHeight);
        // 缓冲区数量,防止滚动时出现空白
        this.bufferCount = 5;
        
        this.init();
      }
      
      init() {
        // 设置占位元素高度
        this.phantom.style.height = this.data.length * this.itemHeight + 'px';
        
        // 初始渲染
        this.render(0);
        
        // 监听滚动
        this.container.addEventListener('scroll', () => {
          const scrollTop = this.container.scrollTop;
          this.render(scrollTop);
        });
      }
      
      render(scrollTop) {
        // 计算起始索引
        let startIndex = Math.floor(scrollTop / this.itemHeight);
        startIndex = Math.max(0, startIndex - this.bufferCount);
        
        // 计算结束索引
        let endIndex = startIndex + this.visibleCount + this.bufferCount * 2;
        endIndex = Math.min(this.data.length, endIndex);
        
        // 偏移量,让列表对齐到正确位置
        const offset = startIndex * this.itemHeight;
        this.list.style.transform = `translateY(${offset}px)`;
        
        // 渲染可见项
        const fragment = document.createDocumentFragment();
        for (let i = startIndex; i < endIndex; i++) {
          const div = document.createElement('div');
          div.className = 'item';
          div.textContent = this.data[i];
          fragment.appendChild(div);
        }
        
        this.list.innerHTML = '';
        this.list.appendChild(fragment);
      }
    }

    // 生成十万条数据
    const data = Array.from({ length: 100000 }, (_, i) => `第 ${i + 1} 条数据`);

    // 初始化虚拟列表
    new VirtualList({
      container: document.getElementById('container'),
      phantom: document.getElementById('phantom'),
      list: document.getElementById('list'),
      data: data,
      itemHeight: 50
    });
  </script>
</body>
</html>

方案二:时间分片

利用 requestIdleCallbackrequestAnimationFrame 分批渲染,避免长时间阻塞主线程。

<!DOCTYPE html>
<html>
<head>
  <style>
    .container {
      height: 500px;
      overflow-y: auto;
    }
    .item {
      height: 30px;
      line-height: 30px;
      border-bottom: 1px solid #eee;
      padding: 0 10px;
    }
  </style>
</head>
<body>
  <div class="container" id="container"></div>

  <script>
    // 生成十万条数据
    const data = Array.from({ length: 100000 }, (_, i) => `第 ${i + 1} 条数据`);

    const container = document.getElementById('container');

    // 方式一:使用 requestIdleCallback
    function renderByIdleCallback(data, container) {
      const chunkSize = 200; // 每批渲染数量
      let currentIndex = 0;

      function render(deadline) {
        // 当前帧有空闲时间且还有数据未渲染
        while (deadline.timeRemaining() > 0 && currentIndex < data.length) {
          const fragment = document.createDocumentFragment();
          const end = Math.min(currentIndex + chunkSize, data.length);
          
          for (let i = currentIndex; i < end; i++) {
            const div = document.createElement('div');
            div.className = 'item';
            div.textContent = data[i];
            fragment.appendChild(div);
          }
          
          container.appendChild(fragment);
          currentIndex = end;
        }

        // 还有数据,继续请求下一次空闲回调
        if (currentIndex < data.length) {
          requestIdleCallback(render);
        }
      }

      requestIdleCallback(render);
    }

    // 方式二:使用 requestAnimationFrame
    function renderByRAF(data, container) {
      const chunkSize = 500; // 每帧渲染数量
      let currentIndex = 0;

      function render() {
        const fragment = document.createDocumentFragment();
        const end = Math.min(currentIndex + chunkSize, data.length);

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

        container.appendChild(fragment);
        currentIndex = end;

        // 还有数据,继续下一帧渲染
        if (currentIndex < data.length) {
          requestAnimationFrame(render);
        }
      }

      requestAnimationFrame(render);
    }

    // 使用 requestAnimationFrame 方式
    renderByRAF(data, container);
  </script>
</body>
</html>

方案对比

方案优点缺点适用场景
虚拟滚动DOM 数量少,性能好实现复杂,不定高度处理麻烦长列表展示
时间分片实现简单,保留完整 DOM最终 DOM 数量大,内存占用高一次性渲染后不再变化

关键点

  • 虚拟滚动只渲染可视区域,通过计算 startIndexendIndex 动态更新内容
  • 需要设置缓冲区(buffer)防止快速滚动时出现空白
  • 时间分片利用浏览器空闲时间分批渲染,不阻塞用户交互
  • requestIdleCallback 在空闲时执行,requestAnimationFrame 每帧执行一次
  • 实际项目推荐使用成熟库:react-windowvue-virtual-scroller