手写:虚拟列表核心逻辑深度解析
从零手写一个支持定高和动态高度的虚拟列表组件,逐行拆解核心计算、缓存策略和 transform 优化
一句话概括
手写虚拟列表就三步:用占位 div 撑起正确滚动条 → 根据 scrollTop 算当前该显示哪些项 → 用 transform 把可见项偏移到正确位置。定高版本 30 行,动高版本加上二分查找和高度缓存约 80 行。
核心知识点
1. 定高版本(30 行,面试直接写这个)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function FixedVirtualList({ items, itemHeight = 50, containerHeight = 600 }) {
const [scrollTop, setScrollTop] = useState(0)
const totalHeight = items.length * itemHeight
const startIndex = Math.floor(scrollTop / itemHeight)
const visibleCount = Math.ceil(containerHeight / itemHeight)
const endIndex = Math.min(startIndex + visibleCount + 2, items.length) // +2 缓冲
const offsetY = startIndex * itemHeight
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: totalHeight }} /> {/* 占位层 */}
<div style={{ transform: `translateY(${offsetY}px)` }}> {/* 可视层 */}
{items.slice(startIndex, endIndex).map((item, i) => (
<div key={startIndex + i} style={{ height: itemHeight }}>{item}</div>
))}
</div>
</div>
)
}
逐行解释:
totalHeight— 占位 div 的高度 = 所有项的总高,让滚动条长度正确startIndex—scrollTop / itemHeight向下取整,算出滚动到了第几项endIndex—startIndex + visibleCount + 2,多渲 2 项防止快速滚动白屏offsetY— 可视数据块应该从startIndex * itemHeight的位置开始显示transform: translateY— 用 GPU 合成而不是改top,避免回流
2. 动态高度版本:二分查找 + 高度缓存
定高版本的核心假设 itemHeight 是常量——现实中几乎没有一个列表能满足这个条件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
interface Position {
index: number
top: number
bottom: number
height: number
}
function DynamicVirtualList({ items, containerHeight = 600, estimateHeight = 50 }) {
const [scrollTop, setScrollTop] = useState(0)
const positionsRef = useRef<Position[]>([])
// 初始化:预估每个项的高度
useEffect(() => {
positionsRef.current = items.map((_, i) => ({
index: i,
top: i * estimateHeight,
bottom: (i + 1) * estimateHeight,
height: estimateHeight,
}))
}, [items.length, estimateHeight])
// 二分查找:找到第一个 bottom > scrollTop 的索引
const findStartIndex = useCallback((scrollTop: number): number => {
let low = 0, high = positionsRef.current.length - 1
while (low <= high) {
const mid = (low + high) >> 1
if (positionsRef.current[mid].bottom <= scrollTop) low = mid + 1
else high = mid - 1
}
return low
}, [])
const positions = positionsRef.current
if (positions.length === 0) return null
const startIndex = findStartIndex(scrollTop)
const endIndex = positions.findIndex(
p => p.bottom > scrollTop + containerHeight
)
const actualEnd = endIndex === -1 ? items.length : Math.min(endIndex + 2, items.length)
const offsetY = startIndex > 0 ? positions[startIndex - 1].bottom : 0
const totalHeight = positions[positions.length - 1]?.bottom ?? 0
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: totalHeight }} />
<div style={{ transform: `translateY(${offsetY}px)` }}>
{items.slice(startIndex, actualEnd).map((item, i) => (
<ItemRow
key={startIndex + i}
item={item}
index={startIndex + i}
onHeightMeasured={(index, height) => {
// 高度校准:实测后更新缓存
const pos = positions[index]
const diff = height - pos.height
if (diff === 0) return
pos.height = height
pos.bottom = pos.top + height
// 向后修正所有后续项的位置
for (let j = index + 1; j < positions.length; j++) {
positions[j].top += diff
positions[j].bottom += diff
}
}}
/>
))}
</div>
</div>
)
}
三个关键细节:
- 二分查找 — 动态高度的 startIndex 不能用除法算,必须二分找。O(log n) 保证滚动计算不成为瓶颈
- 高度校准 — 首次用预估值,DOM 渲染后真实高度如果不一致,必须修正 positions 数组中所有后续项的 top/bottom,否则滚动位置会错乱
positions[startIndex - 1].bottom做 offset — 注意是startIndex - 1的 bottom,因为第 0 项的 top 是 0
3. 高度校准的实现细节
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ItemRow 组件:每次渲染后用 ResizeObserver 测量实际高度
function ItemRow({ item, index, onHeightMeasured }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!ref.current) return
const observer = new ResizeObserver(([entry]) => {
onHeightMeasured(index, entry.contentRect.height)
})
observer.observe(ref.current)
return () => observer.disconnect()
}, [index, onHeightMeasured])
return <div ref={ref}>{item}</div>
}
用 ResizeObserver 而非 getBoundingClientRect + onScroll,因为前者是异步回调、不阻塞主线程,而且能捕获内容变化引起的高度变化(如图片加载后撑开高度)。
4. 性能要点总结
| 要点 | 原因 |
|---|---|
transform 而非 top/left | transform 在合成线程,不触发 layout |
| 二分查找 | O(log n) 找索引,百万级数据也能瞬间定位 |
ResizeObserver 而非 getBoundingClientRect | 异步、不强制同步回流 |
key 用 startIndex + i | 使 React 在滚动时复用 DOM 而非重建 |
| 缓冲区 2-5 项 | 覆盖快速滚动时 scroll 事件触发的延迟 |
其实你每天都在用
- 微信消息列表 — 几万条聊天记录只渲染你看得到的那十几条,就是虚拟列表
- Chrome 开发者工具的 Network 面板 — 几百个请求的列表只渲染可见行
- VSCode 的文件树 — 大项目不卡就是因为只渲染展开且可见的节点
- 飞书多维表格 — 百万行数据表滚动不卡的工作量全在虚拟化上
- Ant Design Table 的
virtualprop — 内部就实现了这些逻辑
常见误解(FAQ)
❌ 误区:「虚拟列表就是无限滚动(Infinite Scroll)」
两者解决不同问题。无限滚动解决「数据太多一次加载不完」,通过分页加载;虚拟列表解决「DOM 太多一次渲染不完」,数据全在内存但只渲染可见 DOM。生产环境通常是两者组合:分页加载数据 + 虚拟化渲染当前页。
❌ 误区:「预估高度和实际高度差距大也没事」
预估值偏差越大,首次渲染后的校准步伐越剧烈——用户会看到列表项突然跳位置。好的做法是用后端返回的数据推断:文字量多的项预估高一些,固定模板的项直接写死精确值。
❌ 误区:「动态高度列表会很慢」
二分查找 + ResizeObserver 的组合已经让动态高度不会成为瓶颈。真正慢的是频繁校准大量未渲染项的高度——所以校准逻辑只需要更新 positions 数组中的数字(纯 JS 操作),不要触发额外渲染。
一句话总结
虚拟列表是「用计算换 DOM」的典范——scrollTop 除 height、二分查索引,两行核心公式把十万个 DOM 节点压缩到十几个,但前提是缓存校准必须做对,否则滚动条会背叛你。