手写虚拟列表核心实现深度解析:用 30 个 DOM 渲染 10 万条数据
面试高频手写题:从零实现虚拟列表(Virtual List),讲清可视区渲染、滚动偏移与 startIndex/endIndex 计算思路。 掌握后能现场写出定高/不定高两种方案,应对万级数据长列表性能优化的追问。
一句话概括
虚拟列表的核心思想就一句话:只渲染看得见的那几条——通过一个高度等于「数据总量 × 单项高度」的占位 div 撑起滚动条,用 transform: translateY(startIndex * itemHeight) 把可见内容偏移到正确位置,最终 10 万条数据和 10 条数据用的 DOM 节点数一样。
核心知识点
1. 定高虚拟列表:3 层 DOM + 2 个公式
1
2
3
4
<div class="container" style="height:500px;overflow-y:auto">
<div class="phantom" style="height:6_000_000px"></div> <!-- 100000 × 60px -->
<div class="content" style="transform:translateY(0px)"></div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const ITEM_HEIGHT = 60 // 固定高度
function getRange(scrollTop, viewportH) {
const start = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - 5) // overscan=5
const end = Math.min(data.length, Math.ceil((scrollTop + viewportH) / ITEM_HEIGHT) + 5)
return { start, end }
}
function render() {
const { scrollTop, clientHeight } = container
const { start, end } = getRange(scrollTop, clientHeight)
content.style.transform = `translateY(${start * ITEM_HEIGHT}px)`
content.innerHTML = data.slice(start, end)
.map(item => `<div class="row" style="height:${ITEM_HEIGHT}px">${item.title}</div>`)
.join('')
}
// rAF 节流:一帧内只渲染一次
let raf = null
container.addEventListener('scroll', () => {
cancelAnimationFrame(raf)
raf = requestAnimationFrame(render)
}, { passive: true })
三个关键决策:
overflow-y: auto在容器上,让浏览器管理滚动- 占位 phantom div 的高度 =
data.length * ITEM_HEIGHT,撑起真实滚动条 - 用
transform: translateY()而非top——前者只触发 Composite,不触发布局重排
2. 不定高:预估 + 测量 + 二分查找
每一项高度不固定(比如包含图片的卡片),需要三步:
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
// 1. 位置缓存:维护每一项的 top/bottom/height
const positions = data.map((_, i) => ({
index: i,
top: i * ESTIMATED, // 预估
bottom: (i + 1) * ESTIMATED,
height: ESTIMATED,
}))
// 2. 二分查找 scrollTop 对应的起始索引
function findIndex(scrollTop) {
let lo = 0, hi = positions.length - 1
while (lo <= hi) {
const mid = (lo + hi) >> 1
if (positions[mid].top <= scrollTop) lo = mid + 1
else hi = mid - 1
}
return Math.max(0, hi)
}
// 3. 渲染后用 getBoundingClientRect 测量实际高度,更新缓存
function updateHeight(index, actualH) {
const diff = actualH - positions[index].height
if (Math.abs(diff) < 1) return
positions[index].height = actualH
positions[index].bottom = positions[index].top + actualH
// 后续位置全部后移
for (let i = index + 1; i < positions.length; i++) {
positions[i].top += diff
positions[i].bottom += diff
}
phantom.style.height = `${positions[positions.length - 1].bottom}px`
}
不定高的核心 trick:只测量可视区域内元素的实际高度,不可见的不测量。每次滚动只更新 20-30 个元素的高度,O(n) 更新后续位置虽然理论上是 O(n),但 n=100000 时遍历一次约 0.5ms,在 16ms 帧预算内完全可行。
3. 动态高度场景:图片加载后重排
1
2
3
4
5
6
7
// 图片加载完成后重新校准高度
function onImageLoad(index, imgEl) {
const actualH = imgEl.closest('.row').getBoundingClientRect().height
updateHeight(index, actualH)
// 用 transform 偏移,避免视觉跳动
render()
}
避免跳动的关键:padding-bottom 比例占位:
1
2
3
<div style="width:100%; padding-bottom:56.25%"> <!-- 16:9 -->
<img style="position:absolute;width:100%;height:100%" src="..." onload="..." />
</div>
图片加载前就用 padding-bottom 撑起正确的高度,避免加载后 layout shift。
4. DOM 节点池复用
每次 innerHTML = '' 重建所有可见节点太浪费。用对象池避免频繁 createElement:
1
2
3
const pool = []
function acquireEl() { return pool.pop() || document.createElement('div') }
function releaseEl(el) { el.innerHTML = ''; if (pool.length < 50) pool.push(el) }
5. 对比三种实现方案
| 方案 | 适用场景 | 复杂度 | 包大小 |
|---|---|---|---|
| react-window | React 项目,定高为主 | 低 | 6KB |
| TanStack Virtual | 多框架,不定高动态尺寸 | 中 | 9KB |
| 手写 | 轻量需求,深度定制 | 高 | 200行 |
其实你每天都在用
- 朋友圈/微博 Feed — 你刷到第 500 条动态时,实际的 DOM 节点只有屏幕上的 ~20 个,前面的全被回收了
- Excel 网页版 — 10000 行表格,屏幕只显示 30 行,其余全是虚拟行
- 聊天消息列表 — 群聊 5000 条消息,往上翻时只有可视的 10-15 条在 DOM 里
- GitHub 文件列表 — 大仓库几千个文件,虚拟列表让滚动如丝般顺滑
- Ant Design Select 下拉 — 选项超过 100 个时,内部就是虚拟列表
常见误解(FAQ)
❌ 误区:「虚拟列表和懒加载是一回事」
懒加载是「先不渲染,滚到了再渲染,渲染后就一直在 DOM 里」;虚拟列表是「只渲染看得见的,滚走了就销毁」。前者 DOM 节点随滚动只增不减,后者始终保持恒定数量。
❌ 误区:「virtal list 用 position:absolute + top 就行」
top 的变化会触发 Layout → Paint → Composite 全流程。transform 只触发 Composite,由 GPU 合成线程处理,不占主线程。性能差距在低端机上可达 10 倍。
❌ 误区:「定高虚拟列表就不能处理动态内容了」
图片等内容在容器高度固定时可以用 object-fit: cover,文本截断用 line-clamp。另外可以在 item render 函数内保持容器高度固定,内容变化不超出高度即可。
❌ 误区:「不定高 = 计算太多,性能差」
二分查找 O(log n),对于 100 万条数据也只有 20 次比较。高度更新只影响可视区附近,其余位置通过数组批量偏移完成。实测下来 10 万条不定高列表的滚动帧率仍能稳定 60fps。
一句话总结
虚拟列表是前端性能优化中最「暴力」的手段——直接砍掉 99.97% 的 DOM 节点。它的本质就是用 JavaScript 的 O(log n) 查找 + O(1) 偏移计算,替代浏览器的 O(n) Layout 计算。记住三个词:占位撑高度、transform 做偏移、rAF 做节流。