Skip to main content

虚拟列表原理与不定高处理

虚拟列表的本质是「只渲染看得见的那十几行」。定高版本很简单,不定高才是真实项目的难点。

一、定高版本的三个量​

可视窗口 = startIndex 偏移的 itemHeight + 视口高度完整列表(10000 条,总高 400000px)item 0 ~ 9(startIndex = 0)…… 视口外,不渲染 ……item 990 ~ 1009(endIndex = 1009)…… 视口外,不渲染 ……item 9990 ~ 9999(endIndex = 9999)transform: translateY(990 × 40px) —— 把 startIndex 对应的行挪到容器顶部,再用一个高 totalHeight 的占位层撑出滚动条总长。
图:定高虚拟列表只需三个量——startIndex、视口高度、itemHeight

假设每行高度固定,算出可视区域能放几行,加上上下各几行缓冲,只渲染这一小段即可。

function FixedList({ items, rowHeight, height }) {
const [scrollTop, setScrollTop] = useState(0)
const count = Math.ceil(height / rowHeight)
const start = Math.floor(scrollTop / rowHeight)
const end = Math.min(items.length, start + count + OVERSCAN)

return (
<div style={{ height, overflow: 'auto' }} onScroll={(e) => setScrollTop(e.target.scrollTop)}>
{/* 撑起真实高度,让滚动条长度正确 */}
<div style={{ height: items.length * rowHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${start * rowHeight}px)` }}>
{items.slice(start, end).map(renderRow)}
</div>
</div>
</div>
)
}

两个实现要点:

  • 外层要有一个撑满总高度的占位元素,否则滚动条长度不对,滚不到底;
  • 用 transform: translateY() 整体偏移,而不是给每一行定位。前者只触发合成,后者会为每一行触发布局计算。

滚动时按 scrollTop 算出起始下标再切片,逻辑就这么简单。定高版本的所有难点都在「如何不让它抖动」,而不在算法本身。

真实项目里一般不必自己写上面这套——react-window 的 FixedSizeList 已经把切片、缓冲、回收都做好了,先确认是否能直接用它:

import { FixedSizeList } from 'react-window'

<FixedSizeList height={400} itemCount={items.length} itemSize={rowHeight} overscanCount={3}>
{({ index, style }) => <div style={style}>{items[index].name}</div>}
</FixedSizeList>

注意 style 必须透传到行元素上,它承载了定位和高度——自己再套一层 div 会破坏偏移计算。

滚出视野(不渲染)可视区:只渲染这几行约 15~20 行缓冲区:预渲染几行防白屏三个量决定实现scrollTop:滚了多少视口高:看得到多少行高:一行占多少不定高:先估算、测量后回填快速滚动:缓冲区兜住白屏
图:虚拟列表只渲染可视区与缓冲区,其余行不产生 DOM

二、不定高的测量与估算​

真实项目里行高往往不固定:内容长短不一、有的行带图片、展开后高度变化。这时无法用除法算起始下标。

标准解法是维护一份位置缓存,流程是四步:

  1. 先按预估高度渲染,保证首屏能出来;
  2. 元素渲染后测量真实高度,回写缓存;
  3. 修正累计偏移与总高度;
  4. 用前缀和 + 二分查找定位起始下标。
// 前缀和:offsets[i] 表示第 i 行的顶部位置
function buildOffsets(heights) {
const offsets = new Array(heights.length + 1)
offsets[0] = 0
for (let i = 0; i < heights.length; i++) offsets[i + 1] = offsets[i] + heights[i]
return offsets
}

// 二分查找:找出第一个「底部位置 > scrollTop」的行
function findStart(offsets, scrollTop) {
let lo = 0, hi = offsets.length - 1
while (lo < hi) {
const mid = (lo + hi) >> 1
if (offsets[mid + 1] <= scrollTop) lo = mid + 1
else hi = mid
}
return lo
}

// 实测后回写缓存
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const index = Number(entry.target.dataset.index)
const height = entry.borderBoxSize[0].blockSize
if (heightCache[index] !== height) {
heightCache[index] = height
recomputeOffsets() // 注意:这会改变上方行的累计偏移
}
}
})

两个必须处理好的细节:

为什么不能直接遍历。 行数上万时,每次滚动都从头累加一遍是 O(n),快速滚动会掉帧。前缀和把定位降到 O(log n)。

高度修正后要校正滚动位置。 如果上方某行的真实高度比预估大,累计偏移会变,用户看到的内容会整体跳动。要么在修正时同步调整 scrollTop,要么接受一次轻微的跳动但保证不累积。这是不定高实现里最难调的一处。

三、虚拟化付出的代价​

虚拟列表不是免费的,它换来了性能,也拿走了一些东西:

  • Ctrl+F 找不到内容 —— DOM 里根本没有全部文本;
  • 锚点跳转与打印会失效 —— 同理;
  • 读屏器读不到全部内容,SEO 也拿不到完整文本;
  • 浏览器原生查找、文本选择跨行会异常。

补救办法有限但值得做:给容器补上表格语义(role="grid" + 行的 role="row")再用 aria-rowcount / aria-rowindex,读屏器才知道规模与位置——这两个属性加在普通 div 上会被忽略;打印时切换到非虚拟视图——这是最实用的一条,打印本来就是全量场景;需要完整查找的场景干脆用分页。

四、和分页的分工​

判据是使用方式,不是数据量:

  • 需要连续滚动、要保持滚动位置、要快速浏览大量条目 → 虚拟列表;
  • 需要深链与分享定位(把第三十条发给别人)、需要完整查找、需要打印、内容要被搜索引擎收录 → 分页。

两者也可以组合:分页取数 + 已加载部分虚拟渲染,这在服务端分页的场景里很常见。

滚动事件要用 rAF 收口,一帧只算一次切片,否则事件堆积会反过来拖慢渲染:

let raf = null

function onScroll(e) {
const top = e.currentTarget.scrollTop
if (raf) return // 这一帧已经有待处理的更新了
raf = requestAnimationFrame(() => {
raf = null
setRange(computeRange(top)) // 只提交一次,中间的位置丢弃
})
}

// 快滚时丢掉中间帧是有意为之:宁可短暂空白,也不要把主线程压死

五、快滚时为什么白屏​

滚动太快时,渲染跟不上,用户看到空白。三种处理:

  • 加大 overscan —— 多渲染几行缓冲,代价是抵消一部分虚拟化收益;
  • 用 requestAnimationFrame 节流渲染 —— 一帧只更新一次切片,避免堆积;
  • 先渲染占位块 —— 来不及渲染的行用等高灰块顶上,视觉上比空白好得多。

注意 overscan 不是越大越好:缓冲行数超过一屏,就等于没做虚拟化。

白屏的那一帧可以用「先画占位、再画真内容」顶住——来不及测高的行先用预估高度占个位,视觉上比空白好得多:

function Row({ index, style, data }) {
const measured = data.heights[index] // 可能还没测到
return (
<div style={{ ...style, height: measured ?? data.estimate }}>
{measured ? data.items[index].content : <span className="skeleton" />}
</div>
)
}

配合 requestAnimationFrame 节流,一帧只更新一次切片,避免滚动事件堆积把主线程压垮。

六、虚拟列表解决不了一切​

「少渲染几个 DOM」只是最表层的部分,难点全在不定高时的测量、缓存与偏移校正,定高只是入门题。

虚拟化也有代价:页内查找、打印、读屏、SEO 都会受影响,要用属性和单独的视图去补偿。

缓冲行数不是越多越流畅,缓冲过多会抵消虚拟化的收益,白屏问题优先靠占位与节流解决。

也不是所有长列表都该虚拟化——需要深链、完整查找、打印的场景,分页更合适。

定高虚拟列表半小时能写完,不定高才是真实项目的考题——它的难点不在渲染,在测量与校正。

不定高场景下靠 ResizeObserver 回读真实高度,是目前最通用的解法。