在不定高场景(如大模型流式输出、卡片高度不固定)中,单纯依赖“固定高度 索引”计算位置会导致页面卡顿、高度计算错误以及可视区域抖动(Jitter)。
解决这两个问题的核心手段是:
- 预估高度 + 动态位置缓存 (Position Cache):初始化时使用预估高度,渲染后通过 DOM 实际测量更新缓存并修正所有后续元素的偏移。
- 锚点定位(大数锚点/视口锁定):在测量高度更新后,以视口内某个固定元素(锚点)为基准,反向修正容器的
scrollTop,防止上方元素高度变化导致视口突然跳动。
不定高虚拟列表 + 锚点防跳动 完整实现代码
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo } from 'react';
interface Position {
index: number;
height: number;
top: number;
bottom: number;
}
interface ItemData {
id: string | number;
content: string;
}
interface VariableVirtualListProps {
items: ItemData[];
estimatedItemHeight?: number; // 预估高度
containerHeight?: number; // 容器高度
bufferCount?: number; // 缓冲区数量
}
export function VariableVirtualList({
items,
estimatedItemHeight = 80,
containerHeight = 600,
bufferCount = 2,
}: VariableVirtualListProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const itemsRef = useRef<Map<number, HTMLDivElement>>(new Map());
const [scrollTop, setScrollTop] = useState(0);
// 1. 位置信息缓存:维护每个 item 的 index, height, top, bottom
const positionsRef = useRef<Position[]>([]);
// 初始化或当 items 增加时扩充 positions 缓存
if (positionsRef.current.length !== items.length) {
const currentLen = positionsRef.current.length;
const newPositions: Position[] = [];
for (let i = currentLen; i < items.length; i++) {
const prevBottom = i > 0 ? (positionsRef.current[i - 1]?.bottom ?? i * estimatedItemHeight) : 0;
newPositions.push({
index: i,
height: estimatedItemHeight,
top: prevBottom,
bottom: prevBottom + estimatedItemHeight,
});
}
positionsRef.current = [...positionsRef.current, ...newPositions];
}
// 2. 二分查找:根据 scrollTop 寻找第一个 bottom > scrollTop 的元素索引
const findStartIndex = useCallback((scrollTopValue: number): number => {
const positions = positionsRef.current;
let start = 0;
let end = positions.length - 1;
let tempIndex = 0;
while (start <= end) {
const mid = Math.floor((start + end) / 2);
const midBottom = positions[mid].bottom;
if (midBottom === scrollTopValue) {
return mid + 1;
} else if (midBottom < scrollTopValue) {
start = mid + 1;
} else {
tempIndex = mid;
end = mid - 1;
}
}
return tempIndex;
}, []);
// 3. 计算可视区域索引范围
const startIndex = useMemo(() => findStartIndex(scrollTop), [scrollTop, findStartIndex]);
// 计算可渲染的理论数量
const visibleCount = Math.ceil(containerHeight / estimatedItemHeight);
// 加上缓冲区边界
const renderStartIndex = Math.max(0, startIndex - bufferCount);
const renderEndIndex = Math.min(items.length, startIndex + visibleCount + bufferCount);
// 真实渲染的数据切片
const visibleItems = useMemo(() => {
return items.slice(renderStartIndex, renderEndIndex).map((item, idx) => ({
...item,
actualIndex: renderStartIndex + idx,
}));
}, [items, renderStartIndex, renderEndIndex]);
// 4. 动态测量渲染后的 DOM 真实高度,并更新 positions 缓存与滚动校准(大数锚点机制)
useLayoutEffect(() => {
if (!containerRef.current || visibleItems.length === 0) return;
// A. 记录更新前的“锚点”元素(通常是视口顶部第一个显示的真实元素及其相对视口顶部的偏置量)
const anchorIndex = startIndex;
const anchorItemPosBefore = positionsRef.current[anchorIndex];
const anchorOffset = scrollTop - (anchorItemPosBefore ? anchorItemPosBefore.top : 0);
let hasMeasuredChange = false;
// B. 测量并更新当前渲染元素的真实高度
visibleItems.forEach(({ actualIndex }) => {
const node = itemsRef.current.get(actualIndex);
if (node) {
const rect = node.getBoundingClientRect();
const realHeight = rect.height;
const oldHeight = positionsRef.current[actualIndex].height;
const dHeight = realHeight - oldHeight;
// 若高度存在偏差(比如预估 80,实际渲染了 120),则修正缓存
if (Math.abs(dHeight) > 0.5) {
hasMeasuredChange = true;
positionsRef.current[actualIndex].height = realHeight;
positionsRef.current[actualIndex].bottom += dHeight;
// 向后累加更新所有受影响元素的 top 和 bottom
for (let i = actualIndex + 1; i < positionsRef.current.length; i++) {
positionsRef.current[i].top = positionsRef.current[i - 1].bottom;
positionsRef.current[i].bottom = positionsRef.current[i].top + positionsRef.current[i].height;
}
}
}
});
// C. 大数锚点校准(视口防跳动核心):如果上方元素高度发生了变更,根据锚点重置 scrollTop
if (hasMeasuredChange && containerRef.current) {
const anchorItemPosAfter = positionsRef.current[anchorIndex];
if (anchorItemPosAfter) {
// 计算锚点元素更新后的全新 top 坐标,加上原先的偏置量
const correctedScrollTop = anchorItemPosAfter.top + anchorOffset;
containerRef.current.scrollTop = correctedScrollTop;
setScrollTop(correctedScrollTop);
}
}
}, [visibleItems, startIndex, scrollTop]);
// 5. 监听滚动事件
const handleScroll = useCallback(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
}, []);
// 获取整个列表的真实预估/总高度
const totalHeight = positionsRef.current[positionsRef.current.length - 1]?.bottom || 0;
// 偏移量计算:当前渲染的第一个元素的 top 坐标
const startOffset = positionsRef.current[renderStartIndex]?.top || 0;
return (
<div
ref={containerRef}
onScroll={handleScroll}
style={{
height: `${containerHeight}px`,
overflowY: 'auto',
position: 'relative',
border: '1px solid #ccc',
}}
>
{/* 1. 撑开滚动条的幻影占位层 */}
<div style={{ height: `${totalHeight}px`, width: '100%', position: 'absolute', left: 0, top: 0 }} />
{/* 2. 实际渲染的内容层 */}
<div
style={{
transform: `translateY(${startOffset}px)`,
position: 'absolute',
left: 0,
right: 0,
top: 0,
}}
>
{visibleItems.map(({ id, content, actualIndex }) => (
<div
key={id}
ref={(el) => {
if (el) itemsRef.current.set(actualIndex, el);
else itemsRef.current.delete(actualIndex);
}}
style={{
padding: '12px',
borderBottom: '1px solid #eee',
boxSizing: 'border-box',
background: '#fff',
}}
>
<div style={{ color: '#888', fontSize: '12px' }}>Item #{actualIndex}</div>
<div>{content}</div>
</div>
))}
</div>
</div>
);
}
核心解法分析
- 二分查找加速查找 (
findStartIndex): 在静态列表里可以直接使用Math.floor(scrollTop / itemHeight)求解;但在不定高列表中,由于各个 item 高度不均匀,需要借助二分查找()在缓存数据positions中快速查找bottom > scrollTop的临界点。 - ResizeObserver / 动态测量 (
useLayoutEffect): DOM 首次渲染完毕后,在浏览器绘制(Paint)之前,同步测量组件的实际高度rect.height。如果真实高度与预估高度存在差异,修改positions列表并向下修正所有后续元素的top和bottom偏移。 - 大数锚点 / 视口防跳动 (Viewport Anchor):
- 问题根源:当用户向上滚动或者在大模型输出文本流(LLM Streaming)时,视口上方未测量的元素被渲染并修正了高度。如果不作处理,上方元素高度膨胀会把下面的元素往推,导致用户眼前的画面发生闪烁与跳动。
- 锚点原理:在更新高度前,记住视口顶部当前看到的这个 Item 的 Index 以及鼠标/视口相对于该元素顶部的偏移像素(
anchorOffset)。在 DOM 高度重新调整后,将scrollTop强行修正为:
这样能够保证无论上方的元素如何动态展开,用户眼前正在阅读的元素在屏幕中的绝对物理位置保持不变。