文章

手写虚拟列表核心实现深度解析:用 30 个 DOM 渲染 10 万条数据

面试高频手写题:从零实现虚拟列表(Virtual List),讲清可视区渲染、滚动偏移与 startIndex/endIndex 计算思路。 掌握后能现场写出定高/不定高两种方案,应对万级数据长列表性能优化的追问。

手写虚拟列表核心实现深度解析:用 30 个 DOM 渲染 10 万条数据

一句话概括

虚拟列表的核心思想就一句话:只渲染看得见的那几条——通过一个高度等于「数据总量 × 单项高度」的占位 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-windowReact 项目,定高为主低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 做节流。

本文由作者按照 CC BY 4.0 进行授权