技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

结合“康熙选秀”,讲讲虚拟列表

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/52-「百毒不侵(三)」结合“康熙选秀”,给大家讲讲“虚拟列表”
本文目录12 个章节
  1. 一、场景:一次渲染一万条数据会怎样
  2. 二、虚拟列表和其他方案的区别
  3. 三、固定高度虚拟列表的核心公式
  4. 四、Vue 3 固定高度实现
  5. 五、VueUse 的现成方案
  6. 六、动态高度列表
  7. 七、分页、无限滚动和虚拟列表组合
  8. 八、可访问性与交互边界
  9. 九、常见问题
  10. 十、总结
  11. 官方参考
  12. 原文出处

结合“康熙选秀”,讲讲虚拟列表

原文用“康熙一次不看一万名秀女,而是分批查看”的故事解释虚拟列表。这个比喻仍然很好:数据可以有一万条,但浏览器只需要同时创建视口附近的一小部分 DOM。

本文保留原文的故事、固定高度算法和 Vue 示例,并修正原代码中的高度变量拼写、窗口监听清理、索引计算和“虚拟列表等于分页/懒加载”等混淆。

一、场景:一次渲染一万条数据会怎样

康熙命人找来一万名女子,但不可能一次让所有人进入大殿。张廷玉把人分成几批:当前正在查看的一批进入大殿,已经看过的在左偏殿,尚未轮到的在右偏殿。大殿始终只容纳少量人,但队列和位置仍然保持完整。

浏览器渲染列表也类似:

  • 完整数据:一万条业务数据;
  • 可视区域:当前滚动容器能看到的部分;
  • overscan:可视区域上下额外预渲染的几条,防止快速滚动时白屏;
  • 占位空间:用高度或 transform 模拟完整列表的滚动长度;
  • 实际 DOM:只创建当前区域附近的几十个列表项。

原文故事示意图(本地化,历史资料)原文分批渲染示意图(本地化,历史资料)

二、虚拟列表和其他方案的区别

虚拟列表、分页、无限滚动和图片懒加载经常一起出现,但不是同一件事:

方案主要解决的问题
分页控制一次从服务端取多少数据
无限滚动用户接近底部时继续请求下一页
图片懒加载图片进入视口附近才下载
虚拟列表数据已在客户端时,控制同时存在的 DOM 数量
服务端筛选避免把不需要的数据传到浏览器

可以组合使用:分页负责数据量,虚拟列表负责 DOM 量,IntersectionObserver 负责触底请求,图片 loading="lazy" 负责媒体资源。

虚拟列表并不会自动减少数据请求,也不会自动减少内存中的数组。若 100 万条数据全部放在客户端,仍然要考虑网络、解析、内存和搜索成本。

三、固定高度虚拟列表的核心公式

设:

  • H:滚动容器可视高度;
  • h:每一项固定高度;
  • T:当前 scrollTop
  • n:数据总条数;
  • overscan:上下额外渲染数量。

可以计算:

第一条可见数据 ≈ floor(T / h)
可视条数 ≈ ceil(H / h)
渲染起点 = max(0, 第一条可见数据 - overscan)
渲染终点 = min(n, 第一条可见数据 + 可视条数 + overscan)
上方偏移 = 渲染起点 * h
总滚动高度 = n * h

只要列表项高度固定,滚动位置与索引之间就可以 O(1) 互相转换。若列表项高度不同,scrollTop / h 不再可靠,需要测量和维护高度前缀和,复杂度和实现方式都会变化。

原文虚拟列表流程图(本地化,历史资料)

四、Vue 3 固定高度实现

下面是一个可运行思路较完整的 Vue 3.5 示例。它使用 transform 模拟上方占位空间,用一个固定高度的外层元素保持滚动条总长度。

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'

interface Row {
  id: number
  message: string
}

const rows = ref<Row[]>(
  Array.from({ length: 100_000 }, (_, id) => ({
    id,
    message: `第 ${id + 1} 条数据`,
  })),
)

const viewport = ref<HTMLElement | null>(null)
// 与 CSS 的 400px 初始高度保持一致,保证 SSR 首屏也有列表内容
const viewportHeight = ref(400)
const scrollTop = ref(0)

const itemHeight = 48
const overscan = 6

const firstVisibleIndex = computed(() => {
  return Math.floor(scrollTop.value / itemHeight)
})

const visibleCount = computed(() => {
  return Math.ceil(viewportHeight.value / itemHeight)
})

const startIndex = computed(() => {
  return Math.max(0, firstVisibleIndex.value - overscan)
})

const endIndex = computed(() => {
  return Math.min(
    rows.value.length,
    firstVisibleIndex.value + visibleCount.value + overscan,
  )
})

const visibleRows = computed(() => {
  return rows.value.slice(startIndex.value, endIndex.value)
})

const offsetY = computed(() => startIndex.value * itemHeight)
const totalHeight = computed(() => rows.value.length * itemHeight)

let resizeObserver: ResizeObserver | undefined
let frame = 0
let pendingScrollTop = 0

function updateScrollTop() {
  scrollTop.value = pendingScrollTop
  frame = 0
}

function handleScroll(event: Event) {
  pendingScrollTop = (event.currentTarget as HTMLElement).scrollTop

  // passive 不等于节流;用 requestAnimationFrame 合并同一帧内的滚动更新
  if (!frame) {
    frame = requestAnimationFrame(updateScrollTop)
  }
}

onMounted(() => {
  if (!viewport.value) return

  const updateHeight = () => {
    viewportHeight.value = viewport.value?.clientHeight ?? 0
  }

  resizeObserver = new ResizeObserver(updateHeight)
  resizeObserver.observe(viewport.value)
  updateHeight()
})

onBeforeUnmount(() => {
  resizeObserver?.disconnect()
  if (frame) {
    cancelAnimationFrame(frame)
  }
})
</script>

<template>
  <div
    ref="viewport"
    class="virtual-viewport"
    role="list"
    tabindex="0"
    @scroll.passive="handleScroll"
  >
    <div
      class="virtual-content"
      :style="{ height: `${totalHeight}px` }"
    >
      <div
        class="virtual-items"
        :style="{ transform: `translateY(${offsetY}px)` }"
      >
        <div
          v-for="(row, offset) in visibleRows"
          :key="row.id"
          class="virtual-row"
          role="listitem"
          :aria-setsize="rows.length"
          :aria-posinset="startIndex + offset + 1"
        >
          {{ row.message }}
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-viewport {
  height: 400px;
  overflow: auto;
  contain: strict;
}

.virtual-content {
  position: relative;
}

.virtual-items {
  position: absolute;
  inset: 0 0 auto;
  will-change: transform;
}

.virtual-row {
  box-sizing: border-box;
  height: 48px;
  display: flex;
  align-items: center;
  padding: 0 16px;
  border-bottom: 1px solid #eee;
}
</style>

4.1 为什么需要 key

<div v-for="row in visibleRows" :key="row.id">

虚拟列表会不断用新的数据替换可视窗口中的项目。稳定、唯一的 key 能帮助 Vue 正确复用节点和组件状态。不要使用会变化的随机数;如果数据顺序会变化,也不要无条件使用数组 index。

4.2 原文实现中的问题

原文代码的整体算法方向正确,但有一些细节需要修正:

  • itemHiehgt 是拼写错误,应统一为 itemHeight
  • window.onresize = ...window.onorientationchange = ... 会覆盖其他监听器,且没有卸载,应使用 ResizeObserveraddEventListener 并清理;
  • @scroll.passive 只能声明不会调用 preventDefault,不能自动节流,仍需 rAF 或节流;
  • 高度、padding 和实际 CSS 行高必须一致,否则滚动位置会逐渐偏移;
  • 触底请求需要 loading、hasMore、失败重试和请求去重,否则快速滚动可能重复请求;
  • require(...).default 是 webpack/CJS 时代写法,Vite/ESM 项目通常使用静态 import 或 URL;
  • 原示例的 style="height: 100%" 与 padding 方案容易造成高度计算混乱,使用总高度 + translate 更直观;
  • 只更新 startIndex 不足以处理容器大小变化,需要 ResizeObserver 或重新计算。

五、VueUse 的现成方案

如果是固定高度列表,可以使用 VueUse 的 useVirtualList

<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualList } from '@vueuse/core'

const items = ref(
  Array.from({ length: 100_000 }, (_, index) => ({
    id: index,
    text: `item-${index}`,
  })),
)

const {
  list,
  containerProps,
  wrapperProps,
} = useVirtualList(items, {
  itemHeight: 50,
  overscan: 8,
})
</script>

<template>
  <div v-bind="containerProps" class="list-container">
    <div v-bind="wrapperProps">
      <div
        v-for="{ data, index } in list"
        :key="data.id"
        class="list-row"
      >
        {{ index }} - {{ data.text }}
      </div>
    </div>
  </div>
</template>

<style scoped>
.list-container {
  height: 400px;
  overflow: auto;
}

.list-row {
  box-sizing: border-box;
  height: 50px;
}
</style>

useVirtualListitemHeight 可以是数字,也可以根据索引返回高度。使用第三方方案前仍要验证动态高度、横向滚动、键盘操作、SSR 和大数据量下的实际表现。

参考:VueUse useVirtualList

六、动态高度列表

固定高度是最容易实现的情况。对于动态高度:

scrollTop / 固定高度
      ✕
动态高度需要知道前面所有项目的累计高度

常见实现路线:

  1. 使用一个估计高度计算初始位置;
  2. 渲染后通过 ResizeObserver 测量每一项;
  3. 维护每项高度和累计高度;
  4. 滚动时用二分查找累计高度,定位第一个可见项;
  5. 高度变化后修正占位空间和滚动锚点;
  6. 处理图片加载、折叠展开和异步内容带来的二次测量。

动态高度虚拟列表要处理滚动跳动、定位误差、测量频率和锚点保持,不能简单把固定高度公式中的 itemHeight 换成平均值就认为完成了。

如果列表项高度差异很大或包含复杂交互,优先选择有动态高度支持的成熟库,并用真实数据测试。

七、分页、无限滚动和虚拟列表组合

典型的大数据页面可以这样设计:

服务端分页/筛选
      ↓
客户端只保留已加载数据
      ↓
虚拟列表只渲染可视区域
      ↓
IntersectionObserver 检测接近底部
      ↓
请求下一页并去重拼接

触底加载不是虚拟列表的一部分:

const loading = ref(false)
const hasMore = ref(true)

async function loadNextPage() {
  if (loading.value || !hasMore.value) return

  loading.value = true
  try {
    const result = await fetchPage(page.value + 1)
    rows.value.push(...result.items)
    hasMore.value = result.hasMore
    page.value++
  } finally {
    loading.value = false
  }
}

需要处理:

  • 并发请求去重;
  • 失败重试;
  • 空列表和无更多数据;
  • 服务端排序变化导致的重复项;
  • 切换筛选条件时清空旧数据并取消旧请求;
  • 用户返回页面时恢复滚动位置。

八、可访问性与交互边界

虚拟化会让屏幕阅读器和键盘用户只看到 DOM 中已渲染的部分,因此需要:

  • 使用 role="list"role="listitem" 等合适语义;
  • 设置 aria-setsizearia-posinset 时保证值准确;
  • 保证滚动容器可以获得焦点和使用键盘;
  • 删除或替换行时不要让焦点突然丢失;
  • 对表格、树、组合框等复杂组件遵循对应 ARIA 模式;
  • 如果无障碍要求高,先评估分页或普通列表是否更可靠。

虚拟列表不是 DOM 越少越好。它应该在性能收益和可访问性、SEO、复制搜索、浏览器查找之间取得平衡。对于不支持必要观察器、无障碍要求无法满足或数据量本来很小的设备/场景,应提供普通列表或分页降级,而不是强行虚拟化。

在 SSR 中,ResizeObserverIntersectionObserverrequestAnimationFrame 等浏览器 API 只能在客户端生命周期中使用;服务端首屏应保留有意义的 HTML,不能依赖浏览器滚动事件才输出全部内容。

九、常见问题

9.1 虚拟列表为什么滚动时白屏

可能原因:

  • overscan 太小;
  • 滚动事件处理太慢;
  • 每一项渲染成本太高;
  • 图片或异步内容阻塞;
  • 实际行高与计算行高不一致;
  • 主线程被其他长任务占用。

可以增加 overscan、使用 rAF、降低行组件复杂度、固定图片尺寸,并通过 Performance 面板确认瓶颈。

9.2 为什么数组已经很大了还会卡

虚拟列表主要减少 DOM 和渲染成本,不能自动消除:

  • 100 万条数据的 JSON 解析;
  • 深层响应式转换;
  • 全量筛选和排序;
  • 大量图片下载;
  • 组件缓存和业务对象占用的内存。

此时需要服务端分页、Web Worker、shallowRef、服务端筛选或数据结构优化。

9.3 虚拟列表能替代分页吗

不能。分页控制网络和数据规模,虚拟列表控制同时渲染的 DOM 数量。实际项目中通常组合使用。

十、总结

  • 虚拟列表只渲染视口附近的数据,通过占位高度模拟完整滚动区域;
  • 固定高度列表可以用 scrollTop / itemHeight 快速计算索引;
  • overscan 可以减少快速滚动白屏,但过大也会增加渲染量;
  • ResizeObserver、rAF、稳定 key 和清理监听器是实现细节中的重点;
  • 动态高度需要测量、累计高度和滚动锚点修正,优先考虑成熟库;
  • 分页、无限滚动、图片懒加载和虚拟列表解决不同问题,可以组合;
  • 大数组解析、筛选、排序和网络请求仍需单独优化;
  • 关注键盘、屏幕阅读器、焦点、SEO 和移动端滚动体验,不要只追求少渲染几个 DOM。

官方参考

原文出处

作者:Sunshine_Lin

来源:稀土掘金

本文保留原文康熙选秀比喻、固定高度公式和 Vue 虚拟列表实现,并补充 Vue 3、VueUse、动态高度、请求分页和无障碍边界。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS