Appearance
虚拟列表记录
全量渲染 10000 个 DOM 节点时,内存占用和 reflow 成本都会随节点数线性增长,交互响应延迟也会同步恶化。
虚拟列表的核心策略:DOM 节点数与数据量解耦。只渲染可视区域内的节点,不可见区域用占位空间替代。渲染复杂度从 O(N) 降至 O(visibleCount)。
模型
text
┌─────────────────────────┐
│ scroll container │ ← overflow: auto,固定高度
│ │
│ ┌─────────────────────┐ │
│ │ spacer │ │ ← 撑开滚动条高度 = totalCount × itemHeight
│ │ [不可见区域] │ │
│ ├─────────────────────┤ │ ← transform: translateY(startIndex × itemHeight)
│ │ item 7 .. item 12 │ │ ← 只渲染可视 + buffer 区域
│ ├─────────────────────┤ │
│ │ [不可见区域] │ │
│ └─────────────────────┘ │
└─────────────────────────┘三个数字决定渲染什么:
visibleCount = Math.ceil(containerHeight / itemHeight)— 可视区能放几个 itemstartIndex = Math.floor(scrollTop / itemHeight)— 从第几个开始endIndex = startIndex + visibleCount + buffer— 到第几个结束
buffer 是额外多渲染的几个节点,用来避免快速滚动时出现短暂白屏。通常 2-5 个就够用。
定高场景
item 高度固定的情况下实现最直接:
js
class VirtualList {
constructor(container, items, itemHeight) {
this.container = container
this.items = items
this.itemHeight = itemHeight
this.visibleCount = Math.ceil(container.clientHeight / itemHeight) + 3
this.render()
container.addEventListener('scroll', () => this.onScroll())
}
onScroll() {
const scrollTop = this.container.scrollTop
const newStart = Math.floor(scrollTop / this.itemHeight)
if (newStart === this.startIndex) return
this.startIndex = newStart
this.updateVisibleRange()
}
updateVisibleRange() {
const end = Math.min(this.startIndex + this.visibleCount, this.items.length)
const offsetY = this.startIndex * this.itemHeight
const totalHeight = this.items.length * this.itemHeight
// 更新 DOM:只改动变化的内容,不销毁重建
this.spacer.style.height = totalHeight + 'px'
this.viewport.style.transform = `translateY(${offsetY}px)`
this.renderItems(this.startIndex, end)
}
renderItems(start, end) {
// 具体的 item 渲染逻辑——增量更新而非 innerHTML 全量替换
const fragment = document.createDocumentFragment()
for (let i = start; i < end; i++) {
const node = this.pool.get(i) || this.createItemNode(i)
this.updateNodeContent(node, this.items[i])
fragment.appendChild(node)
}
this.viewport.innerHTML = ''
this.viewport.appendChild(fragment)
}
}transform: translateY 用来做偏移,而不是改 top 或 margin-top。原因会在后面的渲染管线部分展开,先简化成一句话:transform 通常只触发 composite,不触发 layout 和 paint,在滚动场景里能省掉一部分关键帧预算。
但 renderItems 里用了 innerHTML = '' 清空再 append——这仍然是全量替换可见 DOM。更好的做法是追踪当前已渲染的 item 集合,只删除离开可见范围的 item、只创建新进入可见范围的 item。DOM 节点复用池(Object Pool)是这个问题的标准解法,后面会展开。
不定高场景
生产环境里 item 高度通常不是固定的。每条内容长度不一、图片尺寸不同、可能有展开/折叠操作。
这里的问题会变成:在 item 高度未知的情况下,怎么判断每个 item 的位置?
这里的处理方式通常是“预估 + 实测纠正”:
- 给未渲染的 item 一个预估值(比如 80px)
- item 渲染后用
ResizeObserver拿到真实高度 - 存入高度缓存
Map<index, height> - 后续计算偏移量时优先用缓存中的真实值
startIndex 的计算从固定高度的 O(1) 退化成了需要累加高度的 O(N):
js
const heightCache = new Map()
const estimatedHeight = 80
function getOffset(index) {
let offset = 0
for (let i = 0; i < index; i++) {
offset += heightCache.get(i) ?? estimatedHeight
}
return offset
}getOffset 每次从头累加显然不合理。优化分两步:
第一步,维护前缀和数组。每次高度更新时重新计算受影响的后缀区间。但因为 item 高度更新只发生在 item 首次渲染时,且只影响该 item 之后的偏移,更新成本通常是 O(K),K 是后续尚未稳定的 item 数量。一旦所有测量完成,getOffset 就是 O(1)。
第二步,如果列表支持插入/删除(不只是追加),前缀和的单点更新变成 O(N)。此时用树状数组(Fenwick Tree)可以在 O(log N) 内完成单点高度更新和前缀和查询。
有了快速的位置查询,就可以用二分查找确定 startIndex:
js
function findStartIndex(scrollTop) {
let lo = 0, hi = items.length - 1
while (lo < hi) {
const mid = (lo + hi) >> 1
if (getOffset(mid) <= scrollTop) lo = mid + 1
else hi = mid
}
return Math.max(0, lo - 1)
}但二分定位本身也有坑。如果 getOffset 是 O(1)(有前缀和),二分是 O(log N)。如果 getOffset 还在 O(N) 累加阶段,那就是 O(N log N)——在没有高度缓存的冷启动阶段,这可能是瓶颈。一个折中:冷启动时用指数搜索(exponential search)而不是二分,利用滚动位置的局部性——用户通常从顶部开始滚,startIndex 增长缓慢。
浏览器渲染管线:为什么 transform 不是 top
虚拟列表所有的性能假设都基于浏览器渲染管线的工作方式。一帧的渲染经过三个阶段:
text
JS/CSS → Style → Layout → Paint → Composite
↑ ↑ ↑ ↑
样式计算 重新布局 重新绘制 GPU 合成每次 DOM 修改的代价取决于触发到哪一层:
| 操作 | 触发阶段 | 一帧成本(典型) |
|---|---|---|
改 top / left / width / height | Layout → Paint → Composite | 5-15ms |
改 color / background / box-shadow | Paint → Composite | 1-5ms |
改 transform / opacity | Composite only | 0.1-0.5ms |
虚拟列表在 scroll 时做的是平移,不是改变布局。transform: translateY(offsetY) 把渲染限制在 Composite 层,整个 visible area 作为一个 GPU 纹理直接位移,不需要 layout、不需要 paint。
但这里有个前提:容器必须形成独立的合成层(compositing layer)。触发条件包括 will-change: transform、3D transform(translateZ(0))、或者 transform 本身在大多数现代浏览器里已经隐式提升。如果没有独立合成层,transform 变化仍然会触发 paint。
更深一层的问题:读取 scrollTop 会强制同步布局吗?
会。scrollTop 读取时,如果之前有未 flush 的 DOM 修改,浏览器必须同步计算 layout 才能返回准确值。所以虚拟列表的 scroll handler 里,先批量读取所有需要的位置信息(scrollTop、clientHeight),再批量写入 DOM,避免 read-write-read 交替导致的 layout thrashing:
js
// ❌ thrashing:读 → 写 → 读 → 写
onScroll() {
const st = container.scrollTop // 读
viewport.style.transform = '...' // 写(脏标记)
const h = container.clientHeight // 读 → 强制 layout flush!
spacer.style.height = '...' // 写
}
// ✅ 批量读 → 批量写
onScroll() {
const st = container.scrollTop // 读
const h = container.clientHeight // 读
// --- 读数全部完成 ---
viewport.style.transform = '...' // 写
spacer.style.height = '...' // 写
}scroll 事件在浏览器内部怎么走
scroll 事件的性能取决于 listener 类型。现代浏览器支持 passive 标记:
js
container.addEventListener('scroll', handler, { passive: true })passive: true 告诉浏览器:这个 handler 不会调 preventDefault(),不必等 JS 执行完再滚动。结果是滚动行为在 compositor thread 上启动,不等 main thread。用户感觉即时滚动,JS 回调异步追上。
非 passive 模式下(passive: false 或未指定,Chrome 默认 scroll listener 是 passive 的但 touch 事件不是),每次滚动都要等 JS handler 执行完才能继续,造成明显的滚动延迟。
虚拟列表对 scroll 回调的处理通常叠加两层:
- RAF 节流:确保 DOM 更新和屏幕刷新率同步
- startIndex 去重:如果计算出的 startIndex 和当前一样,跳过 DOM 更新
js
onScroll() {
if (this.ticking) return
this.ticking = true
requestAnimationFrame(() => {
const newStart = this.computeStartIndex()
if (newStart !== this.startIndex) {
this.startIndex = newStart
this.updateDOM()
}
this.ticking = false
})
}RAF 回调在每帧的 style/layout 计算之前执行,所以 DOM 修改会自然合并到当前帧的渲染管线里,不产生额外帧。
DOM 复用池
虚拟列表每滚动一帧,部分 item 离开可见区,部分 item 进入可见区。创建和销毁 DOM 节点本身有开销——不只是 createElement 的时间,还包括 GC 回收旧节点的成本。
复用池的思路:离开可见区的 DOM 节点不销毁,回收进池;新进入可见区的 item 从池里取节点复用,只更新内容。
js
class DOMPool {
constructor() {
this.pool = []
}
acquire() {
return this.pool.pop() || this.createElement()
}
release(node) {
// 清理后放回池
node.removeAttribute('data-key')
node.textContent = ''
this.pool.push(node)
// 池容量上限,防止无限堆积
if (this.pool.length > 20) this.pool.length = 20
}
}池容量设上限是必要的——滚动停止后池里堆积的 DOM 节点是纯内存负担,且会延长 GC 扫描时间。
源码级:react-window 和 vue-virtual-scroller 内部怎么做的
react-window:惰性 metadata 计算
react-window 的核心在 createListComponent.js,关键数据结构是 instanceProps:
js
instanceProps = {
itemMetadataMap: { [index: number]: { offset: number, size: number } },
lastMeasuredIndex: -1, // 已测量的最高 index
estimatedItemSize: 50, // 未测量 item 的预估高度
}getItemMetadata(index) 是整个可变高度列表的基石。它采用惰性、只向前的计算策略:
text
如果 index <= lastMeasuredIndex:
→ 直接从 itemMetadataMap[index] 取缓存(O(1))
如果 index > lastMeasuredIndex:
→ 从 lastMeasuredIndex + 1 开始向前计算到 index
→ 每个位置调用户提供的 itemSize(i) 获取高度
→ 偏移量递推:offset[i] = offset[i-1] + size[i-1]
→ 更新 lastMeasuredIndex = index关键点:offset 只增不减、只算一次、永久缓存。这意味着用户滚动越快(startIndex 增长越快),metadata 计算越密集——但每个 index 只算一次,滚回去时直接从 map 取值。
findNearestItem(scrollOffset) 采用双策略定位:
- 若 scrollOffset 落在已测量区间内 → 二分查找(O(log N)),在 itemMetadataMap 上二分 offset 值
- 若 scrollOffset 超出已测量区间 → 指数搜索(exponential search),先 2 倍跳跃找到上界,再二分
getEstimatedTotalSize() 计算滚动条总高度:
text
totalSize = 已测量部分的确切高度 + 未测量部分 × estimatedItemSize随着 lastMeasuredIndex 增长,预估值逐渐被实测值覆盖,总高度越来越准。这就是为什么不定高的虚拟列表在初次加载时滚动条长度会变化——estimatedItemSize 和实际高度的偏差在逐步被修正。
vue-virtual-scroller:三池模型
vue-virtual-scroller 的 RecycleScroller 维护三个数据结构:
text
pool[] — 当前可见的 view 数组(直接渲染到模板)
$_views — Map<key, view>,按 item key 做 O(1) 查找
$_unusedViews — Map<type, view[]>,按组件类型分组的复用池每个 view 对象的结构:
js
view = {
item: {}, // 数据对象(响应式)
position: 0, // translateY 像素值
nr: { // 非响应式元数据(Object.defineProperty)
id: uid++,
index: number, // 在 items 数组中的位置
used: boolean, // 当前是否在可视区内
key: any, // item 唯一 key
type: string, // 组件类型(用于 type-based pooling)
}
}nr 用 Object.defineProperty 定义为 non-reactive——这些属性在滚动期间高频变更(每秒几十次),如果走 Vue 的响应式系统会导致不必要的依赖追踪和 watcher 触发。
updateVisibleItems() 是核心方法,分四个阶段:
Phase 1 — 计算可视范围:根据 scrollTop + buffer(默认 200px)确定 [startIndex, endIndex]。如果本次滚动距离不足一个 item 高度,直接跳过(early return),避免无意义的 DOM 操作。
Phase 2 — 回收离屏 view:遍历 pool,对每个 index 不在 [startIndex, endIndex) 的 view 调 unuseView()——将其从 $_views 中移除、position 设为 -9999(移出屏幕)、push 进对应 type 的 $_unusedViews 池。
Phase 3 — 获取/复用 view:遍历 [startIndex, endIndex) 的每个 index:
- 先在
$_views中按 key 查找(同 key 复用,O(1)) - 未命中则从
$_unusedViews[type]中 pop(跨 key 复用,类型匹配) - 仍未命中则
addView()创建新 view
Phase 4 — 排序:从复用池 pop 出来的 view 可能乱序,debounce 300ms 后按 index 排序 pool 数组。
type-based pooling 的意义:不同类型的 item(文本 vs 图片 vs 视频卡片)有不同的 DOM 结构和组件实例。同类复用避免 DOM 结构重建和组件销毁/创建的开销。
和 react-window 的架构差异:react-window 依赖 React 的 reconciliation——通过给 item 分配稳定的 key,让 React diff 自动处理 DOM 复用,本身不维护复用池。vue-virtual-scroller 则在框架层之上自建了完整的三池管理系统。前者简洁(代码量少),后者对 DOM 生命周期有更精确的控制。
帧预算:buffer 到底设多大
60fps 每帧预算 16.6ms。虚拟列表在 scroll 期间要做的事:
- 读 scrollTop(可能触发 layout flush)
- 计算 startIndex
- 更新 DOM(最多 buffer × 2 + visibleCount 个节点)
第 3 步的耗时和 item 复杂度直接相关。一个复杂 item(带图片、富文本、交互元素)的 DOM 更新可能要 0.1-0.3ms。如果 visibleCount + 2×buffer = 20,那总 DOM 操作时间 ≈ 6ms——占帧预算的 36%,尚在安全范围。
buffer 的选择本质是在"白屏概率"和"帧预算消耗"之间折中:
text
buffer = 0: 帧预算消耗低,快速滚动必然白屏
buffer = 2: 帧预算 +20%,适度滚动无白屏
buffer = 5: 帧预算 +50%,极快速滚动无白屏
buffer = 10: 帧预算 +100%,可能掉帧,白屏概率极低实际工程里 2-5 足够了。因为 RAF 节流已经把 DOM 更新频率限制在每帧一次,而用户在一帧内滚动的距离通常不超过 2-3 个 item。唯一的例外是惯性滚动(fling)的末段,手指离开屏幕后滚动仍在继续,速度可能达到每帧 10-20 个 item——这时 buffer=5 可能不够。移动端的解决方案是增加 buffer 的同时降低 item 复杂度(骨架屏代替真实内容)。
图片和异步内容
虚拟列表 + 图片懒加载是一个经典组合,但要注意交互:
场景:item 进入可见区 → 渲染 DOM → 开始加载图片 → 图片加载完成 → item 高度变化 → 后面的 item 位置全部偏移。
如果高度缓存没有更新,用户看到的是后面的 item"跳了一下"。解决方案:
js
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const index = entry.target.dataset.index
const newHeight = entry.contentRect.height
if (heightCache.get(index) !== newHeight) {
heightCache.set(index, newHeight)
// 标记此 index 之后的 item 需要重新计算偏移
invalidateOffsetCacheFrom(index)
// 在当前帧更新位置
scheduleUpdate()
}
}
})ResizeObserver 的回调在 layout 之后、paint 之前执行——这个时机非常适合做位置修正。相比 getBoundingClientRect(),ResizeObserver 不强制同步布局,性能更优。
invalidateOffsetCacheFrom(index) 的关键:不定高场景下,一个 item 高度变化会改变之后所有 item 的偏移量。如果每次高度变化都全部重算 offset 数组,10K 条数据会卡。优化:标记脏区间,在下一帧的 RAF 里批量重算受影响区间的前缀和。
常见 bug 拓扑
滚动条跳动:totalHeight 计算不准或者高度缓存值不稳定导致滚动条长度在滚动过程中变化。症状是"滚轮滚着滚着,滚动条 thumb 大小在变"。定高方案不会出现。不定高的根因通常是 estimatedHeight 和实际高度差距过大,或者同一个 item 高度频繁变换(比如内容折叠/展开后)。
搜索跳转定位:Ctrl+F 搜索到列表外的内容,浏览器尝试 scroll-into-view,但目标 item 还没渲染——DOM 里根本没有这个文本。需要向上拦截 scrollToIndex 逻辑:先算出目标 item 位置,设置 startIndex,渲染该 item,再滚动。
键盘导航焦点丢失:用户按上下箭头,焦点移到了下一个"应该存在但还没渲染"的 item 上。解决方案:在 keydown handler 里拦截导航键,手动计算目标 index,调 scrollToIndex 确保该 item 的 DOM 存在后再让焦点进入。
a11y 断裂:屏幕阅读器读到的是不完整的 DOM 树。需要 ARIA 属性补充信息:
html
<div role="list" aria-rowcount="{totalCount}" aria-label="搜索结果列表">
<div role="listitem" aria-rowindex="{index + 1}">{content}</div>
</div>aria-rowcount 告知总行数,aria-rowindex 告知当前行号,这样屏幕阅读器可以说出"第 57 项,共 10000 项"。
和 CSS content-visibility 的关系
CSS 提供了 content-visibility: auto,浏览器自己跳过屏幕外元素的渲染。那还需要虚拟列表吗?
content-visibility: auto 让浏览器自动跳过屏幕外元素的 layout 和 paint,但 DOM 节点本身仍然存在。10000 个节点依然占用内存,只不过不渲染。对于内存敏感的场景(移动端、低端设备),虚拟列表通过不创建 DOM 节点来省内存,content-visibility 做不到。
另一个差异:content-visibility 对屏幕外元素只跳过了渲染,但 JS 查询(querySelector、getBoundingClientRect)仍然会遍历所有节点。而虚拟列表下的 DOM 树只包含可见节点,JS 操作也在 O(visibleCount) 范围内。
实际工程可以是两者叠加——虚拟列表负责控制 DOM 节点数量,列表内的 item 再加 content-visibility: auto 作为二次保障。但叠加的收益递减:虚拟列表已经控制了 DOM 数量,content-visibility 省的那点渲染成本很有限。
性能基准:数据说话
纯 DOM vs 虚拟列表
用一个简单的列表项(固定高度 40px,纯文本内容),在 Chrome 桌面版上测量:
| 数据量 | 方案 | DOM 节点数 | 首次渲染 | 滚动 FPS | 内存占用 |
|---|---|---|---|---|---|
| 1,000 | 全量渲染 | 1,000+ | ~15ms | 60 | ~3MB |
| 1,000 | 虚拟列表 | ~25 | ~2ms | 60 | ~1.5MB |
| 10,000 | 全量渲染 | 10,000+ | ~120ms | 45-50 | ~25MB |
| 10,000 | 虚拟列表 | ~25 | ~2ms | 60 | ~1.5MB |
| 100,000 | 全量渲染 | 100,000+ | ~1200ms | 5-10 | ~180MB |
| 100,000 | 虚拟列表 | ~25 | ~2ms | 60 | ~1.5MB |
数据量从 1K 增长到 100K,虚拟列表的 DOM 节点数、首次渲染时间、内存占用基本不变——O(1) 的 DOM 复杂度。全量渲染则线性退化。
但在实际项目中,item 复杂度远高于"40px 纯文本"。假设每个 item 包含:头像图片 + 标题 + 描述 + 时间戳 + 操作按钮,DOM 节点约 15-20 个,样式规则约 10-15 条,可能有事件监听器。
这种复杂度下,虚拟列表的优势更明显——因为省的不是 DOM 节点本身的内存(几十个节点怎么都省不了多少),而是每个 item 的样式计算、布局计算和事件系统的开销。100K 个复杂 item 全量渲染时,单次全局 reflow 的成本高达秒级。
虚拟列表引入的性能开销
虚拟列表不是免费的。它引入了两类额外开销:
- scroll 事件处理:RAF 回调 + startIndex 计算 + DOM 更新。稳态下约 0.5-2ms/帧。
- 高度缓存维护(不定高场景):ResizeObserver 回调 + 前缀和更新。首次渲染所有可见 item 时每个 item 触发一次回调。
这些开销在数据量 < 100 时可能比全量渲染还大——即虚拟列表存在一个"不值得用"的数据量阈值。根据 item 复杂度不同,阈值大约在 50-200 之间。
FPS 测量方法
验证虚拟列表是否工作正常的客观标准:滚动时 FPS 是否稳定在 60。
Chrome DevTools 的 Performance 面板可以录一段滚动操作,看 FPS 曲线。更实时的方案是 rAF-based FPS meter:
js
let lastTime = performance.now()
let frames = 0
let fps = 60
function measureFPS() {
frames++
const now = performance.now()
if (now >= lastTime + 1000) {
fps = Math.round(frames * 1000 / (now - lastTime))
frames = 0
lastTime = now
}
requestAnimationFrame(measureFPS)
}虚拟列表应该做到:慢速滚动 60fps,快速滚动 55-60fps。如果快速滚动时掉到 30-40fps,排查重点依次是:RAF 节流是否生效 → layout thrashing → item 组件 render 太重 → buffer 过大。
INP(Interaction to Next Paint)影响
INP 衡量用户交互到浏览器渲染下一帧的时间。虚拟列表的 scroll 处理如果不在 RAF 内,会导致 scroll 事件的 INP 超标。因为每次 scroll 事件 → JS handler 执行 → DOM 修改 → 浏览器渲染,如果这个链路超过 200ms,就会被 INP 捕获为"慢交互"。
正确做法:scroll 事件的 handler 只做轻量的 scrollTop 读取和 RAF 调度,真正的 DOM 操作全部在 RAF 回调里。这样 scroll 事件本身的处理时间 < 1ms,不会被计入 INP。
架构决策
虚拟列表在系统中的定位
虚拟列表的核心抽象是一个坐标转换层:
text
┌──────────────────┐
data space (逻辑空间) │ items[0..N-1] │ ← 数据层,N 可以无限大
└────────┬─────────┘
│
┌────────▼─────────┐
virtual list (转换层) │ index → position │ ← 核心:逻辑 index → 物理像素
│ scrollTop → index │
└────────┬─────────┘
│
┌────────▼─────────┐
DOM space (物理空间) │ ~20 DOM nodes │ ← 渲染层,数量恒定
└──────────────────┘这个转换层做了两件事:正向映射(index → pixel offset,用于渲染定位)和反向映射(scrollTop → startIndex,用于确定渲染范围)。定高场景下这两个映射都是 O(1) 的简单乘除。不定高场景下退化为二分查找 + 前缀和。
组件边界设计
虚拟列表组件的职责边界应该是:只管理可见性,不管理数据获取。
text
正确分层:
VirtualList: 负责"哪些 item 应该渲染"、"每个 item 在哪个位置"
ItemComponent: 负责"一个 item 长什么样"
DataFetcher: 负责"数据从哪来"(分页加载、无限滚动、游标管理)典型的职责越界:虚拟列表组件内部集成了分页数据加载逻辑。这会导致列表在"数据加载中"和"滚动中"两个状态叠加,状态组合爆炸(正在加载 + 正在滚动 + 缓存未命中 + 图片未加载 = 4 维状态空间)。
正确做法是把数据加载抽成独立的 hook/service,虚拟列表只接收一个 items[] 数组。数据从哪来、怎么分页、是否在加载中——这些不是虚拟列表的职责。
架构演化路径
text
v1: 固定高度虚拟列表(200 行代码)
↓ 需求驱动:内容长度不一
v2: 可变高度 + 高度缓存 + 前缀和(400 行代码)
↓ 需求驱动:滚动到第 5000 条
v3: scrollToIndex + 指数/二分混合搜索(450 行代码)
↓ 需求驱动:列表嵌入聊天页面,消息实时追加
v4: 支持 prepend + 滚动锚定(scroll anchoring)(600 行代码)
↓ 需求驱动:iOS Safari 惯性滚动掉帧
v5: 平台特化(will-change、-webkit-overflow-scrolling、passive touch)(650 行代码)
↓ 需求驱动:不同 item 类型混排
v6: type-based pooling + 动态组件(800+ 行代码)大多数团队在 v2 或 v3 停住。v4+ 是在特定产品需求驱动下才需要的——比如聊天应用的消息列表(需要 prepend + 锚定),或者 feed 流中的多类型卡片混排。
关键架构权衡
| 抉择 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| 自建虚拟列表 | 完全控制;无依赖;可定制 | 维护成本;边界情况需自行处理 | 定制需求强或依赖体积敏感的团队 |
| 用 react-window / vue-virtual-scroller | 生态成熟;边界情况已处理 | 受限于库的抽象;定制需 hack | 大多数业务场景 |
| 定高实现 | 实现简单;O(1) 定位;滚动条稳定 | 不支持动态内容高度 | 表格数据、统一卡片、日志列表 |
| 不定高实现 | 适配任意内容 | 高度缓存维护复杂;滚动条可能跳动;定位 O(log N) | 富文本、可变卡片、评论列表 |
| 纯虚拟列表(不加载数据) | 职责清晰 | 需配合分页加载 | 数据量可控的场景 |
| 虚拟列表 + 无限滚动 | 滚动 + 加载无缝 | 状态管理复杂;需处理"加载中"和"滚动"的交错 | 大数据量 + 后端分页 |
生产实践
上线前 checklist
- [ ] RAF 节流生效(Performance 面板确认单帧内至多一次 DOM 更新)
- [ ]
passive: true已设(确认 scroll 不阻塞 compositor thread) - [ ] 批量读写分离(没有 read-write-read 交替)
- [ ] buffer 合理(2-5 个 item,移动端可适当增大)
- [ ] 无效的 DOM 更新已去重(startIndex 不变时跳过 DOM 操作)
- [ ] 容器有独立合成层(
will-change: transform或等效手段) - [ ] 图片有尺寸占位(防止加载后高度突变导致布局偏移)
- [ ]
ResizeObserver回调有 debounce(高度变化时批量更新前缀和) - [ ] 搜索跳转 / scrollToIndex 已处理(目标 index 的 DOM 不存在时的逻辑)
- [ ] ARIA 属性已补充(
aria-rowcount、aria-rowindex)
排障:scroll jank
用户在可滚动区域内感觉到"卡顿"或"不跟手"。
排查步骤:
- DevTools Performance 面板录制 → 看 scroll 期间 FPS 曲线。掉帧在哪个阶段(JS?Layout?Paint?Composite?)
- 若 JS 阶段耗时长 → 检查 RAF 回调中的计算量。是否每次都在重算前缀和?是否正确跳过了 startIndex 不变的帧?
- 若 Layout 阶段耗时长 → 检查是否有 layout thrashing(read-write-read 交替)。检查是否在使用
top而非transform定位。检查 ResizeObserver 回调中是否做了触发 layout 的操作(读了clientHeight后立即改transform)。 - 若 Paint 阶段耗时长 → 检查合成层是否生效。检查 item 是否有大量 box-shadow / filter / backdrop-filter 等昂贵的绘制属性。
- 若 Composite 阶段耗时长 → 检查是否有过多合成层(layer explosion)。每个 item 如果都有
will-change: transform会导致 layer 数量爆炸。
移动端特殊处理
移动端浏览器的滚动机制和桌面端不同:
- iOS Safari 的
scroll事件在惯性滚动结束前不会触发(只在手指触摸期间和惯性滚动结束后触发)。这意味着虚拟列表在惯性滚动期间收不到 scroll 事件,不会更新可见范围。如果 buffer 不够大,用户会在惯性滚动末段看到白屏。解决方案:增大 buffer,或使用touchmove事件辅助。 - Android Chrome 的 scroll 事件行为类似桌面,但通常运行在性能更低的设备上。item 组件应尽可能轻量。
- 移动端的
-webkit-overflow-scrolling: touch会创建新的 stacking context,影响合成层行为。需要单独测试。
扩展问题
以下内容整理了虚拟列表实现中的延伸问题。
Q1: 你提到用 transform 而不是 top 来偏移,为什么?transform 在浏览器内部经过了什么路径?
核心区别在渲染管线。top 改变触发 Layout(Recalculate Style → Layout → Paint → Composite),整个渲染管线从头跑。transform 只触发 Composite——浏览器在之前帧已经绘制好的图层上做 GPU 位移即可。前提是目标元素有独立的合成层(compositing layer)。
合成层的创建条件:will-change: transform、3D transform(translateZ(0))、transform 的非 none 值(现代浏览器对 animation/transition 中的 transform 隐式提升)。如果一个元素没有合成层,transform 变化仍然需要触发 paint(因为它在父图层里,需要和兄弟元素一起重绘)。
追问:合成层会消耗额外的 GPU 显存(每个层在 GPU 中有一份纹理副本)。如果给列表里每个可见 item 都设置 will-change: transform,20 个 item 就是 20 个合成层,每层消耗 1-2MB 显存。合理做法是只给包裹所有可见 item 的容器 viewport 设 will-change: transform——一个层就够了。
Q2: 你说 RAF 节流确保和屏幕刷新率同步,但如果用户的显示器是 120Hz 的,RAF 会每 8.3ms 触发一次。虚拟列表的 DOM 更新能在 8.3ms 内完成吗?
120Hz 帧预算是 8.3ms。如果虚拟列表的 DOM 更新耗时 2ms,加上框架的 diff/render 开销 3-4ms,总共 5-6ms——够用但余量不大。如果有复杂 item(图表、大量图片),就需要考虑:
- 减少 buffer(降低 DOM 操作量)
- 骨架屏代替真实内容(快速滚动时只渲染占位符)
- Web Worker 预处理数据(startIndex 计算、前缀和维护)
但这些优化在 60Hz 显示器上通常不需要。关键原则:按最差设备设性能目标,不要按开发机设。
Q3: 不定高虚拟列表的 totalHeight 在滚动过程中会变化,导致滚动条长度跳动。怎么让用户无感知?
滚动条跳动的原因是 getEstimatedTotalSize 返回的高度随着 lastMeasuredIndex 增长而变化。核心矛盾:我们必须在"提供一个准确但可能跳动的滚动条"和"提供一个虚假但稳定的滚动条"之间选一个。
react-window 的选择是前者——接受跳动,因为用户通常不会盯着滚动条。如果体验不可接受,可以在 totalHeight 上加一个单调性约束:totalHeight = max(currentTotalHeight, previousTotalHeight),确保滚动条只变长不变短。但这会导致"滚到底部发现还有内容"。实际工程里,如果 item 高度偏差不大(estimatedHeight 接近真实均值),跳动幅度很小,用户基本无感。
Q4: 如果数据是实时更新的(比如股票行情列表),item 的顺序和内容都在变。虚拟列表怎么处理?
这是虚拟列表最难的场景之一。三个挑战叠加:
- 顺序变化:之前 startIndex=10 对应的 item 现在可能是 index=15。如果用 index 做 key,同一 index 的内容变了但 DOM 被复用,出现内容错乱。
- 内容变化:同一 item 的数据更新了(比如价格变了),但因为它不在可见区或正在被复用,DOM 没更新。
- 高度变化:内容变化导致 item 高度变化,之前缓存的高度和偏移全部失效。
标准解法:用稳定且唯一的业务 ID 做 key,而不是 index。配合 resetAfterIndex 能力——当某个 item 高度变化时,标记该 index 之后的 metadata 需要重新计算。顺序变化时调用 scrollToItem(id) 而不是 scrollToIndex(index)。
虚拟列表在实时数据场景下表现天然不如静态数据场景。如果实时性要求极高(毫秒级价格更新、WebSocket 推送每秒数百条),需要权衡:是否值得为了列表性能牺牲数据更新的实时性?很多交易类产品会选择放弃虚拟列表,改用 canvas 渲染或者限制列表长度(比如只显示前 200 条)。
Q5: 前端虚拟列表和后端分页在架构上是互补还是冗余?
它们解决不同层面的问题。后端分页解决的是数据传输成本(每次只传一页数据),虚拟列表解决的是渲染成本(只渲染可见节点)。
两者互补的典型架构:后端返回全量数据 ID 列表(轻量,几百 KB)→ 前端虚拟列表展示 → 接近可见区时懒加载详情数据。
两者冗余的情况:后端每次只返回一页数据(20 条),前端却上了虚拟列表——20 条数据全量渲染也就 20 个 DOM 节点,虚拟列表的维护成本比收益还大。此时虚拟列表是过度设计。
是否同时使用,判断标准很简单:前端持有的数据条目数是多少? 如果前端只有当前页的数据(几十条),虚拟列表是负优化。如果前端持有全量数据(几千条以上),才值得上虚拟列表。
