虚拟滚动在 AI 流式内容渲染中的深度实践

随着 AI 驱动的对话与内容生成类产品持续演进,前端面临一个全新挑战:如何在浏览器中流畅渲染数万乃至数十万行的流式 AI 输出内容?

传统 DOM 全量渲染早已不堪重负——浏览器合成层过大、重排耗时激增、内存持续飙升,最终导致页面卡顿甚至崩溃。虚拟滚动的核心理念"只渲染可视区域"看似简单,但在 AI 流式内容场景下,动态高度、中间插入、快速定位等问题交织,构成了远超普通表格/列表的复杂度。

本文将用五层认知模型,从原理到实践,完整拆解这套虚拟化渲染方案的每一个关键决策。


一、基础模型:虚拟化的核心原理

🧠 核心理念:只渲染可视区及缓冲区,根据滚动偏移量动态替换内容。

固定高度 vs 动态高度的本质差异

虚拟滚动的数学模型可以抽象为三要素:总内容高度可视区起始索引偏移量补偿

固定高度:索引直接计算

当每一项高度恒定时,计算极为简单:

// 固定高度场景:可视区起止索引 O(1) 计算
const ITEM_HEIGHT = 48

function getVisibleRange(scrollTop: number, containerHeight: number) {
  const startIndex = Math.floor(scrollTop / ITEM_HEIGHT)
  const visibleCount = Math.ceil(containerHeight / ITEM_HEIGHT)
  const endIndex = Math.min(startIndex + visibleCount + overscan, totalCount)
  const offsetY = startIndex * ITEM_HEIGHT
  return { startIndex, endIndex, offsetY }
}

动态高度:测量与估算的博弈

而 AI 生成内容的每一项高度差异极大——短则一行文字,长则整段代码块。这时必须引入高度缓存与估算机制:

// 动态高度场景:需要维护高度缓存
interface ItemMeta {
  index: number
  estimatedHeight: number // 预估高度
  actualHeight: number // 实际测量高度
  offsetTop: number // 累计顶部偏移
}

const itemMetaMap = new Map<number, ItemMeta>()

❗ 大坑提醒:动态高度场景下,仅靠预估高度而不做修正,滚动条位置会随着实际渲染逐渐漂移,用户直观感受就是"滚动跳动"。


二、高度计算策略:三种方案的取舍

🔧 观点:高度测量方案的选择,本质是在"准确性""性能""实现复杂度"三者间做权衡。

方案对比

方案准确性性能影响实现复杂度适用场景
预估高度 + 渲染后修正中等低(仅修正时重排)高度差异不大的列表
测量真实高度 + 缓存中(批量测量导致重排)静态或低频变化列表
ResizeObserver 监听极高中(每个项一个观察器)动态内容、自适应布局

方案一:预估高度 + 渲染后修正

最轻量的实现。先用一个平均高度占位,渲染完成后测量真实高度并回写缓存,重新计算偏移。

// 预估高度方案
const ESTIMATED_HEIGHT = 60

function estimateOffset(index: number): number {
  let offset = 0
  for (let i = 0; i < index; i++) {
    offset += itemMetaMap.get(i)?.actualHeight ?? ESTIMATED_HEIGHT
  }
  return offset
}

// 渲染完成后修正
function onItemRendered(index: number, el: HTMLElement) {
  const actual = el.getBoundingClientRect().height
  const meta = itemMetaMap.get(index)!
  if (meta.actualHeight !== actual) {
    meta.actualHeight = actual
    updateOffsetsFrom(index) // 从该索引开始重新计算所有偏移
  }
}

⚠️ 问题:修正时后续项的偏移量级联变化,如果高度偏差较大,用户会看到内容"跳动"。适合高度差异不大的场景。

方案二:测量真实高度 + 缓存

先批量执行一次"离屏测量",将测量结果写入缓存,后续直接使用缓存值:

// 批量测量——注意批量导致重排
function batchMeasure(elements: HTMLElement[]): Map<number, number> {
  const heights = new Map<number, number>()
  // ✅ 一次性读取避免多次强制同步布局
  for (const el of elements) {
    heights.set(el.dataset.index, el.getBoundingClientRect().height)
  }
  return heights
}

❗ 关键提醒:批量读取 getBoundingClientRect 时,如果读和写操作交错,每一次写后立即读都会触发强制同步布局(Forced Reflow)。务必先全量读取,再全量写入。

方案三:ResizeObserver 持续监听

对需要自适应内容(窗口缩放、内容动态展开/折叠)的场景,使用 ResizeObserver 监听每一项:

const resizeObserver = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const index = Number((entry.target as HTMLElement).dataset.index)
    const newHeight = entry.contentRect.height
    const meta = itemMetaMap.get(index)!
    if (meta.actualHeight !== newHeight) {
      meta.actualHeight = newHeight
      // 触发偏移重算和列表刷新
      scheduleUpdate(index)
    }
  }
})

// 渲染项时挂载观察器
function mountItem(el: HTMLElement, index: number) {
  el.dataset.index = String(index)
  resizeObserver.observe(el)
}

⚠️ 取舍:每个观察器都有内存开销。当列表项数量达到万级以上时,应为可视区之外的项调用 unobserve() 释放观察器,仅保留可视区 + 缓冲区的监听。


三、快速定位算法:从索引到像素的精准跳转

💡 场景驱动:用户点击"跳转到第 5321 个 token",或搜索关键词后需要高亮并滚动到目标位置。

问题分析

在全量 DOM 渲染方案中,element.scrollIntoView() 可以一步到位。但在虚拟滚动中,目标项可能根本不在 DOM 树中——必须先算出它的"理论区间",再将滚动位置调整到该区间,等待渲染完成后再做微调。

分段聚合索引 + 二分查找

不能遍历所有项计算偏移(O(n) 在 10 万项时不可接受),必须建立索引结构:

// 分段聚合索引:每 N 项存储一个累计高度锚点
interface SectionAnchor {
  startIndex: number // 分段起始索引
  cumulativeHeight: number // 该分段之前的累计总高度
}

const SECTION_SIZE = 100 // 每 100 项建一个锚点
const sectionAnchors: SectionAnchor[] = []

function buildAnchors() {
  let cumulative = 0
  for (let i = 0; i < totalCount; i += SECTION_SIZE) {
    sectionAnchors.push({ startIndex: i, cumulativeHeight: cumulative })
    // 累加该分段内所有项的高度
    for (let j = i; j < Math.min(i + SECTION_SIZE, totalCount); j++) {
      cumulative += itemMetaMap.get(j)?.actualHeight ?? ESTIMATED_HEIGHT
    }
  }
}

// O(log n) 二分查找目标索引的偏移量
function binarySearchOffset(targetIndex: number): number {
  const segmentIdx = binarySearchAnchor(targetIndex) // 先定位分段
  const anchor = sectionAnchors[segmentIdx]
  let offset = anchor.cumulativeHeight
  // 段内小范围线性累加(最多 SECTION_SIZE 项)
  for (let i = anchor.startIndex; i < targetIndex; i++) {
    offset += itemMetaMap.get(i)?.actualHeight ?? ESTIMATED_HEIGHT
  }
  return offset
}

关键词搜索 + 高亮滚动

// 搜索结果跳转的完整流程
async function scrollToSearchResult(matchIndex: number) {
  // 1. 预估偏移——目标可能尚未渲染,使用缓存高度估算
  const estimatedOffset = binarySearchOffset(matchIndex)

  // 2. 先滚动到预估位置,触发目标区域渲染
  scrollContainer.scrollTop = estimatedOffset

  // 3. 等待渲染完成(requestAnimationFrame 确保 DOM 已更新)
  await new Promise<void>((resolve) => {
    requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
  })

  // 4. 读取真实偏移,执行微调滚动(补偿预估误差)
  const actualOffset = binarySearchOffset(matchIndex)
  if (Math.abs(actualOffset - estimatedOffset) > 1) {
    scrollContainer.scrollTop = actualOffset
  }

  // 5. 触发高亮
  highlightItem(matchIndex)
}

❗ 关键细节:为什么要两次 requestAnimationFrame?单次 RAF 在渲染前回调,此时 DOM 写操作已排队但尚未绘制。双重 RAF 确保浏览器已完成布局和绘制周期。


四、流式动态内容:AI 逐步 Append 的挑战

🔧 核心难点:AI 生成内容是逐步追加的,虚拟列表不仅要支持尾部插入,某些场景下还要支持中间插入(如 AI 在对话中补充前文遗漏信息)。

维护稳定锚点

流式插入时,如果直接按新数据重新计算所有偏移,正在浏览的用户的画面会产生剧烈跳动。解决方案是:维护一个稳定的锚点项,滚动位置基于锚点重新计算

interface AnchorState {
  index: number // 锚点项索引
  offsetFromTop: number // 锚点项顶部距可视区顶部的偏移
}

let currentAnchor: AnchorState | null = null

// 在数据追加前,保存当前可视区第一项作为锚点
function saveAnchor() {
  const firstVisibleIndex = getVisibleRange(scrollTop, containerHeight).startIndex
  const firstItemEl = document.querySelector(`[data-index="${firstVisibleIndex}"]`)
  if (firstItemEl) {
    currentAnchor = {
      index: firstVisibleIndex,
      offsetFromTop:
        firstItemEl.getBoundingClientRect().top - containerEl.getBoundingClientRect().top,
    }
  }
}

// 数据追加后,基于锚点恢复滚动位置
function restoreAnchor() {
  if (!currentAnchor) return
  const newOffset = binarySearchOffset(currentAnchor.index)
  scrollContainer.scrollTop = newOffset - currentAnchor.offsetFromTop
}

中间插入的处理

当 AI 在对话中间补充内容时,所有后续项的索引和偏移全部失效:

function handleMidInsert(insertIndex: number, newItems: Item[]) {
  // 1. 保存锚点
  saveAnchor()

  // 2. 插入数据
  items.splice(insertIndex, 0, ...newItems)

  // 3. 重建锚点所在分段的索引(分段内的偏移全面重算)
  rebuildAffectedSections(insertIndex)

  // 4. 恢复滚动位置
  restoreAnchor()

  // 5. 触发视图更新
  scheduleRender()
}

💡 最佳实践:中间插入场景下,不要试图"增量修正"所有偏移——复杂度极高且易出错。直接重建受影响分段及后续所有锚点,让数据结构保持一致性。


五、平滑滚动与性能调优

🔧 观点:性能优化的终点不是"足够快",而是"用户感知不到任何延迟"。

滚动事件节流

// passive 告诉浏览器不会调用 preventDefault(),允许提前优化滚动
scrollContainer.addEventListener('scroll', handleScroll, { passive: true })

let ticking = false

function handleScroll() {
  if (!ticking) {
    requestAnimationFrame(() => {
      updateVisibleRange(scrollContainer.scrollTop)
      ticking = false
    })
    ticking = true
  }
}

GPU 合成层优化

// 使用 transform 替代 top/left 布局动画,让浏览器将整个列表容器提升到独立的 GPU 合成层
const wrapperStyle: React.CSSProperties = {
  willChange: 'transform',
  transform: 'translate3d(0, 0, 0)',
}

// 列表整体偏移也通过 transform 实现,避免 layout thrashing
const innerStyle = (offsetY: number): React.CSSProperties => ({
  transform: `translate3d(0, ${offsetY}px, 0)`,
})

❗ 关于 will-change:必须谨慎使用。提前告知浏览器该元素将发生变化,浏览器会提前创建合成层。但滥用会导致 GPU 内存暴涨。仅在滚动容器上使用,不要对每个列表项单独设置。

缓冲区(overscan)大小选择

缓冲区大小内存占用白屏风险适用场景
0(仅可视区)最低滚动时白屏❌ 不推荐
可视区 × 0.5快速滚动白屏高度确定、慢速滚动
可视区 × 1.0基本无白屏✅ 通用推荐
可视区 × 2.0无白屏高帧率快速滚动
const OVERSCAN_RATIO = 1.0 // 缓冲区倍率

function getVisibleRangeWithOverscan(scrollTop: number, containerHeight: number) {
  const overscanHeight = containerHeight * OVERSCAN_RATIO
  const viewStart = scrollTop - overscanHeight
  const viewEnd = scrollTop + containerHeight + overscanHeight

  // 二分查找起始和结束索引
  const startIndex = binarySearchIndexByOffset(Math.max(0, viewStart))
  const endIndex = binarySearchIndexByOffset(viewEnd)

  return { startIndex, endIndex }
}

骨架屏占位

数万项时,即使虚拟滚动也难免出现短暂白屏(首次布局、快速跳转等场景)。使用骨架屏占位消除"无内容"的视觉空白:

function SkeletonPlaceholder({ count, itemHeight }: { count: number; itemHeight: number }) {
  return (
    <div style={{ height: count * itemHeight }}>
      {Array.from({ length: Math.min(count, 5) }).map((_, i) => (
        <div key={i} className="skeleton-item" style={{ height: itemHeight }}>
          <div className="skeleton-line skeleton-line--short" />
          <div className="skeleton-line skeleton-line--long" />
        </div>
      ))}
    </div>
  )
}
/* 骨架屏动画 */
.skeleton-line {
  height: 14px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}

结语

虚拟滚动的实现远不止"隐藏视口外元素"这么简单。在 AI 流式内容渲染这一特殊场景下,动态高度计算、快速定位、流式插入保护和渲染性能调优四个维度相互制约,每一个决策都在准确性和性能之间做权衡。

回顾五层认知体系:

  1. 基础模型——理解固定/动态高度下的索引计算原理;
  2. 高度策略——根据场景选择预估、实测或 Observer 方案;
  3. 定位算法——用分段索引 + 二分查找实现 O(log n) 跳转;
  4. 流式内容——用锚点机制在数据持续变化中保持用户视口稳定;
  5. 性能调优——passive 滚动、GPU 合成层、缓冲区与骨架屏的协同。

面对日益增长的超长列表渲染需求,虚拟滚动不是可选项,而是必须深入掌握的基础设施。理解其背后的数学原理和浏览器渲染机制,才能在面对新场景时做出正确的工程决策。