虚拟列表原理与不定高处理
虚拟列表的本质是「只渲染看得见的那十几行」。定高版本很简单,不定高才是真实项目的难点。
一、定高版本的三个量
假设每行高度固定,算出可视区域能放几行,加上上下各几行缓冲,只渲染这一小段即可。
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 会破坏偏移计算。
二、不定高的测量与估算
真实项目里行高往往不固定:内容长短不一、有的行带图片、展开后高度变化。这时无法用除法算起始下标。
标准解法是维护一份位置缓存,流程是四步:
- 先按预估高度渲染,保证首屏能出来;
- 元素渲染后测量真实高度,回写缓存;
- 修正累计偏移与总高度;
- 用前缀和 + 二分查找定位起始下标。
// 前缀和: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 回读真实高度,是目前最通用的解法。