Skip to content

虚拟列表记录

全量渲染 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 区域
│  ├─────────────────────┤ │
│  │  [不可见区域]       │ │
│  └─────────────────────┘ │
└─────────────────────────┘

三个数字决定渲染什么:

  1. visibleCount = Math.ceil(containerHeight / itemHeight) — 可视区能放几个 item
  2. startIndex = Math.floor(scrollTop / itemHeight) — 从第几个开始
  3. 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 用来做偏移,而不是改 topmargin-top。原因会在后面的渲染管线部分展开,先简化成一句话:transform 通常只触发 composite,不触发 layout 和 paint,在滚动场景里能省掉一部分关键帧预算。

renderItems 里用了 innerHTML = '' 清空再 append——这仍然是全量替换可见 DOM。更好的做法是追踪当前已渲染的 item 集合,只删除离开可见范围的 item、只创建新进入可见范围的 item。DOM 节点复用池(Object Pool)是这个问题的标准解法,后面会展开。


不定高场景

生产环境里 item 高度通常不是固定的。每条内容长度不一、图片尺寸不同、可能有展开/折叠操作。

这里的问题会变成:在 item 高度未知的情况下,怎么判断每个 item 的位置?

这里的处理方式通常是“预估 + 实测纠正”:

  1. 给未渲染的 item 一个预估值(比如 80px)
  2. item 渲染后用 ResizeObserver 拿到真实高度
  3. 存入高度缓存 Map<index, height>
  4. 后续计算偏移量时优先用缓存中的真实值

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 / heightLayout → Paint → Composite5-15ms
color / background / box-shadowPaint → Composite1-5ms
transform / opacityComposite only0.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 回调的处理通常叠加两层:

  1. RAF 节流:确保 DOM 更新和屏幕刷新率同步
  2. 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) 采用双策略定位

  1. 若 scrollOffset 落在已测量区间内 → 二分查找(O(log N)),在 itemMetadataMap 上二分 offset 值
  2. 若 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)
  }
}

nrObject.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 期间要做的事:

  1. 读 scrollTop(可能触发 layout flush)
  2. 计算 startIndex
  3. 更新 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 查询(querySelectorgetBoundingClientRect)仍然会遍历所有节点。而虚拟列表下的 DOM 树只包含可见节点,JS 操作也在 O(visibleCount) 范围内。

实际工程可以是两者叠加——虚拟列表负责控制 DOM 节点数量,列表内的 item 再加 content-visibility: auto 作为二次保障。但叠加的收益递减:虚拟列表已经控制了 DOM 数量,content-visibility 省的那点渲染成本很有限。


性能基准:数据说话

纯 DOM vs 虚拟列表

用一个简单的列表项(固定高度 40px,纯文本内容),在 Chrome 桌面版上测量:

数据量方案DOM 节点数首次渲染滚动 FPS内存占用
1,000全量渲染1,000+~15ms60~3MB
1,000虚拟列表~25~2ms60~1.5MB
10,000全量渲染10,000+~120ms45-50~25MB
10,000虚拟列表~25~2ms60~1.5MB
100,000全量渲染100,000+~1200ms5-10~180MB
100,000虚拟列表~25~2ms60~1.5MB

数据量从 1K 增长到 100K,虚拟列表的 DOM 节点数、首次渲染时间、内存占用基本不变——O(1) 的 DOM 复杂度。全量渲染则线性退化。

但在实际项目中,item 复杂度远高于"40px 纯文本"。假设每个 item 包含:头像图片 + 标题 + 描述 + 时间戳 + 操作按钮,DOM 节点约 15-20 个,样式规则约 10-15 条,可能有事件监听器。

这种复杂度下,虚拟列表的优势更明显——因为省的不是 DOM 节点本身的内存(几十个节点怎么都省不了多少),而是每个 item 的样式计算、布局计算和事件系统的开销。100K 个复杂 item 全量渲染时,单次全局 reflow 的成本高达秒级。

虚拟列表引入的性能开销

虚拟列表不是免费的。它引入了两类额外开销:

  1. scroll 事件处理:RAF 回调 + startIndex 计算 + DOM 更新。稳态下约 0.5-2ms/帧。
  2. 高度缓存维护(不定高场景):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-rowcountaria-rowindex

排障:scroll jank

用户在可滚动区域内感觉到"卡顿"或"不跟手"。

排查步骤:

  1. DevTools Performance 面板录制 → 看 scroll 期间 FPS 曲线。掉帧在哪个阶段(JS?Layout?Paint?Composite?)
  2. 若 JS 阶段耗时长 → 检查 RAF 回调中的计算量。是否每次都在重算前缀和?是否正确跳过了 startIndex 不变的帧?
  3. 若 Layout 阶段耗时长 → 检查是否有 layout thrashing(read-write-read 交替)。检查是否在使用 top 而非 transform 定位。检查 ResizeObserver 回调中是否做了触发 layout 的操作(读了 clientHeight 后立即改 transform)。
  4. 若 Paint 阶段耗时长 → 检查合成层是否生效。检查 item 是否有大量 box-shadow / filter / backdrop-filter 等昂贵的绘制属性。
  5. 若 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 的顺序和内容都在变。虚拟列表怎么处理?

这是虚拟列表最难的场景之一。三个挑战叠加:

  1. 顺序变化:之前 startIndex=10 对应的 item 现在可能是 index=15。如果用 index 做 key,同一 index 的内容变了但 DOM 被复用,出现内容错乱。
  2. 内容变化:同一 item 的数据更新了(比如价格变了),但因为它不在可见区或正在被复用,DOM 没更新。
  3. 高度变化:内容变化导致 item 高度变化,之前缓存的高度和偏移全部失效。

标准解法:用稳定且唯一的业务 ID 做 key,而不是 index。配合 resetAfterIndex 能力——当某个 item 高度变化时,标记该 index 之后的 metadata 需要重新计算。顺序变化时调用 scrollToItem(id) 而不是 scrollToIndex(index)

虚拟列表在实时数据场景下表现天然不如静态数据场景。如果实时性要求极高(毫秒级价格更新、WebSocket 推送每秒数百条),需要权衡:是否值得为了列表性能牺牲数据更新的实时性?很多交易类产品会选择放弃虚拟列表,改用 canvas 渲染或者限制列表长度(比如只显示前 200 条)。

Q5: 前端虚拟列表和后端分页在架构上是互补还是冗余?

它们解决不同层面的问题。后端分页解决的是数据传输成本(每次只传一页数据),虚拟列表解决的是渲染成本(只渲染可见节点)。

两者互补的典型架构:后端返回全量数据 ID 列表(轻量,几百 KB)→ 前端虚拟列表展示 → 接近可见区时懒加载详情数据。

两者冗余的情况:后端每次只返回一页数据(20 条),前端却上了虚拟列表——20 条数据全量渲染也就 20 个 DOM 节点,虚拟列表的维护成本比收益还大。此时虚拟列表是过度设计。

是否同时使用,判断标准很简单:前端持有的数据条目数是多少? 如果前端只有当前页的数据(几十条),虚拟列表是负优化。如果前端持有全量数据(几千条以上),才值得上虚拟列表。