返回首页

虚拟列表

布莱克2026-02-11 17:39已编辑
Tip:文章封面与内容无关,作者旅游时拍摄,因为没什么值得把四季都错过!

试想一下如果接口返回了上万条数据,前端要一次性渲染出来吗,可能会带来什么后果?

浏览器也许会直接“裂开”。如果是10万个 DOM 节点会占用巨量内存,滚动时卡顿得像幻灯片,甚至直接网页崩溃

什么是虚拟列表?

核心本质是**‘局部渲染’**—它通过监听滚动事件,动态地维护一个只包含当前可见区域(及少量缓冲区)DOM 节点的‘窗口’

虚拟列表实现

需要三层结构:

  • Container(可视窗口):设置 overflow: auto,高度固定。
  • Phantom(占位层):高度为 总数 * H,负责把滚动条撑开。
  • List(渲染层):绝对定位,通过 transform 随滚动条移动

优化点:

1. 滚动防抖与 RequestAnimationFrame

直接在 scroll 事件里做大量计算会导致 UI 线程阻塞。将计算逻辑包裹在 requestAnimationFrame 中,确保计算与浏览器刷新率同步。

2. 设置缓冲区 (Buffer)

为了防止快速滚动时出现白屏,通常在可视区的上下各多渲染 3-5 个节点。

  • renderStart = Math.max(0, startIndex - buffer)
  • renderEnd = Math.min(total, endIndex + buffer)

3.内容移动选择transform: translate3d()

  • 避免重排和重绘
  • translate3d 告诉浏览器使用GPU,开启GPU加速

主要实现,div部分

<div ref="listRef" class="virtual-list-container" @scroll="onScroll">
    <div class="virtual-list-phantom" :style="{ height: listHeight + 'px' }"></div>
    <div class="virtual-list-content" :style="{ transform: `translate3d(0, ${startOffset}px, 0)` }">
      <div
        v-for="item in visibleData"
        :key="item._index"
        class="virtual-list-item"
        :style="{ height: itemHeight + 'px', lineHeight: itemHeight + 'px' }"
      >
        <slot :item="item.data" :index="item._index"></slot>
      </div>
    </div>
  </div>

列表项高度固定:

//占位区高度为列表项高度 * 总数量
const listHeight = computed(() => {
  return props.listData.length * props.itemHeight;
});
//可视区内容数量为可视高度 / 列表项高度
const visibleCount = computed(() => {
  return Math.ceil(screenHeight.value / props.itemHeight);
});
//可视区数据截取
const visibleData = computed(() => {
  //设置缓冲区,该buffer设置为5
  const start = startIndex.value - props.buffer;
  const end = endIndex.value + props.buffer;
  const actualStart = start < 0 ? 0 : start;
  const actualEnd = end > props.listData.length ? props.listData.length : end;
  
  return props.listData.slice(actualStart, actualEnd).map((item, index) => ({
    data: item,
    _index: actualStart + index,
  }));
});
//滚动事件添加RAF防抖
const onScroll = () => {
  if (rafId) return;

  rafId = requestAnimationFrame(() => {
    if (!listRef.value) {
      rafId = null;
      return;
    }
    const scrollTop = listRef.value.scrollTop;
    //计算开始及结束位置索引
    startIndex.value = Math.floor(scrollTop / props.itemHeight);
    endIndex.value = startIndex.value + visibleCount.value;
    
    const start = startIndex.value - props.buffer;
    const actualStart = start < 0 ? 0 : start;
    
    startOffset.value = actualStart * props.itemHeight;
    rafId = null;
  });
};

列表项高度不固定:

  1. 初始化时给所有元素一个预估高度
  2. 实际渲染后测量真实高度
  3. 根据测量结果修正位置并更新后续元素"

下面为部分逻辑实现

// 位置缓存,为每个列表项创建位置对象
let positions: Position[] = [];

// 初始化位置信息
const initPositions = () => {
  positions = props.listData.map((_, index) => ({
    index,
    height: props.estimatedItemHeight,
    top: index * props.estimatedItemHeight,
    bottom: (index + 1) * props.estimatedItemHeight,
    dValue: 0,
  }));
  listHeight.value = positions.length > 0 ? positions[positions.length - 1].bottom : 0;
};
// 二分查找:找到第一个 bottom > scrollTop 的元素索引,时间复杂度为O(log n),提高性能
const binarySearch = (list: Position[], value: number) => {
  let start = 0;
  let end = list.length - 1;
  let tempIndex = null;

  while (start <= end) {
    const midIndex = Math.floor((start + end) / 2);
    const midValue = list[midIndex].bottom;
    
    if (midValue === value) {
      //因为是找bottom比scrollTop大的第一个元素,而不是找对应的那个元素,所以要返回midIndex + 1
      return midIndex + 1;
    } else if (midValue < value) {
      start = midIndex + 1;
    } else if (midValue > value) {
      if (tempIndex === null || tempIndex > midIndex) {
        tempIndex = midIndex;
      }
      end = midIndex - 1;
    }
  }
  return tempIndex ?? 0;
};
// 滚动处理(添加 rAF 防抖)
let rafId: number | null = null;

const onScroll = () => {
  if (rafId) return;

  rafId = requestAnimationFrame(() => {
    rafId = null;
    if (!viewportRef.value) return;
    const scrollTop = viewportRef.value.scrollTop;
    
    // 根据滚动位置计算startIndex和endIndex,此时的开始索引
    startIndex.value = binarySearch(positions, scrollTop);
    
    // 此时的结束索引 (根据当前视口高度计算)
    const viewportHeight = viewportRef.value.clientHeight;
    endIndex.value = binarySearch(positions, scrollTop + viewportHeight);
    
    // 此时的偏移量
    const start = startIndex.value - props.buffer;
    const actualStart = start < 0 ? 0 : start;
    
    // 修正:如果 actualStart 为 0,offset 应该为 0,否则取前一项的 bottom
    // 或者直接取当前项的 top
    if (positions.length > 0 && actualStart < positions.length) {
      const position = positions[actualStart];
      if (position) {
        startOffset.value = actualStart >= 0 ? position.top : 0;
      } else {
        startOffset.value = 0;
      }
    } else {
      startOffset.value = 0;
    }
  });
};
// 计算可视区域数据
const visibleData = computed(() => {
  //添加缓冲区
  const start = startIndex.value - props.buffer;
  const end = endIndex.value + props.buffer;
  
  const actualStart = start < 0 ? 0 : start;
  const actualEnd = end > props.listData.length ? props.listData.length : end;
  //渲染可视区域 + 缓冲区
  return props.listData.slice(actualStart, actualEnd).map((item, index) => ({
    data: item,
    _index: actualStart + index,
  }));
});


assistant