在不定高场景(如大模型流式输出、卡片高度不固定)中,单纯依赖“固定高度 索引”计算位置会导致页面卡顿、高度计算错误以及可视区域抖动(Jitter)。

解决这两个问题的核心手段是:

  1. 预估高度 + 动态位置缓存 (Position Cache):初始化时使用预估高度,渲染后通过 DOM 实际测量更新缓存并修正所有后续元素的偏移。
  2. 锚点定位(大数锚点/视口锁定):在测量高度更新后,以视口内某个固定元素(锚点)为基准,反向修正容器的 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>
  );
}
 

核心解法分析

  1. 二分查找加速查找 (findStartIndex): 在静态列表里可以直接使用 Math.floor(scrollTop / itemHeight) 求解;但在不定高列表中,由于各个 item 高度不均匀,需要借助二分查找()在缓存数据 positions 中快速查找 bottom > scrollTop 的临界点。
  2. ResizeObserver / 动态测量 (useLayoutEffect): DOM 首次渲染完毕后,在浏览器绘制(Paint)之前,同步测量组件的实际高度 rect.height。如果真实高度与预估高度存在差异,修改 positions 列表并向下修正所有后续元素的 top 和 bottom 偏移。
  3. 大数锚点 / 视口防跳动 (Viewport Anchor):
  • 问题根源:当用户向上滚动或者在大模型输出文本流(LLM Streaming)时,视口上方未测量的元素被渲染并修正了高度。如果不作处理,上方元素高度膨胀会把下面的元素往推,导致用户眼前的画面发生闪烁与跳动。
  • 锚点原理:在更新高度前,记住视口顶部当前看到的这个 Item 的 Index 以及鼠标/视口相对于该元素顶部的偏移像素(anchorOffset)。在 DOM 高度重新调整后,将 scrollTop 强行修正为:

这样能够保证无论上方的元素如何动态展开,用户眼前正在阅读的元素在屏幕中的绝对物理位置保持不变。