virtual-list 虛擬列表 —— 設定
이 콘텐츠는 아직 번역되지 않았습니다.
虛擬列表是「萬條資料的渲染方案」:只渲染視區內的幾十條,捲動時動態替換。10 萬行資料全部渲染會卡死瀏覽器,虛擬列表讓 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 就地修改:動態高度測到真實高度後,修改原陣列並順移後續所有項的 offset,傳回尺寸變化量 delta。相比 buildVirtualItems 全量重建(O(n) 新陣列 + 觸發全部 diff),它只遍歷一次、且不引起未變化項的重渲染。註解寫明了這個取捨——不可變性在這裡讓位給效能。
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 相容別名,新程式碼直接用 shared/virtual 的 computeRange / getItemOffsetAt。
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 層需要在卸載前記錄焦點 index、重新掛載後還原——這是虛擬列表鍵盤可用性的核心難點。
- 不要用虛擬列表做語義列表:螢幕閱讀器的「按元素遍歷」只能遍歷 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(sizes 缺漏的項按 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:useRef 存 items 陣列(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(表格行更複雜)。- 動態高度的閃爍問題:首屏用估算高度渲染,測量後真實高度不同 → 列表跳一下。緩解:估算值盡量準(用歷史平均高度)、或首屏渲染時同步測量。
patchMeasuredSize的 delta 用途:傳回的高度差用於校正 scrollTop——尺寸變化後捲動位置可能不再對應原來的內容,UI 層據此補償捲動量。- key 的穩定性:
v-for的 key 必須是穩定業務 id(不是 index)——虛擬列表頻繁增刪項,用 index 做 key 會復用錯 DOM。 - 水平虛擬化用
computeWindow:橫向捲動的輪播、時間軸用軸無關版本,演算法同一份。 @deprecated別名:computeVirtualRange/getItemOffset是舊名,委託shared/virtual——新程式碼直接引核心(@lh-ui/share/core/shared/virtual),下游 vue2 仍引用舊名,刪除前先改下游。- 測量時機的循環依賴:渲染依賴區間 → 區間依賴高度 → 高度依賴渲染後的測量。破解:首屏用估算高度渲染,測量後更新一次。別在測量回呼裡同步強制版面配置(
getBoundingClientRect已經是一次重排)。 - 不要在虛擬列表裡做複雜動畫:項頻繁掛載卸載,進場 / 退場動畫會閃爍。需要動畫用 FLIP 或把動畫做在項內部。
@lh-ui/share/core/virtual-list → computeRange( )(填好参数后点运行)Copyright © 2024-2026 FengMo WenXue