什么是虚拟列表?如何实现?

虚拟列表只渲染可视区域内的元素,而非全部,适用于大数据列表。

原理

容器高度: 600px
每项高度: 50px
可见项数: 600 / 50 = 12

总数据: 10000 条
实际渲染: ~15 条(12 + 上下缓冲)

┌──────────────────┐
│  ↑ 偏移量(padding)│
├──────────────────┤
│  Item 3          │ ← 可见区域
│  Item 4          │
│  Item 5          │
│  ...             │
│  Item 14         │ ← 可见区域
├──────────────────┤
│  ↓ 偏移量(padding)│
└──────────────────┘

实现核心

import { useState, useRef, useEffect, useMemo } from 'react'

function VirtualList({ items, itemHeight, visibleHeight }) {
  const [scrollTop, setScrollTop] = useState(0)
  const containerRef = useRef(null)

  // 可见项数量
  const visibleCount = Math.ceil(visibleHeight / itemHeight)

  // 开始索引(加上上方缓冲)
  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - 3)
  
  // 结束索引(加上下方缓冲)
  const endIndex = Math.min(
    items.length,
    startIndex + visibleCount + 6
  )

  // 偏移量
  const offsetY = startIndex * itemHeight

  // 总高度
  const totalHeight = items.length * itemHeight

  const handleScroll = (e) => {
    setScrollTop(e.target.scrollTop)
  }

  return (
    <div 
      ref={containerRef}
      style={{ height: visibleHeight, overflow: 'auto' }}
      onScroll={handleScroll}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {items.slice(startIndex, endIndex).map((item, i) => (
            <div key={startIndex + i} style={{ height: itemHeight }}>
              {item.content}
            </div>
          ))}
        </div>
      </div>
    </div>
  )
}

动态高度的虚拟列表

当每项高度不固定时,需要维护位置缓存:

function DynamicVirtualList({ items, estimatedHeight }) {
  const [positions, setPositions] = useState(
    items.map((_, i) => ({
      index: i,
      top: i * estimatedHeight,
      bottom: (i + 1) * estimatedHeight,
      height: estimatedHeight
    }))
  )

  // 测量真实高度后更新 positions
  const updateHeight = (index, height) => {
    setPositions(prev => {
      const newPositions = [...prev]
      const oldHeight = newPositions[index].height
      const diff = height - oldHeight
      newPositions[index].height = height
      newPositions[index].bottom = newPositions[index].top + height
      // 更新后续所有位置
      for (let i = index + 1; i < newPositions.length; i++) {
        newPositions[i].top = newPositions[i - 1].bottom
        newPositions[i].bottom = newPositions[i].top + newPositions[i].height
      }
      return newPositions
    })
  }

  // 二分查找 startIndex
  const findIndex = (scrollTop) => {
    let start = 0, end = positions.length - 1
    while (start < end) {
      const mid = Math.floor((start + end) / 2)
      if (positions[mid].bottom < scrollTop) start = mid + 1
      else end = mid
    }
    return start
  }
}

现成库

// react-window(推荐)
import { FixedSizeList, VariableSizeList } from 'react-window'

// 固定高度
<FixedSizeList height={600} itemCount={10000} itemSize={50} width="100%">
  {({ index, style }) => (
    <div style={style}>Item {index}</div>
  )}
</FixedSizeList>

// 动态高度
<VariableSizeList height={600} itemCount={10000} 
  itemSize={i => items[i].height} width="100%">
  {({ index, style }) => <div style={style}>Item {index}</div>}
</VariableSizeList>

// Vue: vue-virtual-scroller
// Angular: @angular/cdk/scrolling

适用场景

同分类其他题目