- 1条目 00001标签
- 2条目 00002
- 3条目 00003
- 4条目 00004标签
- 5条目 00005
- 6条目 00006
- 7条目 00007标签
- 8条目 00008
- 9条目 00009
- 10条目 00010标签
<script setup lang="ts">
import { VirtualList, Tag, Inline, Text } from '@hina-ui/vue'
const items = Array.from({ length: 10000 }, (_, id) => ({
id,
label: `条目 ${String(id + 1).padStart(5, '0')}`,
}))
</script>
<template>
<VirtualList
:items="items"
:get-key="item => item.id"
:height="320"
:estimate-size="64"
label="条目列表"
class="border-line bg-surface rounded-lg border"
>
<template #default="{ item, index }">
<Inline :wrap="false" class="border-line h-16 border-b px-4">
<Text
as="span"
size="xs"
tone="muted"
class="bg-inset flex size-9 shrink-0 items-center justify-center rounded-md tabular-nums"
>
{{ index + 1 }}
</Text>
<Text as="span" size="sm" truncate class="min-w-0 flex-1">{{ item.label }}</Text>
<Tag v-if="index % 3 === 0" size="sm">标签</Tag>
</Inline>
</template>
</VirtualList>
</template>
用法
import { VirtualList, type VirtualListExpose } from '@hina-ui/vue'
items 提供数据,getKey 返回每项唯一、稳定的字符串或数字标识,默认插槽接收 { item, index },保留 item 的完整类型。更新或重排数据时不要用数组位置作为 key。
组件内置 ScrollArea,默认高度为 320px。height 可传像素数或 CSS 长度;设为 100% 时父容器需要有确定高度。外观和条目内容由调用方定义,组件本身不增加边框、选中态或点击行为。首个示例包含一万项,并在尾部组合了 Tag。
示例
固定尺寸
设置 :dynamic="false" 后,estimateSize 是条目的实际高度,不再测量 DOM。它也可以是 (item, index) => number,用于已知的不同尺寸。尺寸包含条目自身的 padding 和 border,不包含 gap;内容需要放得进声明的尺寸。
- 条目 11 / 1000
- 条目 22 / 1000
- 条目 33 / 1000
- 条目 44 / 1000
- 条目 55 / 1000
- 条目 66 / 1000
- 条目 77 / 1000
- 条目 88 / 1000
- 条目 99 / 1000
- 条目 1010 / 1000
<script setup lang="ts">
import { VirtualList, Inline, Text } from '@hina-ui/vue'
const items = Array.from({ length: 1000 }, (_, id) => ({ id, label: `条目 ${id + 1}` }))
</script>
<template>
<VirtualList
:items="items"
:get-key="item => item.id"
:height="240"
:estimate-size="48"
:dynamic="false"
label="固定高度列表"
class="border-line rounded-lg border"
>
<template #default="{ item, index }">
<Inline justify="between" gap="sm" :wrap="false" class="border-line h-full border-b px-4">
<Text as="span" size="sm">{{ item.label }}</Text>
<Text as="span" size="sm" tone="muted" class="tabular-nums">
{{ index + 1 }} / {{ items.length }}
</Text>
</Inline>
</template>
</VirtualList>
</template>
动态尺寸
默认 dynamic 开启,条目随内容自然撑开。estimateSize 是尚未测量条目的预估高度,尽量取接近真实内容的值。内容展开、图片加载或容器变窄后,会重新计算实际高度和后续条目的位置。滚动条总长度也会随测量修正。
间隔使用 gap,首尾留白使用 paddingStart 和 paddingEnd,单位均为像素。避免用条目外部 margin 表示这些间隔,因为 margin 不计入条目测量。示例用 Collapsible 展开附加内容,列表跟随开合动画更新高度。开合状态按条目 key 保存在外部,滚出可见范围再返回时仍然保留。
条目 1
内容自然换行,每项高度由实际内容决定。
条目 2
内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。
条目 3
内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。
条目 4
内容自然换行,每项高度由实际内容决定。
条目 5
内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。
条目 6
内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。
条目 7
内容自然换行,每项高度由实际内容决定。
条目 8
内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。
<script setup lang="ts">
import { ref } from 'vue'
import {
VirtualList,
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
Text,
} from '@hina-ui/vue'
const expanded = ref<Record<number, boolean>>({})
const items = Array.from({ length: 500 }, (_, id) => ({
id,
title: `条目 ${id + 1}`,
description: '内容自然换行,每项高度由实际内容决定。'.repeat((id % 3) + 1),
}))
</script>
<template>
<VirtualList
:items="items"
:get-key="item => item.id"
:estimate-size="140"
:height="360"
label="动态高度列表"
class="border-line rounded-lg border"
>
<template #default="{ item }">
<Collapsible v-model:open="expanded[item.id]" class="border-line border-b p-4">
<Text size="sm" weight="medium">{{ item.title }}</Text>
<Text size="sm" tone="muted" class="mt-1">{{ item.description }}</Text>
<CollapsibleTrigger class="mt-2">
{{ expanded[item.id] ? '收起' : '展开' }}
</CollapsibleTrigger>
<CollapsibleContent>
<Text size="sm" tone="muted" class="pt-2">
{{ '展开后增加的内容会自动参与高度计算。'.repeat(5) }}
</Text>
</CollapsibleContent>
</Collapsible>
</template>
</VirtualList>
</template>
滚动与可见范围
通过组件引用调用 scrollToIndex(index, { align, behavior }) 或 scrollToOffset(offset, { behavior })。索引从 0 开始,align 支持 start、center、end 和 auto;默认 auto 只在目标超出视口时滚动。越界索引会限制到首尾项。
behavior="smooth" 开启平滑滚动,系统要求减弱动态效果时使用即时滚动。动态尺寸的远距离跳转会随着目标附近的实际测量继续校正;已知尺寸时使用固定模式可获得精确位置。
rangeChange 返回实际可见的首尾索引,不包含预渲染和保留焦点的额外条目。可据此按需追加数据;请求状态和是否还有数据由调用方控制。示例使用 NumberInput 指定目标。
条目 1
条目 2
条目 3
条目 4
条目 5
条目 6
条目 7
条目 8
条目 9
条目 10
条目 11
<script setup lang="ts">
import { ref } from 'vue'
import {
VirtualList,
NumberInput,
Button,
Inline,
Stack,
Text,
type VirtualListExpose,
type VirtualListRange,
} from '@hina-ui/vue'
const list = ref<VirtualListExpose>()
const target = ref(5000)
const range = ref<VirtualListRange>({ startIndex: 0, endIndex: 0 })
const items = Array.from({ length: 10000 }, (_, id) => ({ id, label: `条目 ${id + 1}` }))
</script>
<template>
<Stack class="w-full">
<Inline gap="sm">
<NumberInput
v-model="target"
:min="1"
:max="items.length"
aria-label="条目序号"
class="w-36"
/>
<Button @click="list?.scrollToIndex(target - 1, { align: 'center' })">跳转</Button>
<Button variant="outline" @click="list?.scrollToOffset(0)">回到顶部</Button>
<Text as="span" size="sm" tone="muted" class="tabular-nums">
可见 {{ range.startIndex + 1 }}–{{ range.endIndex + 1 }}
</Text>
</Inline>
<VirtualList
ref="list"
:items="items"
:get-key="item => item.id"
:dynamic="false"
:estimate-size="48"
:height="288"
label="可跳转列表"
class="border-line rounded-lg border"
@range-change="range = $event"
>
<template #default="{ item }">
<Inline gap="none" class="border-line h-full border-b px-4">
<Text size="sm">{{ item.label }}</Text>
</Inline>
</template>
</VirtualList>
</Stack>
</template>
横向与 RTL
orientation="horizontal" 时,estimateSize 表示宽度,height 控制容器高度。动态模式下在插槽内给内容定义自然宽度;固定模式直接使用声明宽度。dir 可显式设置,也会继承方向。RTL 下条目从右向左排列,滚动方法仍使用正数逻辑偏移。
ltr
01
条目 1
02
条目 2
03
条目 3
04
条目 4
05
条目 5
06
条目 6
07
条目 7
rtl
01
条目 1
02
条目 2
03
条目 3
04
条目 4
05
条目 5
06
条目 6
07
条目 7
<script setup lang="ts">
import { VirtualList, Stack, Text } from '@hina-ui/vue'
const items = Array.from({ length: 200 }, (_, id) => ({ id, label: `条目 ${id + 1}` }))
</script>
<template>
<Stack class="w-full">
<Stack v-for="dir in ['ltr', 'rtl'] as const" :key="dir" gap="sm">
<Text size="xs" tone="muted" class="uppercase">{{ dir }}</Text>
<VirtualList
:items="items"
:get-key="item => item.id"
orientation="horizontal"
:dir="dir"
:height="144"
:estimate-size="160"
:dynamic="false"
:gap="12"
:label="`${dir} 横向列表`"
>
<template #default="{ item, index }">
<Stack
justify="between"
gap="sm"
class="border-line bg-surface h-full rounded-lg border p-4"
>
<Text size="2xl" tone="muted" class="tabular-nums">
{{ String(index + 1).padStart(2, '0') }}
</Text>
<Text size="sm">{{ item.label }}</Text>
</Stack>
</template>
</VirtualList>
</Stack>
</Stack>
</template>
加载与空态
loading 使用 LoadingOverlay 在列表中央显示加载提示,保留已有条目和滚动位置,不改变滚动视口的尺寸。没有条目时也居中显示,加载时不显示空态。#loading 替换加载层的内容,#empty 替换空态,空态也可以用 emptyText 修改默认文案。
示例组合 Switch 和 Empty。组件不请求数据,也不清空已有数据。
条目 1
条目 2
条目 3
条目 4
条目 5
条目 6
条目 7
条目 8
条目 9
条目 10
<script setup lang="ts">
import { computed, ref } from 'vue'
import { VirtualList, Button, Switch, Empty, Inline, Stack, Text } from '@hina-ui/vue'
const loading = ref(false)
const empty = ref(false)
const items = computed(() =>
empty.value ? [] : Array.from({ length: 100 }, (_, id) => ({ id, label: `条目 ${id + 1}` })),
)
</script>
<template>
<Stack class="w-full">
<Inline>
<Switch v-model="loading">加载中</Switch>
<Button variant="outline" @click="empty = !empty">
{{ empty ? '恢复条目' : '清空条目' }}
</Button>
</Inline>
<VirtualList
:items="items"
:get-key="item => item.id"
:loading="loading"
:height="240"
:estimate-size="48"
:dynamic="false"
label="列表状态"
class="border-line rounded-lg border"
>
<template #default="{ item }">
<Inline gap="none" class="border-line h-full border-b px-4">
<Text size="sm">{{ item.label }}</Text>
</Inline>
</template>
<template #empty>
<Empty title="暂无条目" description="列表中没有可显示的内容。" size="sm" />
</template>
</VirtualList>
</Stack>
</template>
SSR 与测量
服务端根据 estimateSize、initialRect 和 initialOffset 渲染首批条目,并预留整个列表的滚动长度。默认初始视口为 320px 宽和 height 指定的数值高度;height 为 CSS 字符串时,预估高度为 320px。需要更准确的首屏范围时显式传入 initialRect,服务端和客户端保持一致。
viewport 在 ScrollArea 完成初始化后才可用。在此之前调用滚动方法会暂存最后一次请求,初始化完成后执行。initialOffset 仅用于初始位置;后续移动使用滚动方法。
动态测量会缓存稳定 key 对应的尺寸。如果批量修改了尚未渲染条目的内容,可调用 measure() 清空尺寸缓存并重新测量当前条目。
无障碍
- 滚动区域可聚焦,通过
label命名,保留浏览器原生键盘滚动。 - 内容使用
list/listitem语义,aria-posinset和aria-setsize标注条目在完整列表中的位置;不增加选择或菜单语义。 - 滚动时包含输入焦点的条目会保持挂载,直到焦点移出;该项被数据删除时仍会卸载。
- 虚拟化条目离开范围后会卸载。需要保留的输入值或展开状态应按 key 存在组件外部。
- 页面搜索和辅助技术只能访问当前挂载的内容。如果必须一次访问全部条目,使用普通 List 或分页呈现。
API
Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
items | readonly T[] | 必填 | 完整数据数组 |
getKey | (item: T, index: number) => string | number | 必填 | 唯一、稳定的标识 |
estimateSize | number | ((item: T, index: number) => number) | 48 | 正数像素尺寸;动态模式下为预估值 |
dynamic | boolean | true | 自动测量条目尺寸 |
height | number | string | 320 | 像素数或 CSS 高度 |
orientation | 'vertical' | 'horizontal' | 'vertical' | 虚拟滚动方向 |
dir | 'ltr' | 'rtl' | 继承 | 内容方向 |
overscan | number | 5 | 在可见范围两侧各预渲染的条目数 |
gap | number | 0 | 条目间距,像素 |
paddingStart | number | 0 | 滚动轴起始留白,像素 |
paddingEnd | number | 0 | 滚动轴末尾留白,像素 |
initialRect | { width: number; height: number } | 见上文 | SSR 初始视口尺寸 |
initialOffset | number | 0 | 初始逻辑滚动偏移,像素 |
loading | boolean | false | 加载提示及 aria-busy |
emptyText | string | locale | 默认空态文字 |
label | string | locale | 滚动区域的无障碍名称 |
shadow | boolean | true | ScrollArea 边缘阴影 |
class | string | — | 根元素类 |
itemClass | string | ((item: T, index: number) => string | undefined) | — | 条目容器类 |
其他原生属性透传到根元素。用 itemClass 调整条目外观,不要覆盖其定位属性;动态模式的纵向 padding 会正常计入测量。
Slots
| 插槽 | 参数 | 说明 |
|---|---|---|
default | { item: T, index: number } | 条目内容 |
empty | — | 无条目且未加载时的内容 |
loading | — | 加载中的内容 |
Events
| 事件 | 参数 | 说明 |
|---|---|---|
rangeChange | { startIndex: number; endIndex: number } | 初始及可见范围变化时触发;无可见项时均为 -1 |
Expose
| 名称 | 类型 | 说明 |
|---|---|---|
viewport | HTMLElement | undefined | 实际滚动元素 |
scrollToIndex | (index, options?: VirtualListScrollOptions) => void | 滚动到指定索引 |
scrollToOffset | (offset, options?: { behavior?: 'auto' | 'smooth' }) => void | 滚动到逻辑偏移 |
measure | () => void | 重置缓存并重新测量 |
同时导出 VirtualListProps<T>、VirtualListSlotProps<T>、VirtualListKey、VirtualListRange、VirtualListScrollOptions 和 VirtualListExpose 类型。
组件集成
Select, MultiSelect, Combobox, MultiCombobox, Listbox, CommandPalette, Tree, TreeSelect, DataList, DataTable 已提供可选的 virtualize,共用虚拟滚动底层,同时保留各自的选择、搜索和键盘行为。无需在这些组件外再嵌套 VirtualList。