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

显示模式

登录
ARCHIVE DOCUMENTVUE

听说你还不会虚拟列表?

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/72-听说你还不会虚拟列表?原谅我来晚了
本文目录8 个章节
  1. 一、什么是虚拟列表
  2. 二、什么时候需要虚拟列表
  3. 三、使用 vue-virtual-scroller
  4. 四、虚拟列表的计算过程
  5. 五、手写一个固定高度的 Vue 3 虚拟列表
  6. 六、常见误区
  7. 七、总结
  8. 参考资料

听说你还不会虚拟列表?

虚拟列表是长列表的常见性能优化。原文介绍了 vue-virtual-scroller 和固定高度的手写版本,思路仍然有效;但组件库版本、动态高度、缓冲区和“渲染一万条数据”的表述需要补充边界。

一、什么是虚拟列表

虚拟列表只把可视区域附近的少量列表项渲染成 DOM,未进入窗口的项目不创建或暂时回收 DOM,同时使用一个占位区域维持完整滚动高度。

viewport(固定高度、可滚动)
  ├── spacer(模拟全部数据的总高度)
  └── visible items(当前窗口 + overscan 缓冲区)

它减少的是同时存在的 DOM 节点和布局工作,不是数据本身:如果前端仍然一次性请求并保存一百万条数据,虚拟列表不会自动降低网络、解析和 JavaScript 内存成本。数据分页、服务端查询和虚拟列表可以组合使用。

二、什么时候需要虚拟列表

适合:

  • 表格、消息、日志、文件等项目很多的列表;
  • 用户需要连续滚动,不适合分页;
  • 单个项目 DOM 结构较复杂,普通列表产生大量布局和更新成本。

使用前应先测量。几百个简单文本节点未必值得引入虚拟列表;虚拟列表会增加滚动计算、测量、焦点管理和键盘导航复杂度。

三、使用 vue-virtual-scroller

vue-virtual-scroller 存在 Vue 2 和 Vue 3 不同版本/分支,安装时要按照项目 Vue 版本选择,不要直接复制旧文章中的依赖版本。当前 Vue 3 线的文档使用 RecycleScrollerDynamicScroller,示例以 Vue 3 为准:

pnpm add vue-virtual-scroller

在入口或组件中引入样式:

import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

1. RecycleScroller:固定高度

当每个项目高度相同,使用 RecycleScroller

<!-- components/VirtualRecycleScroller.vue -->
<script setup lang="ts">
import { RecycleScroller } from 'vue-virtual-scroller'

defineProps<{
  items: { id: string | number, label: string }[]
}>()
</script>

<template>
  <RecycleScroller
    class="scroller"
    :items="items"
    :item-size="50"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="desc">{{ item.label }}</div>
  </RecycleScroller>
</template>

<style scoped>
.scroller {
  height: 300px;
}

.desc {
  box-sizing: border-box;
  height: 50px;
  border-bottom: 1px solid #ddd;
  line-height: 50px;
}
</style>

item-size 是布局计算使用的项目高度。项目内容不能随意超出该高度,否则会发生重叠或裁切。key-field 应指向稳定且唯一的业务 ID,不要使用会变化的数组 index。

RecycleScroller 示例截图(已本地化)

2. DynamicScroller:动态高度

项目高度由文本、图片或展开状态决定时,使用 DynamicScrollerDynamicScrollerItem

<!-- components/VirtualDynamicScroller.vue -->
<script setup lang="ts">
import {
  DynamicScroller,
  DynamicScrollerItem,
} from 'vue-virtual-scroller'

defineProps<{
  items: { id: string | number, label: string }[]
}>()
</script>

<template>
  <DynamicScroller
    class="scroller"
    :items="items"
    :min-item-size="50"
    key-field="id"
    v-slot="{ item, index, active }"
  >
    <DynamicScrollerItem
      :item="item"
      :active="active"
      :size-dependencies="[item.label]"
      :data-index="index"
    >
      <div class="desc">{{ item.label }}</div>
    </DynamicScrollerItem>
  </DynamicScroller>
</template>

<style scoped>
.scroller {
  height: 300px;
}

.desc {
  padding: 12px;
  border-bottom: 1px solid #ddd;
  line-height: 1.5;
}
</style>

min-item-size 是初次估算使用的最小高度,不是所有项目的最终固定高度;size-dependencies 应包含会改变项目尺寸的数据。图片加载、字体变化和展开折叠仍可能触发重新测量。

DynamicScroller 动态高度示例截图(已本地化)

四、虚拟列表的计算过程

以固定项目高度 itemSize 为例:

const startIndex = Math.floor(scrollTop / itemSize)
const visibleCount = Math.ceil(viewportHeight / itemSize)
const overscan = 3

const start = Math.max(0, startIndex - overscan)
const end = Math.min(items.length, startIndex + visibleCount + overscan)
const offset = start * itemSize
const totalHeight = items.length * itemSize

滚动时主要更新:

  1. scrollTop
  2. 可视窗口开始和结束索引;
  3. items.slice(start, end)
  4. 可视列表的 transform 偏移;
  5. 占位区域的总高度。

虚拟列表三层结构示意图(已本地化)

滚动时起止索引和偏移量示意图(已本地化)

原文的三层结构可以概括为:

  • 可视区容器:固定高度并监听滚动;
  • 可滚动区域/占位层:高度等于全部项目高度,用来产生正确的滚动条;
  • 可视区列表:只渲染当前窗口项目,通过 translateY 放到正确位置。

动态高度时不能简单使用 index * itemSize。常见做法是保存已测量高度和前缀和,用二分查找根据 scrollTop 找到项目,再随着测量结果修正估算位置;成熟库已经处理了很多这些边界。

五、手写一个固定高度的 Vue 3 虚拟列表

下面的组件保留原文手写版的核心结构,并补充了 overscan、ResizeObserver、被动滚动监听语义和卸载清理。

<!-- components/MyVirtualScroller.vue -->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'

type Item = {
  id: string | number
  label: string
}

const props = withDefaults(defineProps<{
  items: Item[]
  itemSize?: number
  overscan?: number
}>(), {
  itemSize: 50,
  overscan: 3,
})

const viewport = ref<HTMLElement | null>(null)
const viewportHeight = ref(0)
const scrollTop = ref(0)

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

const firstVisible = computed(() => {
  return Math.floor(scrollTop.value / props.itemSize)
})

const start = computed(() => {
  return Math.max(0, firstVisible.value - props.overscan)
})

const end = computed(() => {
  return Math.min(
    props.items.length,
    firstVisible.value + visibleCount.value + props.overscan,
  )
})

const visibleData = computed(() => props.items.slice(start.value, end.value))
const offset = computed(() => start.value * props.itemSize)
const totalHeight = computed(() => props.items.length * props.itemSize)

function onScroll(event: Event) {
  scrollTop.value = (event.target as HTMLElement).scrollTop
}

let resizeObserver: ResizeObserver | undefined

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

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

  updateHeight()
  if ('ResizeObserver' in window) {
    resizeObserver = new ResizeObserver(updateHeight)
    resizeObserver.observe(viewport.value)
  }
}

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

<template>
  <div
    ref="viewport"
    class="virtual-viewport"
    @scroll.passive="onScroll"
  >
    <div
      class="virtual-spacer"
      :style="{ height: `${totalHeight}px` }"
    />

    <div
      class="virtual-items"
      :style="{ transform: `translateY(${offset}px)` }"
    >
      <div
        v-for="item in visibleData"
        :key="item.id"
        class="virtual-item"
        :style="{ height: `${props.itemSize}px` }"
      >
        {{ item.label }}
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-viewport {
  position: relative;
  height: 300px;
  overflow: auto;
}

.virtual-spacer {
  pointer-events: none;
}

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

.virtual-item {
  box-sizing: border-box;
  border-bottom: 1px solid #ddd;
  line-height: 1.5;
}
</style>

手写虚拟列表示例效果(已本地化)

手写虚拟列表只保留少量 DOM 的示例(已本地化)

使用:

<script setup lang="ts">
import MyVirtualScroller from './components/MyVirtualScroller.vue'

const list = Array.from({ length: 10_000 }, (_, id) => ({
  id,
  label: `virtual-list ${id}`,
}))
</script>

<template>
  <MyVirtualScroller :items="list" />
</template>

手写版本需要继续完善的地方

  • 动态高度和图片加载后的重新测量;
  • 首尾 overscan 和快速滚动时的空白;
  • 键盘焦点、屏幕阅读器和 aria-rowindex
  • 项目插入/删除后保持滚动锚点;
  • 横向列表、网格和 RTL;
  • ResizeObserver、IntersectionObserver 不可用时的降级;
  • 频繁 scroll 回调的调度与性能测量。

六、常见误区

1. 虚拟列表会让一万条数据“瞬间加载”

它只减少同时渲染的 DOM。数据请求、JSON 解析、响应式代理和内存仍然可能发生,必要时要使用分页、游标查询或服务端过滤。

2. 动态高度可以直接套固定高度算法

不可以。固定高度通过除法计算索引;动态高度需要测量、估算和位置修正。

3. 只渲染可见行,不需要稳定 key

仍然需要稳定 key。回收的 DOM 会被复用于不同数据项,错误的 key 可能造成输入框、局部状态和动画状态错配。

4. 虚拟列表一定比普通 v-for

当列表很短、项目很简单或滚动交互要求不高时,虚拟列表的计算和组件开销可能不划算。应使用 Performance 面板和真实设备测量。

七、总结

虚拟列表的核心是“完整滚动高度 + 可视窗口 + 偏移渲染”:

  1. 计算当前可视区的起止索引;
  2. 只渲染窗口和少量缓冲项目;
  3. 使用占位层维持滚动条;
  4. 使用 transform 把窗口移动到正确位置;
  5. 固定高度可用简单除法,动态高度需要测量和估算;
  6. 它减少 DOM,不自动减少数据加载;
  7. 生产项目优先选择与 Vue 版本匹配、经过测试的虚拟滚动库。

参考资料

原文作者:前端阿飞。原文关于 RecycleScrollerDynamicScroller、三层结构和固定高度手写实现的主线予以保留,依赖版本、动态高度、数据加载边界、远程图片和推广内容已整理。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS