ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

长会话对话历史的虚拟滚动与高度动态估算

长会话对话历史的虚拟滚动与高度动态估算 长会话对话历史的虚拟滚动与高度动态估算当对话轮次超过两百轮且单条消息内充斥着折叠思考过程、代码块、LaTeX 公式与流式输出文本时传统的 DOM 全量渲染会让页面节点数迅速突破两万。内存占用飙升之余滚动帧率也会从 60fps 跌落至 20fps 左右。引入虚拟列表是标准解法但在 AI 对话这种高度非线性的场景下虚拟滚动面临的核心矛盾并非渲染节流而是“高度的不可预测性与滚动锚定的稳定性”。动态高度的本质冲突常规虚拟列表如处理固定高度表格可以通过索引直接计算当前像素偏移量offset index * itemHeight。但在复杂对话场景中每一项的高度在真正挂载前都是未知数代码块可能在语法高亮完成后展开撑大高度LaTeX 异步排版或图片加载会导致几何尺寸发生二次跳变用户向上翻页加载历史消息时上方新插入节点的尺寸测量会直接冲击现有视口的scrollTop引发肉眼可见的剧烈跳动。解决这个问题的底座是构建一套结合“预估权重”与“自适应测量回填”的动态尺寸状态机。// 消息节点尺寸缓存结构 interface ItemSizeMeta { index: number; height: number; top: number; bottom: number; measured: boolean; // 是否已完成真实 DOM 测量 } export class DynamicSizeManager { private defaultEstimatedHeight: number; private sizeCache: Mapstring | number, ItemSizeMeta new Map(); private orderedKeys: (string | number)[] []; constructor(defaultEstimatedHeight 120) { this.defaultEstimatedHeight defaultEstimatedHeight; } public initKeys(keys: (string | number)[]) { this.orderedKeys keys; let accumulatedTop 0; for (let i 0; i keys.length; i) { const key keys[i]; const existing this.sizeCache.get(key); const height existing existing.measured ? existing.height : this.defaultEstimatedHeight; this.sizeCache.set(key, { index: i, height, top: accumulatedTop, bottom: accumulatedTop height, measured: existing ? existing.measured : false, }); accumulatedTop height; } } public updateMeasuredHeight(key: string | number, measuredHeight: number): number { const meta this.sizeCache.get(key); if (!meta) return 0; const delta measuredHeight - meta.height; if (Math.abs(delta) 0.5 meta.measured) { return 0; // 尺寸未变直接忽略 } meta.height measuredHeight; meta.bottom meta.top measuredHeight; meta.measured true; // 重新计算受影响后续节点的偏移量前缀和更新 const startIndex meta.index; let currentTop meta.bottom; for (let i startIndex 1; i this.orderedKeys.length; i) { const nextKey this.orderedKeys[i]; const nextMeta this.sizeCache.get(nextKey); if (!nextMeta) break; nextMeta.top currentTop; nextMeta.bottom currentTop nextMeta.height; currentTop nextMeta.bottom; } return delta; } public getTotalHeight(): number { if (this.orderedKeys.length 0) return 0; const lastKey this.orderedKeys[this.orderedKeys.length - 1]; return this.sizeCache.get(lastKey)?.bottom || 0; } // 二分查找当前视口覆盖的首个索引 public findStartIndex(scrollTop: number): number { let low 0; let high this.orderedKeys.length - 1; while (low high) { const mid (low high) 1; const key this.orderedKeys[mid]; const meta this.sizeCache.get(key); if (!meta) break; if (meta.bottom scrollTop) { low mid 1; } else if (meta.top scrollTop) { high mid - 1; } else { return mid; } } return Math.max(0, low); } }ResizeObserver 异步收集与微任务批量合并如果每个 DOM 节点挂载或尺寸改变时立刻同步触发尺寸更新与视口重算浏览器会在一帧内经历频繁的 Reflow/Layout Thrashing。采用ResizeObserver配合微任务queueMicrotask或requestAnimationFrame进行批量更新import { useEffect, useRef, useState, useCallback } from react; export function useDynamicVirtualizer(options: { itemCount: number; getItemKey: (index: number) string | number; containerRef: React.RefObjectHTMLDivElement; estimateHeight?: number; overscan?: number; }) { const { itemCount, getItemKey, containerRef, estimateHeight 100, overscan 3 } options; const managerRef useRef(new DynamicSizeManager(estimateHeight)); const [range, setRange] useState({ startIndex: 0, endIndex: 10 }); const pendingUpdates useRefMapstring | number, number(new Map()); const rafId useRefnumber | null(null); // 初始化键序列 useEffect(() { const keys Array.from({ length: itemCount }, (_, i) getItemKey(i)); managerRef.current.initKeys(keys); }, [itemCount, getItemKey]); const flushUpdates useCallback(() { if (pendingUpdates.current.size 0) return; let accumulatedDeltaAboveViewport 0; const container containerRef.current; const currentScrollTop container ? container.scrollTop : 0; pendingUpdates.current.forEach((height, key) { const meta managerRef.current[sizeCache].get(key); const delta managerRef.current.updateMeasuredHeight(key, height); // 如果当前发生高度变化的项目在当前视口上方需要补偿滚动距离防止画面跳动 if (meta meta.bottom currentScrollTop delta ! 0) { accumulatedDeltaAboveViewport delta; } }); pendingUpdates.current.clear(); if (container accumulatedDeltaAboveViewport ! 0) { container.scrollTop accumulatedDeltaAboveViewport; } // 重新计算视口可视范围 if (container) { const scrollTop container.scrollTop; const clientHeight container.clientHeight; const start managerRef.current.findStartIndex(scrollTop); let end start; while (end itemCount) { const key getItemKey(end); const meta managerRef.current[sizeCache].get(key); if (!meta || meta.top scrollTop clientHeight) break; end; } setRange({ startIndex: Math.max(0, start - overscan), endIndex: Math.min(itemCount - 1, end overscan), }); } }, [containerRef, getItemKey, itemCount, overscan]); const measureElement useCallback((key: string | number, element: HTMLElement | null) { if (!element) return; const observer new ResizeObserver((entries) { for (const entry of entries) { const measuredHeight entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; pendingUpdates.current.set(key, measuredHeight); if (!rafId.current) { rafId.current requestAnimationFrame(() { rafId.current null; flushUpdates(); }); } } }); observer.observe(element); return () observer.disconnect(); }, [flushUpdates]); return { range, totalHeight: managerRef.current.getTotalHeight(), measureElement, getOffsetTop: (key: string | number) managerRef.current[sizeCache].get(key)?.top || 0, }; }向上加载历史记录时的滚动锚定Scroll Anchoring在对话流中向上滚动拉取历史消息是极具破坏性的交互。在顶部插入 20 条新消息后容器内容整体下移如果不做滚动干预视口会直接被冲到顶部迫使用户丢失当前的阅读位置。解决该问题需在数据变更前记录视口参考点Anchor Element捕获当前视口最上沿所处的关键元素 Key 以及它距离容器顶部的像素偏移量anchorOffset targetNode.getBoundingClientRect().top - container.getBoundingClientRect().top注入历史数据并批量刷新尺寸索引在 DOM 重新布局后立刻根据该 Key 的新绝对位置与先前的anchorOffset重置container.scrollTop。现代浏览器虽然原生支持overflow-anchor: auto但在虚拟列表频繁卸载和挂载上方节点的极端场景下原生锚定机制容易误判手动实现基于数据 Key 的偏移补偿是最稳妥的做法。尾部流式输出Streaming与自动吸底的平衡对话助手在打字机模式下持续输出 Token内容每秒撑开若干像素。此时必须维护一套“是否锁定在底部”的状态标志当用户主动向上滚动查看上下文时解除底端锁定绝不可粗暴调用scrollIntoView夺取用户的浏览控制权只有当用户处于接近底部的安全阈值区间如距离底部 80px时才在每次测量回填后平滑维持scrollTop scrollHeight - clientHeight。这种兼顾空间动态形变与时间流式演进的虚拟滚动方案既保全了万级节点下 60 帧的轻快手感又化解了图文排版带来的尺寸抖动。
返回列表