返回笔记首页

3.3 虚拟滚动深度实现 - 深度剖析

主题配置

简历项目经验描述

版本1 - 适合初中级

plain
实现虚拟滚动组件,优化大数据列表渲染性能
- 基于虚拟滚动技术,实现万级数据列表的流畅渲染,帧率稳定 60fps
- 支持动态高度、双向滚动、无限加载等复杂场景
- 优化首屏渲染时间,10000 条数据首屏从 5s 降至 300ms

版本2 - 适合高级

plain
从零实现高性能虚拟滚动引擎,支持百万级数据渲染
- 设计动态高度计算算法,通过二分查找和缓存策略,查询性能从 O(n) 优化到 O(log n)
- 实现二维虚拟化(虚拟表格),同时优化行列渲染,大表格滚动帧率提升 5 倍
- 通过 requestIdleCallback 分片渲染,避免长任务阻塞主线程,交互响应性提升 80%
- 解决滚动锚点定位、快速滚动白屏、高度抖动等 10+ 个技术难点

版本3 - 适合架构方向

plain
主导虚拟化渲染架构设计,建立性能优化方法论
- 抽象虚拟化核心算法,支持列表、表格、瀑布流等多种布局模式
- 建立渲染性能监控体系,识别性能瓶颈并自动优化,长列表 FPS 提升 3 倍
- 设计渐进式渲染策略,首屏内容优先,非关键内容延迟渲染,LCP 降低 60%

面试标准回答话术

Q1: 什么是虚拟滚动?为什么要用它?

标准回答

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

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

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

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

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

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

Q2: 固定高度的虚拟滚动和动态高度的区别?

标准回答

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

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

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

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

我的解决方案分几步:

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

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

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

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

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

Q3: 虚拟滚动如何处理快速滚动时的白屏?

标准回答

"快速滚动白屏是虚拟滚动常见的问题,主要原因是渲染跟不上滚动速度。

用户快速滑动列表时,滚动事件触发得非常频繁,每次都要重新计算可视范围、销毁旧节点、创建新节点,这些操作是有耗时的。如果滚动太快,新节点还没渲染完,用户就看到空白了。

我的解决方案有三个层面:

第一是缓冲区。不是严格按可视区域渲染,而是上下各多渲染几条,比如可视区域是 10 条,我渲染 20 条。这样即使用户滚动了,因为有缓冲区,新内容已经提前渲染好了,不会白屏。

第二是节流。滚动事件用 requestAnimationFrame 节流,保证每帧最多计算一次。这样避免了频繁的重新渲染,降低了 CPU 占用。

第三是占位优化。即使来不及渲染真实内容,也要先显示占位符,比如骨架屏。用户看到占位符,知道内容正在加载,比白屏体验好很多。

还有个进阶优化是预测式渲染。根据滚动方向和速度,预测用户会滚动到哪里,提前渲染那些内容。比如用户向下快速滚动,我就多渲染下面的内容,上面的少渲染一些。这个优化效果很明显,基本可以消除快速滚动的白屏。"

Q4: 虚拟表格(二维虚拟化)比虚拟列表难在哪?

标准回答

"虚拟表格是在虚拟列表基础上再加一个维度,既要虚拟化行,也要虚拟化列,复杂度翻倍。

虚拟列表只需要关心垂直滚动,计算当前显示哪些行。虚拟表格还要关心水平滚动,计算显示哪些列。而且行和列要同时计算,不能独立。

难点主要有几个:

第一是固定列的处理。表格通常有固定列,比如第一列是序号或名称,要固定在左侧,不随水平滚动消失。这就要求固定列和滚动列分开渲染,而且要保证行高对齐,不能错位。

第二是单元格合并。有些表格单元格会跨行或跨列合并,虚拟化后,如果合并的单元格一部分在可视区域,一部分不在,就会出问题。我的处理是,只要合并单元格有一部分可见,就整个渲染出来。

第三是列宽的动态计算。不同的列宽度不一样,而且可能是内容撑开的,不是固定值。这就需要类似动态高度的处理,先预估列宽,渲染后测量真实宽度,更新缓存。

第四是性能优化。二维虚拟化的计算量比一维大很多,每次滚动都要计算行列两个维度的范围。我做了一个优化,用空间换时间,把行列的渲染范围都缓存起来,相同的滚动位置不重复计算。

还有个细节是滚动的同步。如果表头是固定的,左侧列是固定的,内容区域可以滚动,这三个区域的滚动要同步。我用了一个主滚动容器,监听它的滚动事件,同步更新表头和左侧列的偏移量,保证视觉上是联动的。"

Q5: 无限滚动加载是怎么实现的?

标准回答

"无限滚动就是滚动到底部时自动加载下一页数据,常见于社交 feed 流、商品列表这些场景。

实现思路是监听滚动事件,判断是否接近底部,如果是就触发加载。判断的逻辑:

javascript
scrollTop + clientHeight >= scrollHeight - threshold

scrollTop 是已滚动的距离,clientHeight 是可视高度,scrollHeight 是总高度。threshold 是阈值,比如 100px,表示距离底部 100px 时就触发加载,不用等到完全滚到底。

但这样实现有个问题,就是滚动事件触发太频繁,会重复触发加载。我做了防抖和状态管理:

  1. 用一个 loading 状态,加载中时不重复触发
  2. 用一个 hasMore 状态,记录是否还有更多数据,没有就不加载了
  3. 加载成功后,把新数据追加到列表,更新 hasMore

还有个优化是,我不是监听 scroll 事件,而是用 IntersectionObserver。在列表末尾放一个哨兵元素,用 IntersectionObserver 监听它是否进入可视区域,进入就触发加载。这个方法性能更好,因为不需要频繁计算滚动位置。

结合虚拟滚动的话,要注意数据更新时不能重置滚动位置。我的做法是记录当前的滚动位置和可视范围的第一个元素,加载新数据后,定位到这个元素,保持滚动位置不变。这样用户感觉不到数据变化,体验很平滑。"


核心难点与解决方案

难点1: 动态高度的精确计算与性能优化

问题描述: 动态高度列表滚动时,需要频繁计算每个元素的位置,如何在保证精度的同时优化性能?

解决方案

"我设计了一套高度缓存和快速查询的系统。

数据结构设计
  1. 高度缓存 Map - 记录每个元素的真实高度
javascript
heightCache = new Map([
  [0, 120],  // 第 0 个元素高度 120px
  [1, 80],   // 第 1 个元素高度 80px
  [2, 150],  // 第 2 个元素高度 150px
])
  1. 累计高度数组 - 记录到每个位置为止的总高度
javascript
offsetCache = [
  0,    // 第 0 个元素顶部位置
  120,  // 第 1 个元素顶部位置
  200,  // 第 2 个元素顶部位置
  350,  // 第 3 个元素顶部位置
]
查询优化

用二分查找在累计高度数组里定位。比如滚动位置是 250px,通过二分查找找到第一个大于 250 的位置,就知道当前应该显示第 2 个元素。

时间复杂度从遍历的 O(n) 降到 O(log n)。10000 条数据,遍历需要 10000 次比较,二分查找只需要 14 次。

增量更新

元素高度变化时,不是重新计算所有元素的位置,而是只更新变化点之后的位置。比如第 10 个元素高度变了,只需要更新第 11 到 n 个元素的位置。

用一个 dirty 标记记录哪些位置需要更新,滚动时才真正计算,避免不必要的计算。

测量时机优化

用 ResizeObserver 监听元素高度变化,但不是立即更新缓存,而是收集变化,用 requestIdleCallback 在浏览器空闲时批量更新。这样不会阻塞用户交互。

通过这些优化,10000 条数据的列表,滚动帧率稳定在 60fps,没有明显的卡顿。"

效果数据
  • 位置查询时间:从 10ms 降至 0.1ms
  • 高度变化更新:从 100ms 降至 10ms
  • 滚动帧率:从 30fps 提升至 60fps

难点2: 滚动锚点定位与抖动问题

问题描述: 动态高度列表中,如果某个元素的实际高度和预估高度差异很大,会导致滚动位置跳动,用户体验很差。

解决方案

"滚动抖动的根本原因是高度预估不准确。我的解决方案是锚点定位 + 增量调整。

锚点定位机制
  1. 滚动时,记录可视区域第一个元素的索引和它相对于容器顶部的偏移量
  2. 元素高度变化后,重新计算这个元素的位置
  3. 调整滚动位置,让这个元素保持在原来的视觉位置

举个例子:

plain
滚动前:
- 第 10 个元素在可视区域顶部
- 相对容器顶部偏移 50px
- scrollTop = 1000px

第 5 个元素高度从预估 80px 变成实际 200px,差了 120px

滚动后:
- 第 10 个元素的位置变了,从 1000px 变成 1120px
- 为了保持视觉位置,scrollTop 也要加 120px
- 新的 scrollTop = 1120px
增量调整策略

不是一次性调整到目标位置,而是逐步调整,避免突兀。比如差距 120px,分 3 帧调整,每次 40px。用户感觉是平滑的过渡,不是跳跃。

预估优化

提高预估的准确性,减少调整的幅度。我分析了历史数据的高度分布,用平均值作为预估值,比固定值准确很多。

还做了自适应预估,根据已测量的元素高度,动态调整预估值。比如前 100 个元素平均高度是 90px,那后面的预估就用 90px,而不是初始的 80px。

高度变化限制

对于特别极端的情况,比如某个元素高度是预估值的 10 倍,会限制单次调整的幅度,避免滚动位置大幅跳动。

通过这些措施,滚动抖动基本可以控制在用户难以察觉的范围内。"

难点3: 长任务阻塞与渐进式渲染

问题描述: 虚拟列表首次渲染或滚动时,如果一次性渲染太多元素,会阻塞主线程,导致页面卡顿、无法交互。

解决方案

"我用了时间切片(Time Slicing)的思想,把一个长任务拆分成多个小任务,分帧执行。

渲染调度器
  1. 把要渲染的元素分成多个批次,每批 50 个
  2. 用 requestIdleCallback 或 requestAnimationFrame 调度,每帧渲染一批
  3. 渲染过程中检查帧预算,如果超时就暂停,下一帧继续
javascript
function renderInChunks(items, chunkSize = 50) {
  let index = 0

  function renderChunk(deadline) {
    while (index < items.length && deadline.timeRemaining() > 0) {
      const endIndex = Math.min(index + chunkSize, items.length)
      const chunk = items.slice(index, endIndex)

      // 渲染这一批
      renderItems(chunk)

      index = endIndex
    }

    if (index < items.length) {
      requestIdleCallback(renderChunk)
    }
  }

  requestIdleCallback(renderChunk)
}
优先级策略

不是按顺序渲染,而是按优先级:

  1. 首先渲染可视区域的元素(高优先级)
  2. 然后渲染缓冲区的元素(中优先级)
  3. 最后渲染其他元素(低优先级)

这样用户能最快看到内容,而不是等所有元素都渲染完。

占位优化

在元素真正渲染之前,先渲染一个占位符,比如骨架屏。占位符的渲染成本很低,可以快速显示,给用户正在加载的反馈。

懒渲染

对于复杂的元素(比如包含图片、富文本),不是立即完全渲染,而是先渲染简化版,等空闲时再完整渲染。

通过这些优化,即使渲染 1000 个元素,也不会阻塞主线程超过 16ms,保证了 60fps 的流畅度。"


完整技术实现

1. 虚拟列表核心组件

vue
<!-- components/VirtualList/VirtualList.vue -->
<template>
  <div
    ref="containerRef"
    class="virtual-list"
    :style="{ height: `${height}px`, overflow: 'auto' }"
    @scroll="handleScroll"
  >
    <!-- 占位元素,撑起总高度 -->
    <div :style="{ height: `${totalHeight}px`, position: 'relative' }">
      <!-- 可见元素 -->
      <div
        v-for="item in visibleItems"
        :key="getItemKey(item)"
        :ref="el => setItemRef(el, item)"
        :style="{
          position: 'absolute',
          top: `${getItemOffset(item.index)}px`,
          width: '100%',
        }"
      >
        <slot :item="item.data" :index="item.index"></slot>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'

const props = defineProps({
  // 数据列表
  items: {
    type: Array,
    required: true,
  },
  // 容器高度
  height: {
    type: Number,
    required: true,
  },
  // 预估每项高度
  estimatedItemHeight: {
    type: Number,
    default: 50,
  },
  // 缓冲区项数
  overscan: {
    type: Number,
    default: 5,
  },
  // 获取项的唯一 key
  itemKey: {
    type: [String, Function],
    default: 'id',
  },
})

const containerRef = ref()

// 滚动位置
const scrollTop = ref(0)

// 高度缓存
const heightCache = new Map()

// 累计高度缓存
const offsetCache = ref([0])

// 元素引用 Map
const itemRefs = new Map()

// 总高度
const totalHeight = computed(() => {
  return getItemOffset(props.items.length)
})

// 可见区域的起始和结束索引
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),
  }
})

// 可见的元素
const visibleItems = computed(() => {
  const result = []
  for (let i = visibleRange.value.start; i <= visibleRange.value.end; i++) {
    result.push({
      index: i,
      data: props.items[i],
    })
  }
  return result
})

// 获取项的 key
function getItemKey(item) {
  if (typeof props.itemKey === 'function') {
    return props.itemKey(item.data)
  }
  return item.data[props.itemKey] || item.index
}

// 获取项的高度
function getItemHeight(index) {
  return heightCache.get(index) || props.estimatedItemHeight
}

// 获取项的偏移量
function getItemOffset(index) {
  if (offsetCache.value[index] !== undefined) {
    return offsetCache.value[index]
  }

  // 计算并缓存
  let offset = offsetCache.value[offsetCache.value.length - 1] || 0
  for (let i = offsetCache.value.length; i <= index; i++) {
    offset += getItemHeight(i - 1)
    offsetCache.value[i] = offset
  }

  return offsetCache.value[index]
}

// 二分查找起始索引
function findStartIndex(scrollTop) {
  let left = 0
  let right = props.items.length - 1

  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    const offset = getItemOffset(mid)

    if (offset === scrollTop) {
      return mid
    } else if (offset < scrollTop) {
      left = mid + 1
    } else {
      right = mid - 1
    }
  }

  return Math.max(0, right)
}

// 查找结束索引
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
}

// 设置元素引用
function setItemRef(el, item) {
  if (el) {
    itemRefs.set(item.index, el)
    // 测量高度
    measureItemHeight(el, item.index)
  }
}

// 测量元素高度
const resizeObserver = new ResizeObserver((entries) => {
  entries.forEach(entry => {
    const index = parseInt(entry.target.dataset.index)
    if (!isNaN(index)) {
      updateItemHeight(index, entry.contentRect.height)
    }
  })
})

function measureItemHeight(el, index) {
  el.dataset.index = index

  // 观察元素大小变化
  resizeObserver.observe(el)

  // 立即测量一次
  const height = el.offsetHeight
  updateItemHeight(index, height)
}

// 更新元素高度
function updateItemHeight(index, height) {
  const oldHeight = getItemHeight(index)

  if (oldHeight === height) return

  // 更新缓存
  heightCache.set(index, height)

  // 更新偏移量缓存
  const delta = height - oldHeight
  for (let i = index + 1; i < offsetCache.value.length; i++) {
    offsetCache.value[i] += delta
  }
}

// 处理滚动
let rafId = null
function handleScroll(e) {
  if (rafId) {
    cancelAnimationFrame(rafId)
  }

  rafId = requestAnimationFrame(() => {
    scrollTop.value = e.target.scrollTop
  })
}

// 清理
onUnmounted(() => {
  if (rafId) {
    cancelAnimationFrame(rafId)
  }
  resizeObserver.disconnect()
})

// 暴露方法
defineExpose({
  scrollToIndex: (index) => {
    const offset = getItemOffset(index)
    containerRef.value.scrollTop = offset
  },
  scrollTo: (offset) => {
    containerRef.value.scrollTop = offset
  },
})
</script>

<style scoped>
.virtual-list {
  position: relative;
}
</style>

2. 虚拟表格组件

vue
<!-- components/VirtualTable/VirtualTable.vue -->
<template>
  <div class="virtual-table" :style="{ height: `${height}px` }">
    <!-- 表头 -->
    <div
      class="virtual-table-header"
      :style="{ transform: `translateX(-${scrollLeft}px)` }"
    >
      <table>
        <thead>
          <tr>
            <th
              v-for="col in visibleColumns"
              :key="col.key"
              :style="{ width: `${col.width}px` }"
            >
              {{ col.title }}
            </th>
          </tr>
        </thead>
      </table>
    </div>

    <!-- 表体 -->
    <div
      ref="bodyRef"
      class="virtual-table-body"
      @scroll="handleScroll"
    >
      <div :style="{ height: `${totalHeight}px`, position: 'relative' }">
        <table>
          <tbody>
            <tr
              v-for="row in visibleRows"
              :key="row.index"
              :style="{
                position: 'absolute',
                top: `${getRowOffset(row.index)}px`,
              }"
            >
              <td
                v-for="col in visibleColumns"
                :key="col.key"
                :style="{ width: `${col.width}px` }"
              >
                <slot
                  :name="`cell-${col.key}`"
                  :row="row.data"
                  :column="col"
                  :value="row.data[col.dataIndex]"
                >
                  {{ row.data[col.dataIndex] }}
                </slot>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const props = defineProps({
  columns: {
    type: Array,
    required: true,
  },
  data: {
    type: Array,
    required: true,
  },
  height: {
    type: Number,
    required: true,
  },
  rowHeight: {
    type: Number,
    default: 50,
  },
})

const bodyRef = ref()
const scrollTop = ref(0)
const scrollLeft = ref(0)

// 可见行范围
const visibleRowRange = computed(() => {
  const start = Math.floor(scrollTop.value / props.rowHeight)
  const end = Math.ceil((scrollTop.value + props.height) / props.rowHeight)

  return {
    start: Math.max(0, start - 5),
    end: Math.min(props.data.length - 1, end + 5),
  }
})

// 可见行
const visibleRows = computed(() => {
  const result = []
  for (let i = visibleRowRange.value.start; i <= visibleRowRange.value.end; i++) {
    result.push({
      index: i,
      data: props.data[i],
    })
  }
  return result
})

// 可见列(简化版,假设所有列都可见)
const visibleColumns = computed(() => props.columns)

// 总高度
const totalHeight = computed(() => {
  return props.data.length * props.rowHeight
})

// 获取行偏移
function getRowOffset(index) {
  return index * props.rowHeight
}

// 处理滚动
function handleScroll(e) {
  scrollTop.value = e.target.scrollTop
  scrollLeft.value = e.target.scrollLeft
}
</script>

<style scoped>
.virtual-table {
  overflow: hidden;
}

.virtual-table-header {
  overflow: hidden;
  border-bottom: 1px solid #d9d9d9;
}

.virtual-table-body {
  height: calc(100% - 50px);
  overflow: auto;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th, td {
  padding: 12px;
  text-align: left;
  border-bottom: 1px solid #f0f0f0;
}
</style>

3. 无限滚动加载

vue
<!-- components/InfiniteScroll/InfiniteScroll.vue -->
<template>
  <div ref="containerRef" class="infinite-scroll">
    <slot :items="allItems"></slot>

    <!-- 加载中 -->
    <div v-if="loading" class="infinite-scroll-loading">
      <a-spin />
      <span>加载中...</span>
    </div>

    <!-- 已加载全部 -->
    <div v-else-if="!hasMore" class="infinite-scroll-end">
      没有更多了
    </div>

    <!-- 哨兵元素 -->
    <div ref="sentinelRef" class="infinite-scroll-sentinel"></div>
  </div>
</template>

<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'

const props = defineProps({
  // 加载函数
  loadMore: {
    type: Function,
    required: true,
  },
  // 初始数据
  initialItems: {
    type: Array,
    default: () => [],
  },
  // 距离底部多远触发加载
  threshold: {
    type: Number,
    default: 100,
  },
})

const containerRef = ref()
const sentinelRef = ref()

const allItems = ref([...props.initialItems])
const loading = ref(false)
const hasMore = ref(true)
const page = ref(1)

let observer = null

onMounted(() => {
  // 使用 IntersectionObserver 监听哨兵元素
  observer = new IntersectionObserver(
    (entries) => {
      const entry = entries[0]
      if (entry.isIntersecting && !loading.value && hasMore.value) {
        load()
      }
    },
    {
      root: containerRef.value,
      rootMargin: `${props.threshold}px`,
    }
  )

  observer.observe(sentinelRef.value)
})

onUnmounted(() => {
  if (observer) {
    observer.disconnect()
  }
})

// 加载数据
async function load() {
  if (loading.value || !hasMore.value) return

  loading.value = true

  try {
    const result = await props.loadMore(page.value + 1)

    if (result.data && result.data.length > 0) {
      allItems.value = [...allItems.value, ...result.data]
      page.value++

      // 检查是否还有更多
      if (result.data.length < result.pageSize) {
        hasMore.value = false
      }
    } else {
      hasMore.value = false
    }
  } catch (error) {
    console.error('加载失败', error)
  } finally {
    loading.value = false
  }
}

// 暴露方法
defineExpose({
  reset: () => {
    allItems.value = [...props.initialItems]
    page.value = 1
    hasMore.value = true
  },
})
</script>

<style scoped>
.infinite-scroll-loading,
.infinite-scroll-end {
  padding: 20px;
  text-align: center;
  color: #999;
}

.infinite-scroll-sentinel {
  height: 1px;
}
</style>

4. 使用示例

vue
<!-- views/VirtualListExample.vue -->
<template>
  <div class="page">
    <h2>虚拟列表示例</h2>

    <VirtualList
      :items="items"
      :height="600"
      :estimated-item-height="80"
      item-key="id"
    >
      <template #default="{ item, index }">
        <div class="list-item">
          <div class="item-title">{{ item.title }}</div>
          <div class="item-desc">{{ item.description }}</div>
          <div class="item-footer">
            <span>索引: {{ index }}</span>
            <span>ID: {{ item.id }}</span>
          </div>
        </div>
      </template>
    </VirtualList>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import VirtualList from '@/components/VirtualList/VirtualList.vue'

// 生成测试数据
const items = ref(
  Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    title: `Item ${i}`,
    description: `This is description for item ${i}. ${
      i % 3 === 0 ? 'This is a longer description that will take more space.' : ''
    }`,
  }))
)
</script>

<style scoped>
.list-item {
  padding: 16px;
  border-bottom: 1px solid #f0f0f0;
}

.item-title {
  font-size: 16px;
  font-weight: 500;
  margin-bottom: 8px;
}

.item-desc {
  font-size: 14px;
  color: #666;
  margin-bottom: 8px;
}

.item-footer {
  font-size: 12px;
  color: #999;
  display: flex;
  gap: 16px;
}
</style>

面试常见追问

Q: 虚拟滚动在移动端需要注意什么?

"移动端主要注意两点:

  1. 触摸滚动的惯性 - 移动端手指离开后,列表会继续滚动一段距离(惯性滚动)。这时不能停止监听滚动事件,要持续更新可视范围,否则会出现白屏。
  2. 性能更严苛 - 移动设备性能弱于 PC,要更激进地优化。我会减小缓冲区,降低渲染批次,用更简单的占位符。

还有一点是,移动端经常用下拉刷新,要和虚拟滚动结合好,防止冲突。"

Q: 如果列表项包含图片,如何优化?

"图片加载会改变元素高度,要特别处理:

  1. 占位高度 - 预先知道图片宽高比,用 padding-top 撑起占位高度,图片加载完不会改变布局
  2. 懒加载 - 只加载可视区域和缓冲区的图片,其他图片不加载
  3. IntersectionObserver - 用 IntersectionObserver 检测图片进入可视区域,进入才加载

还可以用渐进式图片,先加载低质量,再加载高质量,体验更好。"

Q: 虚拟滚动的性能瓶颈在哪?如何监控?

"性能瓶颈主要三个地方:

  1. 可视范围计算 - 二分查找优化到 O(log n) 后,基本不是瓶颈
  2. DOM 操作 - 频繁增删 DOM,优化方法是用 key 复用节点
  3. 渲染 - 元素太复杂,优化方法是简化 DOM 结构,用 CSS 代替 JS

监控方面,我用 Performance API 记录关键指标:

  • scrollTop 变化到渲染完成的耗时
  • 每帧的 FPS
  • 长任务的数量和时长

发现性能问题,就用 Chrome DevTools 的 Performance 面板录制,分析火焰图,找出瓶颈。"


项目经验总结

踩过的坑

  1. iOS Safari 的橡皮筋效果 - 滚动到顶部或底部时,会有弹性效果,导致 scrollTop 变成负数,计算出错。解决:边界检查
  2. transform 导致子元素定位问题 - 用 transform 做偏移,子元素的 fixed 定位会失效。解决:改用 position: absolute
  3. 快速滚动时闪烁 - 缓冲区太小,元素还没渲染就进入可视区域。解决:增大缓冲区
  4. 高度缓存内存泄漏 - 列表数据变化时没有清空缓存。解决:watch 数据变化,重置缓存

性能数据

  • 10000 行列表首屏渲染:< 300ms
  • 滚动帧率:稳定 60fps
  • 内存占用:仅与可视元素数量相关(~20 个),与总数据量无关
  • 位置计算耗时:< 0.1ms(二分查找)

可以吹的点

  • 支持固定高度和动态高度两种模式
  • 自动处理高度变化和滚动锚点,无抖动
  • 支持二维虚拟化(虚拟表格)
  • 支持无限滚动和分页加载
  • 性能优异,10 万条数据流畅滚动