VirtualList 虚拟列表

只渲染可见范围附近的条目,支持动态尺寸与程序化滚动。

  • 1条目 00001标签
  • 2条目 00002
  • 3条目 00003
  • 4条目 00004标签
  • 5条目 00005
  • 6条目 00006
  • 7条目 00007标签
  • 8条目 00008
  • 9条目 00009
  • 10条目 00010标签
'use client'

import { VirtualList, Tag, Inline, Text } from '@hina-ui/react'

const items = Array.from({ length: 10000 }, (_, id) => ({
  id,
  label: `条目 ${String(id + 1).padStart(5, '0')}`,
}))

export default function Demo() {
  return (
    <VirtualList
      items={items}
      getKey={item => item.id}
      height={320}
      estimateSize={64}
      label="条目列表"
      className="border-line bg-surface rounded-lg border"
    >
      {({ item, index }) => (
        <Inline wrap={false} className="border-line h-16 border-b px-4">
          <Text
            as="span"
            size="xs"
            tone="muted"
            className="bg-inset flex size-9 shrink-0 items-center justify-center rounded-md tabular-nums"
          >
            {index + 1}
          </Text>
          <Text as="span" size="sm" truncate className="min-w-0 flex-1">
            {item.label}
          </Text>
          {index % 3 === 0 && <Tag size="sm">标签</Tag>}
        </Inline>
      )}
    </VirtualList>
  )
}
tsx

用法

import { VirtualList, type VirtualListExpose } from '@hina-ui/react'
ts

items 提供数据,getKey 返回每项唯一、稳定的字符串或数字标识,children 是函数,接收 { item, index },保留 item 的完整类型。更新或重排数据时不要用数组位置作为 key。getKey 与 children 都是函数,渲染 VirtualList 的组件需要声明 'use client'。

组件内置 ScrollArea,默认高度为 320px。height 可传像素数或 CSS 长度;设为 100% 时父容器需要有确定高度。外观和条目内容由调用方定义,组件本身不增加边框、选中态或点击行为。首个示例包含一万项,并在尾部组合了 Tag。

示例使用 Text 设置文字样式,通过 Stack 与 Inline 排列内容。

示例

固定尺寸

设置 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
'use client'

import { VirtualList, Inline, Text } from '@hina-ui/react'

const items = Array.from({ length: 1000 }, (_, id) => ({ id, label: `条目 ${id + 1}` }))

export default function Demo() {
  return (
    <VirtualList
      items={items}
      getKey={item => item.id}
      height={240}
      estimateSize={48}
      dynamic={false}
      label="固定高度列表"
      className="border-line rounded-lg border"
    >
      {({ item, index }) => (
        <Inline
          justify="between"
          gap="sm"
          wrap={false}
          className="border-line h-full border-b px-4"
        >
          <Text as="span" size="sm">
            {item.label}
          </Text>
          <Text as="span" size="sm" tone="muted" className="tabular-nums">
            {index + 1} / {items.length}
          </Text>
        </Inline>
      )}
    </VirtualList>
  )
}
tsx

动态尺寸

默认 dynamic 开启,条目随内容自然撑开。estimateSize 是尚未测量条目的预估高度,尽量取接近真实内容的值。内容展开、图片加载或容器变窄后,会重新计算实际高度和后续条目的位置。滚动条总长度也会随测量修正。

间隔使用 gap,首尾留白使用 paddingStart 和 paddingEnd,单位均为像素。避免用条目外部 margin 表示这些间隔,因为 margin 不计入条目测量。示例用 Collapsible 展开附加内容,列表跟随开合动画更新高度。开合状态按条目 key 保存在外部,滚出可见范围再返回时仍然保留。

  • 条目 1

    内容自然换行,每项高度由实际内容决定。

  • 条目 2

    内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。

  • 条目 3

    内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。

  • 条目 4

    内容自然换行,每项高度由实际内容决定。

  • 条目 5

    内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。

  • 条目 6

    内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。

  • 条目 7

    内容自然换行,每项高度由实际内容决定。

  • 条目 8

    内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。

'use client'

import { useState } from 'react'
import {
  VirtualList,
  Collapsible,
  CollapsibleTrigger,
  CollapsibleContent,
  Text,
} from '@hina-ui/react'

const items = Array.from({ length: 500 }, (_, id) => ({
  id,
  title: `条目 ${id + 1}`,
  description: '内容自然换行,每项高度由实际内容决定。'.repeat((id % 3) + 1),
}))

export default function Demo() {
  const [expanded, setExpanded] = useState<Record<number, boolean>>({})

  return (
    <VirtualList
      items={items}
      getKey={item => item.id}
      estimateSize={140}
      height={360}
      label="动态高度列表"
      className="border-line rounded-lg border"
    >
      {({ item }) => (
        <Collapsible
          open={!!expanded[item.id]}
          onOpenChange={open => setExpanded(current => ({ ...current, [item.id]: open }))}
          className="border-line border-b p-4"
        >
          <Text size="sm" weight="medium">
            {item.title}
          </Text>
          <Text size="sm" tone="muted" className="mt-1">
            {item.description}
          </Text>
          <CollapsibleTrigger className="mt-2">
            {expanded[item.id] ? '收起' : '展开'}
          </CollapsibleTrigger>
          <CollapsibleContent>
            <Text size="sm" tone="muted" className="pt-2">
              {'展开后增加的内容会自动参与高度计算。'.repeat(5)}
            </Text>
          </CollapsibleContent>
        </Collapsible>
      )}
    </VirtualList>
  )
}
tsx

滚动与可见范围

通过 ref 调用 scrollToIndex(index, { align, behavior }) 或 scrollToOffset(offset, { behavior })。索引从 0 开始,align 支持 start、center、end 和 auto;默认 auto 只在目标超出视口时滚动。越界索引会限制到首尾项。

behavior="smooth" 开启平滑滚动,系统要求减弱动态效果时使用即时滚动。动态尺寸的远距离跳转会随着目标附近的实际测量继续校正;已知尺寸时使用固定模式可获得精确位置。

onRangeChange 收到实际可见的首尾索引,不包含预渲染和保留焦点的额外条目。可据此按需追加数据;请求状态和是否还有数据由调用方控制。示例使用 NumberInput 指定目标。

可见 1–1
  • 条目 1

  • 条目 2

  • 条目 3

  • 条目 4

  • 条目 5

  • 条目 6

  • 条目 7

  • 条目 8

  • 条目 9

  • 条目 10

  • 条目 11

'use client'

import { useRef, useState } from 'react'
import {
  VirtualList,
  NumberInput,
  Button,
  Inline,
  Stack,
  Text,
  type VirtualListExpose,
  type VirtualListRange,
} from '@hina-ui/react'

const items = Array.from({ length: 10000 }, (_, id) => ({ id, label: `条目 ${id + 1}` }))

export default function Demo() {
  const list = useRef<VirtualListExpose>(null)
  const [target, setTarget] = useState<number | null>(5000)
  const [range, setRange] = useState<VirtualListRange>({ startIndex: 0, endIndex: 0 })

  return (
    <Stack className="w-full">
      <Inline gap="sm">
        <NumberInput
          value={target}
          onValueChange={value => setTarget(value ?? null)}
          min={1}
          max={items.length}
          aria-label="条目序号"
          className="w-36"
        />
        <Button onClick={() => list.current?.scrollToIndex((target ?? 1) - 1, { align: 'center' })}>
          跳转
        </Button>
        <Button variant="outline" onClick={() => list.current?.scrollToOffset(0)}>
          回到顶部
        </Button>
        <Text as="span" size="sm" tone="muted" className="tabular-nums">
          可见 {range.startIndex + 1}–{range.endIndex + 1}
        </Text>
      </Inline>
      <VirtualList
        ref={list}
        items={items}
        getKey={item => item.id}
        dynamic={false}
        estimateSize={48}
        height={288}
        label="可跳转列表"
        className="border-line rounded-lg border"
        onRangeChange={setRange}
      >
        {({ item }) => (
          <Inline gap="none" className="border-line h-full border-b px-4">
            <Text size="sm">{item.label}</Text>
          </Inline>
        )}
      </VirtualList>
    </Stack>
  )
}
tsx

横向与 RTL

orientation="horizontal" 时,estimateSize 表示宽度,height 控制容器高度。动态模式下给 children 返回的内容定义自然宽度;固定模式直接使用声明宽度。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

'use client'

import { VirtualList, Stack, Text } from '@hina-ui/react'

const items = Array.from({ length: 200 }, (_, id) => ({ id, label: `条目 ${id + 1}` }))

export default function Demo() {
  return (
    <Stack className="w-full">
      {(['ltr', 'rtl'] as const).map(dir => (
        <Stack key={dir} gap="sm">
          <Text size="xs" tone="muted" className="uppercase">
            {dir}
          </Text>
          <VirtualList
            items={items}
            getKey={item => item.id}
            orientation="horizontal"
            dir={dir}
            height={144}
            estimateSize={160}
            dynamic={false}
            gap={12}
            label={`${dir} 横向列表`}
          >
            {({ item, index }) => (
              <Stack
                justify="between"
                gap="sm"
                className="border-line bg-surface h-full rounded-lg border p-4"
              >
                <Text size="2xl" tone="muted" className="tabular-nums">
                  {String(index + 1).padStart(2, '0')}
                </Text>
                <Text size="sm">{item.label}</Text>
              </Stack>
            )}
          </VirtualList>
        </Stack>
      ))}
    </Stack>
  )
}
tsx

加载与空态

loading 使用 LoadingOverlay 在列表中央显示加载提示,保留已有条目和滚动位置,不改变滚动视口的尺寸。没有条目时也居中显示,加载时不显示空态。loadingContent 替换加载层的内容,empty 替换空态,空态也可以用 emptyText 修改默认文案。

示例组合 Switch 和 Empty。组件不请求数据,也不清空已有数据。

  • 条目 1

  • 条目 2

  • 条目 3

  • 条目 4

  • 条目 5

  • 条目 6

  • 条目 7

  • 条目 8

  • 条目 9

  • 条目 10

'use client'

import { useMemo, useState } from 'react'
import { VirtualList, Button, Switch, Empty, Inline, Stack, Text } from '@hina-ui/react'

export default function Demo() {
  const [loading, setLoading] = useState(false)
  const [empty, setEmpty] = useState(false)
  const items = useMemo(
    () => (empty ? [] : Array.from({ length: 100 }, (_, id) => ({ id, label: `条目 ${id + 1}` }))),
    [empty],
  )

  return (
    <Stack className="w-full">
      <Inline>
        <Switch checked={loading} onCheckedChange={setLoading}>
          加载中
        </Switch>
        <Button variant="outline" onClick={() => setEmpty(!empty)}>
          {empty ? '恢复条目' : '清空条目'}
        </Button>
      </Inline>
      <VirtualList
        items={items}
        getKey={item => item.id}
        loading={loading}
        height={240}
        estimateSize={48}
        dynamic={false}
        label="列表状态"
        className="border-line rounded-lg border"
        empty={<Empty title="暂无条目" description="列表中没有可显示的内容。" size="sm" />}
      >
        {({ item }) => (
          <Inline gap="none" className="border-line h-full border-b px-4">
            <Text size="sm">{item.label}</Text>
          </Inline>
        )}
      </VirtualList>
    </Stack>
  )
}
tsx

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 边缘阴影
className
string
—
根元素类
itemClass
string | ((item: T, index: number) => string | undefined)
—
条目容器类

其他原生属性透传到根元素。用 itemClass 调整条目外观,不要覆盖其定位属性;动态模式的纵向 padding 会正常计入测量。

内容属性

属性
参数
说明
children
{ item: T, index: number }
条目内容
empty
—
无条目且未加载时的内容
loadingContent
—
加载中的内容

回调

回调
参数
说明
onRangeChange
{ startIndex: number; endIndex: number }
初始及可见范围变化时触发;无可见项时均为 -1

Ref

名称
类型
说明
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。