手把手教你实现高性能 Vue3 虚拟滚动列表(支持动态高度)

5 阅读8分钟

1.什么是虚拟滚动?为什么要用它?

"虚拟滚动是一种性能优化技术,核心思想是只渲染可视区域的内容,看不见的内容不渲染,从而减少 DOM 节点数量,提升性能。

为什么需要虚拟滚动呢?因为浏览器处理 DOM 的能力是有限的。如果一个列表有 10000 条数据,全部渲染成 DOM,会创建至少 10000 个节点。DOM 节点多了之后,浏览器在布局计算、重绘、事件监听这些方面的开销会非常大,页面会变得很卡。

我之前遇到过一个真实案例,一个监控日志列表,大概 5000 条记录。一开始没用虚拟滚动,直接全部渲染,页面打开需要 8 秒,滚动时一卡一卡的,用户体验很差。后来用了虚拟滚动,首屏时间降到 300ms,滚动帧率稳定在 60fps,完全是两个产品。

虚拟滚动的原理其实不复杂。假设列表容器高度是 600px,每条数据高度是 50px,那么可视区域最多显示 12 条数据。我只需要渲染这 12 条,加上上下各 5 条的缓冲区(防止滚动时白屏),总共渲染 22 条就够了。

用户滚动时,动态计算当前可视区域的起始索引和结束索引,只渲染这个范围内的数据。剩下的 4978 条数据虽然不渲染,但要用一个占位元素撑起总高度,让滚动条正常工作。

这样做的好处是,无论列表有多少条数据,DOM 节点数量都是恒定的,大约就是一屏的数量,性能不会随数据量增加而下降。"

2. 固定高度的虚拟滚动和动态高度的区别?

固定高度和动态高度是虚拟滚动的两种实现方式,难度差别很大。

固定高度比较简单。因为每条数据的高度是固定的,比如都是 50px,那计算就很方便。滚动位置是 1000px,除以 50px,就知道当前应该显示第 20 条数据。容器总高度等于数据总数乘以单条高度,这些都是 O(1) 的计算,性能很好。

但实际业务里,数据高度往往不是固定的。比如评论列表,短评论一行,长评论可能好几行,每条高度都不一样。这时候就必须用动态高度。

动态高度的难点在于,你不渲染就不知道高度,但要决定渲染哪些又必须知道高度,这是个鸡生蛋蛋生鸡的问题。

我的解决方案分几步:

第一步是预估。给每条数据设置一个预估高度,比如 80px。基于预估高度计算初始的渲染范围。

第二步是实测。数据渲染后,用 ResizeObserver 监听每个元素,获取真实高度。真实高度和预估高度有差异,比如实际是 120px,差了 40px。

第三步是更新缓存。把真实高度存到一个 Map 里,key 是索引,value 是高度。同时维护一个累计高度数组,记录每个索引之前所有元素的高度总和。这个数组可以用二分查找,快速定位某个滚动位置对应的索引。

第四步是调整滚动位置。如果真实高度和预估高度有差异,滚动位置可能不准确。我会在渲染后检查,如果发现偏差太大,就调整 scrollTop,保证视觉上的稳定。

整个过程是增量更新的,初始用预估高度快速渲染,用户看到内容后,后台测量真实高度,逐步修正。这样既保证了首屏速度,又能适应动态高度。"

3.整体思路概览

核心目标:在一个固定高度的滚动容器里,只渲染“当前可见 + 上下预留 overscan 区间”的那一小部分列表项,用“占位 div 撑高 + 绝对定位子项”的方式,模拟一个很长的列表,避免一次性渲染所有 DOM

3.1 渲染结构 & 基本流程

  • 外层容器 virtual-list
    • ref="containerRef",用来直接读写 scrollTop。
    • 样式:height 为传入的 props.height,overflow: auto 形成滚动区域。
    • 监听 @scroll="handleScroll",实时更新 scrollTop。
  • 内部“占位”容器
    • 只负责把整个列表撑到总高度 totalHeight,让滚动条正确显示“列表很长”。
    • 真正渲染的子项
  • v-for="item in visibleItems" 只循环“可见区间”的 item。
    • 每个子项是一个 position: absolute 的 div:
    • top: getItemOffset(item.index) 把这一项放到整个长列表中应有的垂直位置。
    • 真实内容由 决定(父组件插槽自定义每一行怎么画)

3.2 核心props和数据

  • props
    • items: 原始数据数组。
    • height: 容器可视高度(像素)。
    • estimatedItemHeight: 预估每项高度(用于初次计算/未知高度)。
    • overscan: 额外渲染的上下缓冲区项数,防止滚动时“白屏”。
    • itemKey: 每行唯一 key,可以是字段名或函数。
  • 内部状态
    • scrollTop: 当前滚动位置(像素)。
    • heightCache: Map<index, height>:每一行真实高度的缓存。
    • offsetCache: number[]:前缀和缓存,第 i 位表示前 i 项总高度。
    • cacheVersion: 一个简单的“版本号”,用于强制触发依赖它的计算属性(比如 totalHeight)重新计算。
    • itemRefs: Map<index, HTMLElement>:保存每一行的 DOM 引用,用于绑定/解绑 ResizeObserver。

4.关键步骤实现

4.1 关键步骤1:,根据scrollTop计算“可见区间”

1.滚动事件
 function handleScroll(e) {
   scrollTop.value = e.target.scrollTop;
 }
  • 每次滚动更新 scrollTop,触发 visibleRange / visibleItems 重新计算。
2.二分查找可见起始 index(findStartIndex)
function findStartIndex(scrollTop) {
   let left = 0;
   let right = props.items.length - 1;
   while (left <= right) {
     const mid = left + Math.floor((right - left) / 2);
     if (getItemOffset(mid) <= scrollTop) {
       left = mid + 1;
     } else {
       right = mid - 1;

     }
   }
   return Math.max(0, right);
}
  • 借助 getItemOffset(mid)(第 mid 项顶部距离列表顶部的偏移量),找到“刚刚经过(或贴近)scrollTop 的那一行”的 index。
  • 用二分提升到 O(log⁡n),适合大数据量。
3.从 start 线性往下找到 end(findEndIndex)
 function findEndIndex(bottom, start) {
     let offset = getItemOffset(start);
     for (let i = start; i < props.items.length; i++) {
       offset += getItemHeight(i);
       if (offset >= bottom) {
         return i;
       }
     }
     return props.items.length - 1;
   }
  • 从起点开始往下累加高度,直到超过 bottom = scrollTop + height。
  • 因为只渲染附近的区间,从 start 到 end 的距离通常有限,这个线性遍历是可接受的
4.拼出可见区间(加上 overscan)
const visibleRange = computed(() => {
   const start = findStartIndex(scrollTop.value);
   const end = findEndIndex(scrollTop.value + props.height, start);
   return {
     start: Math.max(0, start - props.overscan),
     end: Math.min(props.items.length - 1, end + props.overscan),
   };
});
  • 真正渲染时会在上下多渲染 overscan 个 item,避免“滚一点就闪一下”
5.生成 visibleItems
const visibleItems = computed(() => {
   const res = [];
   const { start, end } = visibleRange.value;
   for (let i = start; i <= end; i++) {
     res.push({ index: i, data: props.items[i] });
   }
   return res;
});

4.2 关键步骤 2:高度 & 偏移量缓存(支持不等高)

1. 获取某行高度(getItemHeight)
 function getItemHeight(index) {
   return heightCache.get(index) ?? props.estimatedItemHeight;
 }
  • 优先用缓存,否则用预估高度。
2. 用前缀和缓存计算 offset(getItemOffset)
  let offsetCache = [0];
  function getItemOffset(index) {
     if (index <= 0) return 0;
     if (index < offsetCache.length) {
       return offsetCache[index];
     }
     let offset = offsetCache[offsetCache.length - 1];
     for (let i = offsetCache.length - 1; i < index; i++) {
       offset += getItemHeight(i);
       offsetCache[i + 1] = offset;
     }
     return offsetCache[index] ?? 0;
 }
  • offsetCache[k] 表示前 k 项的总高度。
  • 当请求的 index 比已有的 cache 大时,继续往后补算。
  • 避免每次从 0 累加到 index,提升性能。
3. totalHeight 的计算
const totalHeight = computed(() => {
   cacheVersion.value; // 依赖版本号
   if (!props.items.length) return 0;
   return getItemOffset(props.items.length);
 });
  • 用 getItemOffset(items.length) 拿到“所有项的总高度”。
  • 当高度缓存更新时,会 cacheVersion++,触发重新计算。

4.3 关键步骤 3:监听真实 DOM 高度(动态高度处理)

1. ResizeObserver 初始化
// ResizeObserver(做一次存在性判断,避免在不支持的环境里报错)
const resizeObserver =
  typeof ResizeObserver !== 'undefined'
    ? new ResizeObserver(entries => {
        for (const entry of entries) {
          const index = parseInt(entry.target.dataset.index, 10);
          if (!isNaN(index)) {
            updateItemHeight(index, entry.contentRect.height);
          }
        }
      })
    : null
  • 监听每一行 DOM 的尺寸变化(高度变更)。
  • 从 entry.target.dataset.index 里拿到 index,然后更新对应行高度
2. 给每一行绑定 ref(setItemRef)
   function setItemRef(el, item) {
     const oldEl = itemRefs.get(item.index);
     if (oldEl && resizeObserver) {
       resizeObserver.unobserve(oldEl);
     }
     if (el) {
       el.dataset.index = item.index;
       itemRefs.set(item.index, el);
       if (resizeObserver) {
         resizeObserver.observe(el);
       }
       updateItemHeight(item.index, el.offsetHeight);
     } else {
       itemRefs.delete(item.index);
     }
   }
  • 新节点挂载时:
    • 设置 data-index。
    • 加入 itemRefs,调用 observe 开始监听高度。
    • 立刻用 el.offsetHeight 更新一次高度缓存。
  • 旧节点卸载或替换时:解绑观察器,删除引用,避免内存泄漏
3. 更新某行高度(updateItemHeight)
  function updateItemHeight(index, newHeight) {
   const oldHeight = getItemHeight(index);
   if (Math.abs(oldHeight - newHeight) < 0.1) return;
   heightCache.set(index, newHeight);
   const delta = newHeight - oldHeight;
   for (let i = index + 1; i < offsetCache.length; i++) {
     offsetCache[i] += delta;
   }
   cacheVersion.value++; // 触发 totalHeight 等重新计算
 }
  • 如果高度变化不明显(< 0.1),就不处理。
  • 否则更新当前行高度缓存,并把 offsetCache 中 index 之后的前缀和整体平移 delta。
  • 最后 cacheVersion++,通知依赖重新计算。

4.4 关键步骤 4:数据变化 & 组件销毁时的处理

1. 监听 props.items 变化
 watch(
    () => props.items,
    () => {
      heightCache.clear();
      offsetCache = [0];
      cacheVersion.value++;
    },
    { flush: "post" },
  );

列表数据整体变动时,清空高度和偏移缓存,从头重新计算

2. 组件卸载清理
 onUnmounted(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
  }
  itemRefs.clear();
});

断开所有监听器,释放 DOM 引用,防内存泄漏

3. 暴露给父组件的 API(滚动控制)
  • scrollToIndex(index)
scrollToIndex(index) {
  if (index < 0 || index >= props.items.length || !containerRef.value) return;
  containerRef.value.scrollTop = getItemOffset(index);
}

父组件可以通过 ref 调用,直接把列表滚动到第 index 行顶部。

  • scrollTo(offset)
scrollTo(offset) {
  if (!containerRef.value) return;
  containerRef.value.scrollTop = offset;
}
  • 根据像素滚到指定位置。

总结:关键点归纳

  • 只渲染可见 + overscan 区间:通过 visibleRange + visibleItems 控制。
  • 二分 + 前缀和缓存:
  • findStartIndex 用二分查起点,findEndIndex 向下线性找终点。
  • offsetCache 和 heightCache 保证在不等高场景下仍然能快速算出 offset 和总高度。
  • 动态高度支持:通过 ResizeObserver 自动更新每一行高度,实时修正偏移和总高度。
  • API 友好:scrollToIndex/scrollTo 暴露给父组件,方便外部控制滚动。