虚拟滚动在 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>()
❗ 大坑提醒:动态高度场景下,仅靠预估高度而不做修正,滚动条位置会随着实际渲染逐渐漂移,用户直观感受就是"滚动跳动"。
二、高度计算策略:三种方案的取舍
🔧 观点:高度测量方案的选择,本质是在"准确性""性能""实现复杂度"三者间做权衡。
方案对比
方案一:预估高度 + 渲染后修正
最轻量的实现。先用一个平均高度占位,渲染完成后测量真实高度并回写缓存,重新计算偏移。
// 预估高度方案
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)大小选择
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 流式内容渲染这一特殊场景下,动态高度计算、快速定位、流式插入保护和渲染性能调优四个维度相互制约,每一个决策都在准确性和性能之间做权衡。
回顾五层认知体系:
- 基础模型——理解固定/动态高度下的索引计算原理;
- 高度策略——根据场景选择预估、实测或 Observer 方案;
- 定位算法——用分段索引 + 二分查找实现 O(log n) 跳转;
- 流式内容——用锚点机制在数据持续变化中保持用户视口稳定;
- 性能调优——passive 滚动、GPU 合成层、缓冲区与骨架屏的协同。
面对日益增长的超长列表渲染需求,虚拟滚动不是可选项,而是必须深入掌握的基础设施。理解其背后的数学原理和浏览器渲染机制,才能在面对新场景时做出正确的工程决策。