Перейти к содержимому

virtual-list виртуальный список —— Настройка

Виртуальный список — это «решение рендеринга для десятков тысяч записей»: отрисовываются лишь десятки элементов в области просмотра, а при прокрутке они динамически заменяются. Отрисовка 100 000 строк наглухо вешает браузер, а виртуальный список держит число DOM-узлов постоянным. Цена — высота должна быть предсказуема: в режиме фиксированной высоты она вычисляется напрямую, а в режиме динамической высоты — сначала измеряется. core/virtual-list инкапсулирует оба режима и разделяет с table единый алгоритм ядра из shared/virtual.

  • При реализации <LhVirtualList> на каждой платформе: длинные списки сообщений, потоки логов, адресные книги, опции выпадающих списков с большими данными.
  • Имеет смысл, только когда объем данных превышает вместимость области просмотра в 5+ раз и отрисовка каждого элемента недешева.
  • Не следует использовать: десятки или сотни записей (рендерить напрямую — расходы на виртуализацию не окупаются); высота элементов абсолютно непредсказуема и не измеряется (придётся принять фиксированную высоту или обеспечить возможность измерения); сценарии, требующие полного DOM (выделение всех, поиск Ctrl+F в браузере, печать целой страницы — содержимое вне виртуального списка отсутствует в DOM и не найдётся).
import {
LH_VIRTUAL_ITEM_SIZE,
LH_VIRTUAL_OVERSCAN,
computeWindow,
computeVirtualRange,
buildVirtualItems,
patchMeasuredSize,
findVirtualRange,
getItemOffset,
getVirtualListClassNames,
} from '@lh-ui/share/core/virtual-list'
import type { LhVirtualItem, LhVirtualRange } from '@lh-ui/share/core/virtual-list'

Два режима: фиксированная высота (computeRange, диапазон за O(1)) и динамическая высота (buildVirtualItems + patchMeasuredSize + findVirtualRange, требуется измерение). Фиксированная высота рекомендована по умолчанию — просто и без мерцания. Динамическая применяется, когда высота содержимого действительно неравномерна (сообщения чата разной длины).

Общее ядро с table: комментарии в shared/virtual фиксируют историю — алгоритм фиксированной высоты когда-то существовал в двух экземплярах (virtual-list и table), причём даже лишняя прокрутка по умолчанию была разной (2 vs 3), и исправление в одном месте не синхронизировалось с другим. После выделения единого алгоритма оба места стали тонкими обёртками. LH_TABLE_OVERSCAN = 3 — кастомное значение со стороны table, а virtual-list использует значение по умолчанию LH_VIRTUAL_OVERSCAN = 2.

Очистка NaN защищает от белого экрана: комментарии в computeRange говорят прямо — NaN-овый scrollTop, попадая в Math.max / Math.floor, порождает NaN-овое окно, а array.slice(NaN) возвращает пустой массив → белый экран. Все входные параметры проходят очистку через Number.isFinite. findVirtualRange идёт дальше: items[NaN].offset выбросит TypeError и прервёт отрисовку.

first ограничивается total - 1: в том кадре, когда данные сжались (удалили строки), а scrollTop ещё не ограничен, first может превысить total, что приведёт к start > end → slice вернёт пустоту → белый экран. Это ограничение является исправлением гонки.

patchMeasuredSize меняет на месте: измерив реальную высоту, функция модифицирует исходный массив, сдвигая смещения всех последующих элементов, и возвращает дельту изменения размеров. В отличие от полного перестроения buildVirtualItems (новый массив за O(n) + дифф для всех элементов), она проходит по массиву один раз и не вызывает повторного рендеринга неизменившихся элементов. Комментарии фиксируют этот компромисс — неизменяемость здесь уступает место производительности.

Ускорение getItemOffset для фиксированной высоты: прокрутка к N-ному элементу в режиме фиксированной высоты делается напрямую как N * itemHeight, массив items не нужен — документация функции прямо говорит, что он используется только в динамическом режиме.

computeWindow — версия, не зависящая от оси: горизонтальная виртуальная прокрутка (scrollLeft/viewportWidth/itemWidth) и вертикальная используют один и тот же алгоритм, различаются лишь имена полей: offsetY заменяется на offset. LhAxisRange и LhVirtualRange синонимичны.

Функция / константа Сигнатура Возврат Назначение
computeRange (через computeVirtualRange) (scrollTop, viewportHeight, itemHeight, total, overscan?) => LhVirtualRange видимый диапазон режим фиксированной высоты
computeWindow (scrollOffset, viewportSize, itemSize, total, overscan?) => LhAxisRange диапазон, не зависящий от оси горизонтальная виртуализация
buildVirtualItems (count, sizes, defaultSize?) => { items, totalHeight } массив элементов предрасчёт динамических высот
patchMeasuredSize (items, index, size, defaultSize?) => number delta инкрементное обновление измеренных размеров
findVirtualRange (items, scrollTop, viewportHeight, overscan?) => LhVirtualRange видимый диапазон бинарный поиск при динамической высоте
getItemOffset ⚠️ (items, index) => number смещение прокрутка к заданному элементу
getVirtualListClassNames () => string[] классы контейнер

Константы: LH_VIRTUAL_ITEM_SIZE = 40 (высота элемента по умолчанию), LH_VIRTUAL_OVERSCAN = 2 (лишняя прокрутка по умолчанию).

⚠️ computeVirtualRange / getItemOffset — совместимые псевдонимы @deprecated; новый код должен использовать computeRange / getItemOffsetAt из shared/virtual.

export interface LhVirtualItem {
size: number // расчётная/измеренная высота элемента
offset: number // смещение элемента относительно начала списка
}
export interface LhVirtualRange {
start: number // начальный индекс (включительно)
end: number // конечный индекс (не включительно)
offsetY: number // смещение окна, px (высота плейсхолдера, удерживающая ползунок прокрутки)
totalHeight: number // полная высота, px (создаёт полосу прокрутки)
}

Виртуальный список — невидимый вызов для скринридеров: в DOM находятся лишь десятки элементов, и скринридер не может сообщить «элемент 3000 из 10000».

  • aria-rowcount / aria-rowindex: контейнеру задаётся aria-rowcount (общее число элементов), а каждому элементу — aria-rowindex (реальный индекс + 1). Так делается виртуальная прокрутка в table; для списков это можно позаимствовать. Эти атрибуты отрисовывает UI-слой, core вычисляет только диапазон.
  • Сохранение фокуса при прокрутке: если пользователь навёл фокус на элемент клавиатурой, а затем прокрутил список, элемент покидает DOM и теряет фокус. UI-слой обязан запомнить индекс фокуса перед размонтированием и восстановить после повторного монтирования — это ключевая трудность клавиатурной пригодности виртуального списка.
  • Не делайте виртуальный список семантическим списком: «обход по элементам» у скринридера проходит только по тем элементам, которые есть в DOM. Чисто показательные потоки больших данных (логи, сообщения) подходят для виртуализации; списки, по которым пользователь скринридера хочет ходить поштучно, виртуализируйте с осторожностью (например, каталог товаров, который хотят прослушать элемент за элементом).
  • Поиск и позиционирование: содержимое вне виртуального списка отсутствует в DOM, и браузерный Ctrl+F его не найдёт. Возмещайте это функциями «перейти к N-ному элементу» и «поиск-фильтрация».
import {
computeRange, buildVirtualItems, patchMeasuredSize, findVirtualRange,
} from '@lh-ui/share/core/virtual-list'
// Режим фиксированной высоты
computeRange(800, 400, 50, 1000)
// { start: 14, end: 26, offsetY: 700, totalHeight: 50000 }
// (first = floor(800/50) = 16, start = 16-2 = 14, visible = ceil(400/50) = 8,
// end = 14 + 8 + 4 = 26)
computeRange(0, 400, 50, 10) // { start: 0, end: 10, offsetY: 0, totalHeight: 500 }
computeRange(NaN, 400, 50, 10) // { start: 0, end: 10, offsetY: 0, totalHeight: 500 } (очистка NaN)
computeRange(0, 400, 0, 10) // { start: 0, end: 0, offsetY: 0, totalHeight: 0 } (некорректный itemHeight)
// Режим динамической высоты
const { items, totalHeight } = buildVirtualItems(5, [30, 0, 60], 40)
// items: [{ size: 30, offset: 0 }, { size: 40, offset: 30 }, { size: 60, offset: 70 },
// { size: 40, offset: 130 }, { size: 40, offset: 170 }]
// totalHeight: 210 (пропущенные размеры оцениваются по defaultSize=40)
const delta = patchMeasuredSize(items, 1, 50) // 10 (дельта изменения 40 → 50)
// items[2..4].offset сдвигаются на +10; items[1].size = 50
findVirtualRange(items, 100, 200)
// бинарный поиск диапазона области просмотра, возвращает { start, end, offsetY, totalHeight }
// UI-слой: рендеринг в режиме фиксированной высоты
const range = ref<LhVirtualRange>({ start: 0, end: 0, offsetY: 0, totalHeight: 0 })
function onScroll() {
range.value = computeRange(
container.value!.scrollTop,
container.value!.clientHeight,
props.itemHeight,
props.data.length,
)
}
const visibleData = computed(() => props.data.slice(range.value.start, range.value.end))
<!-- Vue3 -->
<template>
<div class="lh-virtual-list" @scroll.passive="onScroll">
<div class="lh-virtual-list__phantom" :style="{ height: range.totalHeight + 'px' }">
<div class="lh-virtual-list__window" :style="{ transform: `translateY(${range.offsetY}px)` }">
<div v-for="item in visibleData" :key="item.id" class="lh-virtual-list__item">
{{ item.text }}
</div>
</div>
</div>
</div>
</template>
// Режим динамической высоты: после рендера измеряем реальную высоту каждого item
function onItemMounted(index: number, el: HTMLElement) {
const height = el.getBoundingClientRect().height
const delta = patchMeasuredSize(items.value, index, height)
if (delta !== 0) {
// изменение высоты меняет totalHeight и последующие offset, пересчитываем диапазон
range.value = findVirtualRange(items.value, scrollTop.value, viewportHeight.value)
}
}
// React: массив items хранится в useRef (patchMeasuredSize меняет на месте и не
// вызывает setState, чтобы не запустить цепочку повторных измерений);
// события прокрутки троттлятся через throttle или requestAnimationFrame
// uniapp: <scroll-view scroll-y :scroll-top> + запрос узлов (createSelectorQuery)
// для измерения высоты; события scroll в мини-программе уже троттлятся,
// но прокрутка scroll-view отстаёт от h5 на такт
Класс Семантика
.lh-virtual-list контейнер прокрутки (фиксированная высота + overflow: auto)
.lh-virtual-list__phantom элемент-плейсхолдер (высота = totalHeight, создаёт полосу прокрутки)
.lh-virtual-list__window видимое окно (transform: translateY(offsetY))
.lh-virtual-list__item отдельный элемент

Высота контейнера задаётся бизнесом (height: 400px или через flex); will-change: transform оптимизирует производительность сдвига окна.

  • В режиме фиксированной высоты высота должна быть действительно фиксированной: переносы содержимого, разные размеры иконок делают высоту элементов неравномерной, и в фиксированном режиме возникает рассинхрон (элементы обрезаются или накладываются). Если есть сомнения — переходите на динамическую высоту или принудительно задавайте одну строку через CSS white-space: nowrap; overflow: hidden; text-overflow: ellipsis.
  • Смысл overscan: лишние 2 элемента сверху и снизу области просмотра предрендерятся, чтобы при быстрой прокрутке не мелькали пустоты. Чем больше значение, тем плавнее, но рендерится больше — 2 это эмпирическое значение по умолчанию, у table используется 3 (строки таблицы сложнее).
  • Мерцание при динамической высоте: первый экран рендерится с расчётными высотами, а после измерения реальные высоты отличаются → список подпрыгивает. Смягчение: максимально точные расчётные значения (средняя высота из истории) или синхронное измерение при первом рендере.
  • Применение delta из patchMeasuredSize: возвращаемая разница высот нужна для коррекции scrollTop — после изменения размеров положение прокрутки может перестать соответствовать прежнему содержимому, и UI-слой компенсирует прокрутку.
  • Стабильность key: key в v-for обязан быть стабильным бизнес-id (а не индексом) — виртуальный список часто добавляет и удаляет элементы, и использование индекса в качестве key приведёт к повторному использованию не тех DOM-узлов.
  • Горизонтальная виртуализация — через computeWindow: прокручивающиеся вбок карусели и таймлайны используют версию, не зависящую от оси; алгоритм тот же.
  • Псевдонимы @deprecated: computeVirtualRange / getItemOffset — старые имена, делегирующие в shared/virtual; новый код подключает ядро напрямую (@lh-ui/share/core/shared/virtual), а нижестоящий vue2 всё ещё ссылается на старые имена — до удаления нужно сначала перевести его.
  • Циклическая зависимость момента измерения: рендер зависит от диапазона → диапазон зависит от высоты → высота зависит от измерения после рендера. Разрывается так: первый экран рендерится с расчётными высотами, а после измерения обновляется один раз. Не делайте синхронного принудительного layout в колбэке измерения (getBoundingClientRect — это уже один reflow).
  • Не делайте сложных анимаций в виртуальном списке: элементы часто монтируются и размонтируются, анимации появления и исчезновения будут мерцать. Если анимация нужна, используйте FLIP или размещайте её внутри самого элемента.
@lh-ui/share/core/virtual-list → computeRange( )
返回值
(填好参数后点运行)

Copyright © 2024-2026 FengMo WenXue